From 23d05d9379c3342ff0e2e1419529367b6097a848 Mon Sep 17 00:00:00 2001 From: sax3l Date: Tue, 22 Sep 2026 15:37:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(HSP-082):=20migration-mapping=20=E2=80=94?= =?UTF-8?q?=20decideEntityMerge=20(T01=20identiska=20namn=20EJ=20ihopslagn?= =?UTF-8?q?a=20utan=20bevis),=20transformRecord=20(T02=20=C3=A5=C3=A4?= =?UTF-8?q?=C3=B6=20byte-exakt=20+=20UTC-normalisering=20med=20original-of?= =?UTF-8?q?fset),=20aclBlockingCheck=20(T03=20saknad=20ACL-mappning=20BLOC?= =?UTF-8?q?KERANDE),=2010=20tester?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/connectors/migration-mapping.ts | 154 +++++++++++++++++++++ tests/connectors/migration-mapping.test.ts | 107 ++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 src/connectors/migration-mapping.ts create mode 100644 tests/connectors/migration-mapping.test.ts diff --git a/src/connectors/migration-mapping.ts b/src/connectors/migration-mapping.ts new file mode 100644 index 0000000..6855bdf --- /dev/null +++ b/src/connectors/migration-mapping.ts @@ -0,0 +1,154 @@ +/** + * `connectors/migration-mapping.ts` — HSP-082: migrationsmappning och + * konfliktmodell — bevara databetydelse, identitet och åtkomst vid byte av + * verktyg. + * + * T01: IDENTICAL NAMES are NOT merged without proof — two entities with + * the same display name but different ids (or different ACL/owner) stay + * SEPARATE and the merge is BLOCKED until provenance evidence is provided. + * T02: Swedish characters (åäö) and timezones survive the transformation + * byte-exactly — the transform is encoding-safe (UTF-8 passthrough) and + * normalizes every timestamp to UTC with an explicit offset record. + * T03: a MISSING ACL mapping is BLOCKING for any protected object — the + * migration refuses to proceed for that object, the rest can proceed. + */ + +export interface MappingEntity { + sourceId: string; + targetId: string; + /** Display name — identical names alone are NOT identity evidence (T01). */ + displayName: string; + /** Provenance evidence that the two are the SAME entity (T01). */ + identityEvidence?: 'same_external_id' | 'content_hash_match' | 'operator_confirmed'; +} + +export interface AclMapping { + targetObjectId: string; + /** The access rules carried over. Missing = BLOCKING for protected objects (T03). */ + entries: ReadonlyArray<{ subjectId: string; permission: 'read' | 'write' | 'admin' }>; +} + +export interface ProtectedObject { + objectId: string; + /** A protected object REQUIRES an explicit ACL mapping (T03). */ + protected: boolean; +} + +export interface EntityMergeDecision { + sourceId: string; + targetId: string; + decision: 'merged' | 'blocked_pending_evidence'; + reason: string; +} + +/** + * T01: identical display names are NOT merged unless identity evidence + * exists (same external id, content-hash match, or an explicit operator + * confirmation). Without evidence the merge is BLOCKED pending proof — + * never silently folded together. + */ +export function decideEntityMerge(candidates: ReadonlyArray): EntityMergeDecision[] { + const byName = new Map(); + for (const e of candidates) { + const key = e.displayName.trim().toLowerCase(); + byName.set(key, [...(byName.get(key) ?? []), e]); + } + const decisions: EntityMergeDecision[] = []; + for (const [name, group] of byName) { + if (group.length <= 1) { + decisions.push({ sourceId: group[0]!.sourceId, targetId: group[0]!.targetId, decision: 'merged', reason: 'unikt namn — inga namnkollisioner' }); + continue; + } + // Multiple entities share the name: merge ONLY pairs with evidence. + const withEvidence = group.filter((e) => e.identityEvidence !== undefined); + const withoutEvidence = group.filter((e) => e.identityEvidence === undefined); + for (const e of group) { + if (withEvidence.includes(e) && group.filter((o) => o === e).length === 1) { + decisions.push({ sourceId: e.sourceId, targetId: e.targetId, decision: 'merged', reason: `identitet bevisad (${e.identityEvidence})` }); + } else if (e.identityEvidence !== undefined) { + decisions.push({ sourceId: e.sourceId, targetId: e.targetId, decision: 'merged', reason: `identitet bevisad (${e.identityEvidence})` }); + } else { + decisions.push({ + sourceId: e.sourceId, + targetId: e.targetId, + decision: 'blocked_pending_evidence', + reason: `'${name}' delas av ${group.length} entiteter utan identitetsbevis — identiska namn slås inte ihop utan bevis (HSP-082-T01)`, + }); + } + } + void withoutEvidence; + } + return decisions; +} + +export interface TransformResult { + /** The transformed record (UTF-8 preserved). */ + record: Record; + /** T02: every timestamp normalized to UTC ISO with the ORIGINAL offset recorded. */ + timestamps: ReadonlyArray<{ field: string; original: string; normalized: string; originalOffset: string }>; +} + +/** + * T02: transform a record — Swedish characters pass through untouched (the + * transform is byte-safe UTF-8, no normalization/decomposition), and every + * timestamp is normalized to UTC with its original offset RECORDED. + */ +export function transformRecord(record: Record): TransformResult { + const timestamps: Array<{ field: string; original: string; normalized: string; originalOffset: string }> = []; + const out: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (typeof value === 'string') { + const isoMatch = value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?)(Z|[+-]\d{2}:?\d{2})?$/); + if (isoMatch) { + const original = value; + const offset = isoMatch[2] ?? '(no offset)'; + const d = new Date(value); + const normalized = d.toISOString(); + timestamps.push({ field: key, original, normalized, originalOffset: offset }); + out[key] = normalized; + continue; + } + out[key] = value; // byte-passthrough — åäö preserved verbatim + continue; + } + if (value !== null && typeof value === 'object') { + const nested = transformRecord(value as Record); + out[key] = nested.record; + timestamps.push(...nested.timestamps); + continue; + } + out[key] = value; + } + return { record: out, timestamps }; +} + +/** + * T03: which protected objects are BLOCKED by a missing ACL mapping? An + * object with NO entry in the ACL map is blocked (its migration refuses); + * unprotected objects proceed. Blocked objects are returned so the caller + * can surface exactly which objects await an ACL mapping. + */ +export function aclBlockingCheck( + objects: ReadonlyArray, + aclMappings: ReadonlyArray, +): { proceed: ReadonlyArray; blocked: ReadonlyArray<{ objectId: string; reason: string }> } { + const mapped = new Set(aclMappings.map((m) => m.targetObjectId)); + const proceed: string[] = []; + const blocked: Array<{ objectId: string; reason: string }> = []; + for (const obj of objects) { + if (obj.protected && !mapped.has(obj.objectId)) { + blocked.push({ + objectId: obj.objectId, + reason: 'skyddat objekt saknar ACL-mappning — BLOCKERANDE för berört objekt (HSP-082-T03); saknad mappning måste läggas in innan migrering', + }); + continue; + } + proceed.push(obj.objectId); + } + return { proceed, blocked }; +} + +export interface ProtectedObjectId { + objectId: string; + protected: boolean; +} diff --git a/tests/connectors/migration-mapping.test.ts b/tests/connectors/migration-mapping.test.ts new file mode 100644 index 0000000..cd0a55b --- /dev/null +++ b/tests/connectors/migration-mapping.test.ts @@ -0,0 +1,107 @@ +/** + * `migration-mapping.test.ts` — HSP-082 acceptance: + * T01: identical names are NOT merged without evidence. + * T02: Swedish characters + timezones are preserved (UTF-8 passthrough, + * UTC normalization with the original offset recorded). + * T03: a missing ACL mapping is BLOCKING for protected objects. + */ +import { describe, expect, it } from 'vitest'; + +import { + decideEntityMerge, + transformRecord, + aclBlockingCheck, + type MappingEntity, + type ProtectedObjectId, +} from '../../src/connectors/migration-mapping'; + +describe('HSP-082-T01: identiska namn slås inte ihop utan bevis', () => { + it('two entities with the SAME name and NO evidence are both BLOCKED pending proof', () => { + const decisions = decideEntityMerge([ + { sourceId: 's1', targetId: 't1', displayName: 'Support' }, + { sourceId: 's2', targetId: 't2', displayName: 'Support' }, + ]); + for (const d of decisions) { + expect(d.decision).toBe('blocked_pending_evidence'); + expect(d.reason).toContain('HSP-082-T01'); + } + }); + + it('entities WITH evidence (same_external_id) merge cleanly even in a name collision', () => { + const decisions = decideEntityMerge([ + { sourceId: 's1', targetId: 't1', displayName: 'Support', identityEvidence: 'same_external_id' }, + { sourceId: 's2', targetId: 't2', displayName: 'Support' }, + ]); + const merged = decisions.find((d) => d.sourceId === 's1')!; + expect(merged.decision).toBe('merged'); + expect(merged.reason).toContain('same_external_id'); + }); + + it('a UNIQUE name merges without extra evidence', () => { + const decisions = decideEntityMerge([ + { sourceId: 's1', targetId: 't1', displayName: 'Unik' }, + ]); + expect(decisions[0]!.decision).toBe('merged'); + }); + + it('case-insensitive name collision is also caught (Support vs support)', () => { + const decisions = decideEntityMerge([ + { sourceId: 's1', targetId: 't1', displayName: 'Support' }, + { sourceId: 's2', targetId: 't2', displayName: 'support' }, + ]); + expect(decisions.every((d) => d.decision === 'blocked_pending_evidence')).toBe(true); + }); +}); + +describe('HSP-082-T02: svenska tecken och tidszoner bevaras', () => { + it('Swedish characters pass through byte-exactly (no normalization/decomposition)', () => { + const result = transformRecord({ name: 'Blåbärsvägen 32 — Göteborg, ÅÄÖåäö' }); + expect(result.record['name']).toBe('Blåbärsvägen 32 — Göteborg, ÅÄÖåäö'); + expect((result.record['name'] as string).includes('ÅÄÖ')).toBe(true); + expect((result.record['name'] as string).includes('åäö')).toBe(true); + }); + + it('timestamps normalize to UTC with the ORIGINAL offset recorded', () => { + const result = transformRecord({ bookedAt: '2026-09-22T14:30:00+02:00' }); + expect(result.timestamps).toHaveLength(1); + const t = result.timestamps[0]!; + expect(t.original).toBe('2026-09-22T14:30:00+02:00'); + expect(t.originalOffset).toBe('+02:00'); + expect(t.normalized).toBe('2026-09-22T12:30:00.000Z'); + expect(result.record['bookedAt']).toBe('2026-09-22T12:30:00.000Z'); + }); + + it('Z-suffixed timestamps record their (no) offset explicitly', () => { + const result = transformRecord({ at: '2026-09-22T12:00:00Z' }); + expect(result.timestamps[0]!.originalOffset).toBe('Z'); + }); + + it('nested timestamps are also normalized and recorded', () => { + const result = transformRecord({ order: { createdAt: '2026-01-01T00:00:00-05:00' } }); + expect(result.timestamps).toHaveLength(1); + expect(result.timestamps[0]!.normalized).toBe('2026-01-01T05:00:00.000Z'); + }); +}); + +describe('HSP-082-T03: saknad ACL-mappning är blockerande för berört skyddat objekt', () => { + const objects: ProtectedObjectId[] = [ + { objectId: 'obj_protected', protected: true }, + { objectId: 'obj_public', protected: false }, + ]; + + it('a protected object WITHOUT an ACL mapping is BLOCKED; unprotected proceeds', () => { + const result = aclBlockingCheck(objects, []); + expect(result.proceed).toEqual(['obj_public']); + expect(result.blocked[0]!.objectId).toBe('obj_protected'); + expect(result.blocked[0]!.reason).toContain('BLOCKERANDE'); + }); + + it('a protected object WITH a mapping proceeds', () => { + const result = aclBlockingCheck( + objects, + [{ targetObjectId: 'obj_protected', entries: [{ subjectId: 's1', permission: 'read' }] }], + ); + expect(result.blocked).toEqual([]); + expect(result.proceed).toContain('obj_protected'); + }); +}); \ No newline at end of file -- 2.54.0