Multi-stage LLM pipeline that produces structured analyses of Autotask tickets from local Postgres. Migration 069 + Zod schemas, Stage 0 preprocessor, IT Glue redaction + search, Anthropic SDK wrapper, Stages 1/3/4 (Haiku/Sonnet/Opus), pipeline + cost circuit breaker, job worker (opt-in autostart), 6 API routes, 3 frontend pages, share-row persistence (email send deferred to phase 7). 128 vitest tests, tsc clean. Build journal in docs/wulf-pulse-ticket-analyzer-build-notes.md. Sync: adds syncTicketNotes() + ticket_notes to ordered/date-filtered entities so the analyzer's local mirror stays current via scheduler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
4.4 KiB
TypeScript
165 lines
4.4 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { redact, isSensitiveKey, REDACTED_VALUE } from './itglue-redact';
|
|
|
|
describe('isSensitiveKey', () => {
|
|
it.each([
|
|
['password', true],
|
|
['Password', true],
|
|
['PASSWORD', true],
|
|
['user_password', true],
|
|
['secret', true],
|
|
['client_secret', true],
|
|
['apiKey', true],
|
|
['api_key', true],
|
|
['api-key', true],
|
|
['API_KEY', true],
|
|
['authToken', true],
|
|
['accessToken', true],
|
|
['credentials', true],
|
|
['masterKey', true],
|
|
['privateKey', true],
|
|
['name', false],
|
|
['email', false],
|
|
['id', false],
|
|
['title', false],
|
|
['hostname', false],
|
|
['username', false], // intentional: username alone isn't a credential
|
|
])('%s -> %s', (key, expected) => {
|
|
expect(isSensitiveKey(key)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe('redact', () => {
|
|
it('redacts top-level sensitive keys', () => {
|
|
const input = { id: 'abc', password: 'hunter2', name: 'wifi' };
|
|
expect(redact(input)).toEqual({
|
|
id: 'abc',
|
|
password: REDACTED_VALUE,
|
|
name: 'wifi',
|
|
});
|
|
});
|
|
|
|
it('redacts nested object credentials', () => {
|
|
const input = {
|
|
id: 'abc',
|
|
traits: {
|
|
username: 'svc-account',
|
|
password: 'p@ss',
|
|
api_key: 'ak_123',
|
|
},
|
|
};
|
|
expect(redact(input)).toEqual({
|
|
id: 'abc',
|
|
traits: {
|
|
username: 'svc-account',
|
|
password: REDACTED_VALUE,
|
|
api_key: REDACTED_VALUE,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('redacts inside arrays of objects', () => {
|
|
const input = {
|
|
passwords: [
|
|
{ id: 1, name: 'admin', password: 'topsecret' },
|
|
{ id: 2, name: 'svc', password: 'alsotopsecret' },
|
|
],
|
|
};
|
|
// The outer key "passwords" matches → entire array is redacted.
|
|
expect(redact(input)).toEqual({ passwords: REDACTED_VALUE });
|
|
});
|
|
|
|
it('redacts per-item secrets when the array key is benign', () => {
|
|
const input = {
|
|
accounts: [
|
|
{ id: 1, name: 'admin', password: 'topsecret' },
|
|
{ id: 2, name: 'svc', api_key: 'ak_456' },
|
|
],
|
|
};
|
|
expect(redact(input)).toEqual({
|
|
accounts: [
|
|
{ id: 1, name: 'admin', password: REDACTED_VALUE },
|
|
{ id: 2, name: 'svc', api_key: REDACTED_VALUE },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('redacts whole subtree when key matches even if value is an object', () => {
|
|
const input = {
|
|
org_id: 7,
|
|
auth: {
|
|
type: 'oauth',
|
|
client_id: 'cid',
|
|
client_secret: 'shouldbehidden',
|
|
nested: { tokens: { access: 'a', refresh: 'r' } },
|
|
},
|
|
};
|
|
// Note: "auth" itself does NOT match the pattern, so it's recursed into.
|
|
// Inside, client_id is benign, client_secret matches, nested.tokens matches.
|
|
expect(redact(input)).toEqual({
|
|
org_id: 7,
|
|
auth: {
|
|
type: 'oauth',
|
|
client_id: 'cid',
|
|
client_secret: REDACTED_VALUE,
|
|
nested: { tokens: REDACTED_VALUE },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('preserves non-sensitive values of all primitive types', () => {
|
|
const input = {
|
|
id: 'abc',
|
|
count: 42,
|
|
enabled: true,
|
|
ratio: 0.5,
|
|
tag: null,
|
|
missing: undefined,
|
|
};
|
|
expect(redact(input)).toEqual(input);
|
|
});
|
|
|
|
it('does not mutate the input', () => {
|
|
const input = {
|
|
id: 'abc',
|
|
password: 'hunter2',
|
|
nested: { api_key: 'ak_123', name: 'svc' },
|
|
};
|
|
const snapshot = JSON.parse(JSON.stringify(input));
|
|
redact(input);
|
|
expect(input).toEqual(snapshot);
|
|
});
|
|
|
|
it('returns null/undefined unchanged on sensitive keys with null value', () => {
|
|
expect(redact({ password: null })).toEqual({ password: null });
|
|
expect(redact({ password: undefined })).toEqual({ password: undefined });
|
|
});
|
|
|
|
it('handles primitives at the root', () => {
|
|
expect(redact('plain string')).toBe('plain string');
|
|
expect(redact(42)).toBe(42);
|
|
expect(redact(null)).toBe(null);
|
|
expect(redact(undefined)).toBe(undefined);
|
|
});
|
|
|
|
it('handles top-level arrays', () => {
|
|
const input = [
|
|
{ id: 1, password: 'a' },
|
|
{ id: 2, name: 'b' },
|
|
];
|
|
expect(redact(input)).toEqual([
|
|
{ id: 1, password: REDACTED_VALUE },
|
|
{ id: 2, name: 'b' },
|
|
]);
|
|
});
|
|
|
|
it('does not crash on cyclic references', () => {
|
|
const a: Record<string, unknown> = { id: 1 };
|
|
a.self = a; // cycle
|
|
a.password = 'secret';
|
|
const out = redact(a) as Record<string, unknown>;
|
|
expect(out.id).toBe(1);
|
|
expect(out.password).toBe(REDACTED_VALUE);
|
|
expect(out.self).toBe('[CIRCULAR]');
|
|
});
|
|
});
|