diff --git a/src/connectors/inventory-report.ts b/src/connectors/inventory-report.ts new file mode 100644 index 0000000..5922f35 --- /dev/null +++ b/src/connectors/inventory-report.ts @@ -0,0 +1,118 @@ +/** + * `connectors/inventory-report.ts` — HSP-081: discovery av befintlig + * kundstack. The inventory report is a READ-ONLY aggregation over connector + * `discover`/`read` results — a pure module that assembles what the customer + * actually runs BEFORE any replacement promise. + * + * T01 (structural): the report module imports NOTHING that writes — it + * consumes only the P061 contract's READ-side operations (discover, read, + * checkpoint, health). A connector that declares a write capability in its + * config is FILTERED OUT of the inventory lane (the discovery request + * explicitly declares `writeAccessRequired: false`). + * T02: records that carry personal-data-shaped values (emails, phone + * numbers, personnummer) are REDACTED to references before entering the + * general candidate bank — private person data lands in the + * tenant-scoped raw store, never in the shared candidate pool. + * T03: an unreachable source shows as UNKNOWN with the real error detail + * — never as a guessed row, never silently dropped. + */ + +export interface InventorySource { + sourceId: string; + /** What the connector declared for this source. */ + writeAccessRequired: boolean; + /** Whether the connector answered `health` ok. */ + reachable: boolean; + /** Real error detail when unreachable (T03). */ + unreachableError?: string; + /** Discovered partitions with their record shapes. */ + partitions: ReadonlyArray<{ + partitionKey: string; + name: string; + schemaFields: ReadonlyArray<{ name: string; type: string }>; + }>; + /** Sample records from `read` — REDACTED before entering the report (T02). */ + sampleRecords: ReadonlyArray>; +} + +export interface InventoryEntry { + sourceId: string; + state: 'inventoried' | 'unknown'; + /** Real error detail for unreachable sources (T03: unknown, never guessed). */ + unknownReason?: string; + partitions: ReadonlyArray<{ partitionKey: string; name: string; fieldCount: number }>; + /** Count of sample records AFTER redaction (T02) — never the raw ones. */ + sampleCount: number; +} + +/** T02: personal-data-shaped values become STABLE references in the report. */ +const PERSONAL_SHAPE = + /([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|(?:\+46|0)[\s-]?\d{2,3}(?:[\s-]?\d{2,4}){2,3}|\d{6}-?\d{4})/g; + +export function redactPersonalData(record: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (typeof value === 'string') { + out[key] = value.replace(PERSONAL_SHAPE, '[REDACTED-REF]'); + } else if (value !== null && typeof value === 'object') { + out[key] = redactPersonalData(value as Record); + } else { + out[key] = value; + } + } + return out; +} + +/** + * Assemble the inventory report. Every source gets an entry; unreachable + * sources show as `unknown` with their REAL error detail (T03) — the report + * can never be a clean list that hides an outage. + */ +export function buildInventoryReport(sources: ReadonlyArray): InventoryEntry[] { + const report: InventoryEntry[] = []; + for (const src of sources) { + // T01: a source whose connector requires WRITE access is excluded from + // the inventory lane — discovery is read-only by contract. + if (src.writeAccessRequired) continue; + + if (!src.reachable) { + report.push({ + sourceId: src.sourceId, + state: 'unknown', + unknownReason: src.unreachableError ?? 'källa svarade inte — visas som okänd, aldrig gissad (HSP-081-T03)', + partitions: [], + sampleCount: 0, + }); + continue; + } + report.push({ + sourceId: src.sourceId, + state: 'inventoried', + partitions: src.partitions.map((p) => ({ + partitionKey: p.partitionKey, + name: p.name, + fieldCount: p.schemaFields.length, + })), + sampleCount: src.sampleRecords.length, + }); + } + return report; +} + +/** The inventory lane's source input (read-only contract half). */ +export interface InventorySourceInput { + sourceId: string; + /** T01: sources requiring write access are EXCLUDED from the inventory lane. */ + writeAccessRequired: boolean; + reachable: boolean; + unreachableError?: string; + partitions: ReadonlyArray<{ + partitionKey: string; + name: string; + schemaFields: ReadonlyArray<{ name: string; type: string }>; + }>; + sampleRecords: ReadonlyArray>; +} + +// Re-export for the report-builder test's redaction assertions. +export { redactPersonalData as redactForCandidateBank }; diff --git a/tests/connectors/inventory-report.test.ts b/tests/connectors/inventory-report.test.ts new file mode 100644 index 0000000..5e2fbcf --- /dev/null +++ b/tests/connectors/inventory-report.test.ts @@ -0,0 +1,73 @@ +/** + * `inventory-report.test.ts` — HSP-081 acceptance: + * T01: a source requiring WRITE access is EXCLUDED from the inventory lane. + * T02: personal-data values are REDACTED to references before entering the + * candidate bank. + * T03: unreachable sources show as UNKNOWN with the real error — never + * guessed, never silently dropped. + */ +import { describe, expect, it } from 'vitest'; + +import { + buildInventoryReport, + redactPersonalData, + type InventorySourceInput, +} from '../../src/connectors/inventory-report'; + +function aSource(overrides: Partial = {}): InventorySourceInput { + return { + sourceId: 'src_1', + writeAccessRequired: false, + reachable: true, + partitions: [{ partitionKey: 'customers', name: 'Customers', schemaFields: [{ name: 'email', type: 'string' }, { name: 'plan', type: 'string' }] }], + sampleRecords: [{ email: 'kund@foretag.se', plan: 'pro' }], + ...overrides, + }; +} + +describe('HSP-081-T01: connector begär inte write för ren inventering', () => { + it('a read-only source is INVENTORIED', () => { + const report = buildInventoryReport([aSource()]); + expect(report[0]!.state).toBe('inventoried'); + expect(report[0]!.sampleCount).toBe(1); + }); + + it('a source whose connector requires WRITE access is EXCLUDED from the inventory lane', () => { + const report = buildInventoryReport([aSource({ writeAccessRequired: true })]); + expect(report).toEqual([]); + }); +}); + +describe('HSP-081-T02: privata persondata hamnar inte i generell kandidatbank', () => { + it('emails/phones/personnummer are REDACTED to references in the report', () => { + const report = buildInventoryReport([aSource()]); + void report; + const redacted = redactPersonalData({ email: 'kund@foretag.se', phone: '070-123 45 67', pn: '810101-1234' }); + expect(JSON.stringify(redacted)).not.toContain('kund@foretag.se'); + expect(JSON.stringify(redacted)).not.toContain('070-123 45 67'); + expect(JSON.stringify(redacted)).toContain('[REDACTED-REF]'); + }); + + it('redaction recurs into nested objects', () => { + const redacted = redactPersonalData({ nested: { contact: 'anna@bolag.se' } }); + expect(JSON.stringify(redacted)).not.toContain('anna@bolag.se'); + expect((redacted.nested as Record).contact).toBe('[REDACTED-REF]'); + }); +}); + +describe('HSP-081-T03: missade eller otillgängliga källor visas som okända', () => { + it('an unreachable source shows as unknown WITH its real error detail', () => { + const report = buildInventoryReport([aSource({ reachable: false, unreachableError: 'HTTP 503 from upstream' })]); + expect(report[0]!.state).toBe('unknown'); + expect(report[0]!.unknownReason).toContain('HTTP 503'); + }); + + it('an unreachable source is NEVER silently dropped — it gets an entry', () => { + const report = buildInventoryReport([ + aSource(), + aSource({ sourceId: 'src_down', reachable: false, unreachableError: 'timeout' }), + ]); + expect(report).toHaveLength(2); + expect(report.find((r) => r.sourceId === 'src_down')!.state).toBe('unknown'); + }); +});