feat: AI ticket analyzer (phases 1-6)
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>
This commit is contained in:
parent
ea3471d38d
commit
8f8b5ab7be
53 changed files with 9377 additions and 33 deletions
165
lib/services/analyzer/itglue-redact.test.ts
Normal file
165
lib/services/analyzer/itglue-redact.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
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]');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue