feat(HSP-082): migrationsmappning + konfliktmodell #92
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user