okr/tests/innboks-write.test.mjs

272 lines
12 KiB
JavaScript

// innboks-write.test.mjs
// Step 7 (SC original + riktig nivaa): konsept-skriver med type->nivaa-ruting og
// ikke-destruktiv original-bevaring. Driver den reelle kjernen
// (splitConcepts -> projectFrontmatter -> resolveRelations) per original og lar
// writeConcepts persistere settet i en temp-bundle. Verifiserer:
// - konsept med strategisk-kontekst/-destRel skrives dit, dokumenter/-destRel dit
// - skrevet fil = frontmatter (verbatim) + body (verbatim), trailing newline
// - originalens sha256 er uendret etter skriv (ikke-destruktiv, SC3)
// - peker-fil i konseptets nivaa baerer original-stien i resource: UTEN noen
// lenkeform i body (B1: en body-lenke til ikke-.md-original feller strict)
// - mal-sti utenfor bundle-rot avvist (../-escape) og home-org-rot avvist
// - ingen .tmp lekker (atomisk temp+renameSync)
// - B5-guards: nekter overskriving av kuratert (ikke-ingestion) fil; tillater
// idempotent re-skriv av egen kilde:innboks-output; avviser reservert navn
// (index.md); avviser kryss-kilde destRel-kollisjon via claimed-registeret
// Zero npm deps. Moenster: tests/org-profile-write.test.mjs (mkdtemp + realpath/sha).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join, dirname } from 'node:path';
import { splitConcepts } from '../lib/innboks-split.mjs';
import { projectFrontmatter } from '../lib/innboks-frontmatter.mjs';
import { resolveRelations } from '../lib/innboks-relations.mjs';
import { writeConcepts } from '../lib/innboks-write.mjs';
const MTIME = new Date('2026-03-15T08:30:00.000Z');
function sha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
// Kjor den reelle kjernen for ett dokument -> konsepter (m/ destRel + frontmatter + body).
function ingestOne(markdown, sourceSlug, sourcePath) {
const raw = splitConcepts(markdown, { sourceSlug });
const projected = raw.map((c) => projectFrontmatter(c, { sourcePath, sourceMtime: MTIME }));
return resolveRelations(projected);
}
// Temp-bundle: <tmp>/.claude/okr (+ innboks/ drop-zone). Ryddes alltid.
function withBundle(fn) {
const tmp = mkdtempSync(join(tmpdir(), 'okrwrite-'));
const bundleRoot = join(tmp, '.claude', 'okr');
const inbox = join(bundleRoot, 'innboks');
mkdirSync(inbox, { recursive: true });
try {
fn({ tmp, bundleRoot, inbox });
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
const TILDELING = '# Tildelingsbrev 2026\n\nStatens vegvesen skal levere paa foelgende maal i 2026.\n';
const MERKNAD = '# Generell merknad\n\nEt fritt notat uten foeringsord i tittel.\n';
test('writeConcepts: type->nivaa-ruting (strategisk-kontekst + dokumenter)', () => {
withBundle(({ bundleRoot, inbox }) => {
const tPath = join(inbox, 'tildeling.txt');
const mPath = join(inbox, 'merknad.txt');
writeFileSync(tPath, TILDELING);
writeFileSync(mPath, MERKNAD);
const tConcepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt');
const mConcepts = ingestOne(MERKNAD, 'merknad', 'innboks/merknad.txt');
const [tc] = tConcepts;
const [mc] = mConcepts;
// Forventning fra Step 5-ruting (sanity foer skriv).
assert.equal(dirname(tc.destRel), 'strategisk-kontekst', 'Tildelingsbrev -> strategisk-kontekst/');
assert.equal(dirname(mc.destRel), 'dokumenter', 'default Dokument -> dokumenter/');
writeConcepts([...tConcepts, ...mConcepts], {
bundleRoot,
originals: [
{ sourceSlug: 'tildeling', path: tPath },
{ sourceSlug: 'merknad', path: mPath },
],
});
const tFile = join(bundleRoot, tc.destRel);
const mFile = join(bundleRoot, mc.destRel);
assert.ok(existsSync(tFile), 'tildelingsbrev-konsept skrevet til strategisk-kontekst/');
assert.ok(existsSync(mFile), 'merknad-konsept skrevet til dokumenter/');
// Skrevet fil = frontmatter (verbatim) + body (verbatim), trailing newline. IKKE re-serialisert.
const tContent = readFileSync(tFile, 'utf8');
assert.ok(tContent.startsWith(tc.frontmatter), 'frontmatter verbatim oeverst');
assert.ok(tContent.includes(tc.body), 'body verbatim bevart');
assert.ok(tContent.endsWith('\n'), 'fil avsluttes med newline');
});
});
test('writeConcepts: original uendret (sha256) + peker-fil uten lenkeform (B1)', () => {
withBundle(({ bundleRoot, inbox }) => {
const tPath = join(inbox, 'tildeling.txt');
writeFileSync(tPath, TILDELING);
const before = sha256(tPath);
const concepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt');
writeConcepts(concepts, {
bundleRoot,
originals: [{ sourceSlug: 'tildeling', path: tPath }],
});
// SC3: ikke-destruktiv -- originalen er byte-uendret.
assert.equal(sha256(tPath), before, 'original-sha256 uendret etter skriv');
assert.ok(existsSync(tPath), 'original finnes fortsatt i drop-zonen');
// Peker-fil i konseptets nivaa: resource baerer stien, body har INGEN
// lenkeform (B1: `- [x.txt](/innboks/x.txt)` er ikke .md -> feller strict).
const level = dirname(concepts[0].destRel);
const pointer = join(bundleRoot, level, 'tildeling.kilde.md');
assert.ok(existsSync(pointer), 'peker-fil skrevet i konseptets nivaa');
const pointerContent = readFileSync(pointer, 'utf8');
assert.match(pointerContent, /^resource: innboks\/tildeling\.txt$/m, 'resource baerer original-stien');
assert.match(pointerContent, /innboks\/tildeling\.txt/, 'original-stien er grep-bar');
assert.doesNotMatch(pointerContent, /\]\(/, 'ingen inline/bilde-lenke i pekerfila (B1)');
assert.doesNotMatch(pointerContent, /^[ \t]{0,3}\[[^\]]+\]:/m, 'ingen referanse-definisjon i pekerfila (B1)');
assert.doesNotMatch(pointerContent, /<a\b|<[a-z][a-z0-9+.-]*:/i, 'ingen HTML-anker/autolink i pekerfila (B1)');
assert.match(pointerContent, /^type: Notat$/m, 'peker baerer gyldig OKF-type (vokab)');
assert.match(pointerContent, /^kilde: innboks$/m, 'peker baerer kilde:innboks (provenans + B5-eierskap)');
});
});
// --- B5 (A2): kollisjons-guards -- treet selv skal vaere uskjermet aldri mer ---
const CURATED = '---\ntype: Status\ntitle: Kuratert status\n---\nHaandskrevet innhold som ALDRI skal overskrives.\n';
function concept(slug, destRel, sourceSlug = 'kilde-a') {
return {
slug,
sourceSlug,
destRel,
frontmatter: '---\ntype: Dokument\nkilde: innboks\n---\n',
body: 'innhold',
};
}
test('writeConcepts: nekter aa overskrive pre-eksisterende kuratert fil (B5)', () => {
withBundle(({ bundleRoot }) => {
const curatedPath = join(bundleRoot, 'dokumenter', 'status.md');
mkdirSync(dirname(curatedPath), { recursive: true });
writeFileSync(curatedPath, CURATED);
assert.throws(
() => writeConcepts([concept('status', 'dokumenter/status.md')], { bundleRoot }),
/kuratert|ikke-ingestion|overskriv/i,
'skriv over kuratert fil (uten kilde:innboks) skal kaste',
);
assert.equal(readFileSync(curatedPath, 'utf8'), CURATED, 'kuratert fil er byte-uendret');
});
});
test('writeConcepts: tillater idempotent re-skriv av egen kilde:innboks-fil (B5)', () => {
withBundle(({ bundleRoot }) => {
const c = concept('notat', 'dokumenter/notat.md');
writeConcepts([c], { bundleRoot });
// Re-ingest (run2): samme fil skrives igjen -- skal IKKE kaste.
writeConcepts([c], { bundleRoot });
const written = readFileSync(join(bundleRoot, 'dokumenter', 'notat.md'), 'utf8');
assert.ok(written.includes('kilde: innboks'), 'ingestion-eierskap staar i skrevet fil');
});
});
test('writeConcepts: reservert navn index.md avvises (B5)', () => {
withBundle(({ bundleRoot }) => {
assert.throws(
() => writeConcepts([concept('index', 'dokumenter/index.md')], { bundleRoot }),
/reservert|index/i,
'destRel med basename index.md skal kaste (indeksering eier index.md)',
);
assert.ok(!existsSync(join(bundleRoot, 'dokumenter', 'index.md')), 'ingenting skrevet');
});
});
test('writeConcepts: kryss-kilde destRel-kollisjon avvises via claimed-register (B5)', () => {
withBundle(({ bundleRoot }) => {
const claimed = new Map();
writeConcepts([concept('status', 'dokumenter/status.md', 'kilde-a')], { bundleRoot, claimed });
// Annen kilde, samme destRel -> kollisjon (stille last-wins var B5-datatapet).
assert.throws(
() => writeConcepts([concept('status', 'dokumenter/status.md', 'kilde-b')], { bundleRoot, claimed }),
/kollisjon|kilde/i,
'samme destRel fra annen kilde i samme kjoering skal kaste',
);
// Samme kilde igjen (relasjons-omskriv i fase 2) -> OK.
writeConcepts([concept('status', 'dokumenter/status.md', 'kilde-a')], { bundleRoot, claimed });
});
});
test('writeConcepts: ../-escape destRel avvist (path-confinement)', () => {
withBundle(({ bundleRoot }) => {
const evil = { slug: 'evil', sourceSlug: 'evil', destRel: '../escape.md', frontmatter: '---\ntype: Dokument\n---\n', body: 'x' };
assert.throws(
() => writeConcepts([evil], { bundleRoot, originals: [] }),
/utenfor bundle-rot/,
'destRel som escaper bundle-rota skal kaste',
);
assert.ok(!existsSync(join(bundleRoot, '..', 'escape.md')), 'ingenting skrevet utenfor bundle');
});
});
test('writeConcepts: skriv til home-org-rot avvist (ingestion roerer aldri ~/.claude/okr/org)', () => {
const homeOrg = join(homedir(), '.claude', 'okr', 'org');
const concept = { slug: 'p', sourceSlug: 'p', destRel: 'dokumenter/p.md', frontmatter: '---\ntype: Dokument\n---\n', body: 'x' };
// Kaster FOER noen skriv -- home-profilen roeres aldri.
assert.throws(
() => writeConcepts([concept], { bundleRoot: homeOrg, originals: [] }),
/home-org-rot avvist/,
'home-org bundleRoot skal kaste',
);
});
test('writeConcepts: atomisk -- ingen .tmp lekker etter skriv', () => {
withBundle(({ bundleRoot, inbox }) => {
const tPath = join(inbox, 'tildeling.txt');
writeFileSync(tPath, TILDELING);
const concepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt');
writeConcepts(concepts, { bundleRoot, originals: [{ sourceSlug: 'tildeling', path: tPath }] });
const level = dirname(concepts[0].destRel);
const leftover = readdirSync(join(bundleRoot, level)).filter((f) => f.endsWith('.tmp'));
assert.deepEqual(leftover, [], 'ingen temp-fil igjen etter atomisk renameSync');
});
});
// --- M2 (A1): realpathSync-confinement -- leksikalsk sjekk alene slipper symlinks ---
test('writeConcepts: symlink-original som peker UT av bundlen avvises (M2)', () => {
withBundle(({ tmp, bundleRoot, inbox }) => {
// Fil UTENFOR bundle-rota; symlink i drop-zonen passerer den leksikalske sjekken.
const outside = join(tmp, 'utenfor.txt');
writeFileSync(outside, 'sensitivt innhold utenfor bundlen');
symlinkSync(outside, join(inbox, 'lenket.txt'));
assert.throws(
() => writeConcepts([], {
bundleRoot,
originals: [{ sourceSlug: 'lenket', path: join(inbox, 'lenket.txt') }],
}),
/symlink|utenfor bundle/,
'symlink-original skal avvises via realpathSync',
);
});
});
test('writeConcepts: symlinket destinasjons-katalog som peker UT av bundlen avvises (M2)', () => {
withBundle(({ tmp, bundleRoot }) => {
// dokumenter/ er en symlink til en katalog utenfor bundlen -> skriv gjennom
// den ville landet utenfor tross leksikalsk '..'-sjekk paa destRel.
const outsideDir = join(tmp, 'ute');
mkdirSync(outsideDir, { recursive: true });
symlinkSync(outsideDir, join(bundleRoot, 'dokumenter'));
const concept = {
slug: 'x',
sourceSlug: 'x',
destRel: 'dokumenter/x.md',
frontmatter: '---\ntype: Dokument\n---\n',
body: 'innhold',
};
assert.throws(
() => writeConcepts([concept], { bundleRoot }),
/symlink|utenfor bundle/,
'destinasjons-parent skal realpath-sjekkes foer skriv',
);
assert.deepEqual(readdirSync(outsideDir), [], 'ingenting skrevet utenfor bundlen');
});
});