feat(HSP-082): migrationsmappning + konfliktmodell #92

Merged
admin merged 1 commits from feat/hsp-082-migration-mapping into main 2026-09-22 13:42:44 +00:00
2 changed files with 261 additions and 0 deletions
+154
View File
@@ -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<MappingEntity>): EntityMergeDecision[] {
const byName = new Map<string, MappingEntity[]>();
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<string, unknown>;
/** 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<string, unknown>): TransformResult {
const timestamps: Array<{ field: string; original: string; normalized: string; originalOffset: string }> = [];
const out: Record<string, unknown> = {};
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<string, unknown>);
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<ProtectedObjectId>,
aclMappings: ReadonlyArray<AclMapping>,
): { proceed: ReadonlyArray<string>; 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;
}
+107
View File
@@ -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');
});
});