feat(HSP-081): discovery-inventory — read-only kartläggning av kundstack #90
@@ -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<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<string, unknown>);
|
||||
} 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<InventorySource>): 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<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
// Re-export for the report-builder test's redaction assertions.
|
||||
export { redactPersonalData as redactForCandidateBank };
|
||||
@@ -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> = {}): 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<string, unknown>).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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user