CI (SIAX Cloud) / integration (push) Has been skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (push) Successful in 23s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / integration (pull_request) Successful in 26s
CI (SIAX Cloud) / security (push) Successful in 39s
CI (SIAX Cloud) / quality (pull_request) Successful in 36s
CI (SIAX Cloud) / quality (push) Successful in 1m7s
107 lines
4.4 KiB
TypeScript
107 lines
4.4 KiB
TypeScript
/**
|
|
* `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');
|
|
});
|
|
}); |