feat(HSP-081): discovery-inventory — read-only kartläggning av kundstack #90

Merged
admin merged 1 commits from feat/hsp-081-discovery-inventory into main 2026-09-22 13:08:22 +00:00
2 changed files with 191 additions and 0 deletions
+118
View File
@@ -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 };
+73
View File
@@ -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');
});
});