392 lines
17 KiB
JavaScript
392 lines
17 KiB
JavaScript
// arkivklar.test.mjs
|
|
// D7 steg 17: path-confinement som LESEGRENSE + klassifisering mot
|
|
// bevaringsforskrifta (FOR-2025-12-19-2729) §§ 7 og 30.
|
|
//
|
|
// Path-confinement er kravets kjerne, og de tre angrepsformene fanges av ULIKE
|
|
// lag. Derfor er de tre SEPARATE cases, ikke en felles «avvis ugyldig sti»:
|
|
// (17a) ../-traversering -> leksikalsk lag
|
|
// (17b) symlenke ut -> realpath-lag (path.resolve loeser IKKE symlenker)
|
|
// (17c) soesken-katalog -> path.sep i prefikssjekken (/uploads vs /uploads-other)
|
|
// En felles case ville bestaatt paa ett bein og skjult at de to andre var borte.
|
|
// Mutasjons-verifisert per bein -- se sesjonsloggen (D7/S49).
|
|
//
|
|
// (17d) skiller ENOENT fra confinement-brudd: realpathSync kaster paa en sti som
|
|
// ikke finnes, og de to feilene krever ulik handling hos kalleren. Maskeres den
|
|
// ene som den andre, blir «fila mangler» rapportert som «angrepsforsoek».
|
|
//
|
|
// Zero npm deps. Moenster: tests/innboks-write.test.mjs (mkdtemp + symlinkSync).
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync, symlinkSync } from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { lesemaal, klassifiser, traverserBundle, rapport } from '../lib/arkivklar.mjs';
|
|
|
|
const FIXTURE = fileURLToPath(new URL('./fixtures/okf-realistic', import.meta.url));
|
|
const SCRIPT = fileURLToPath(new URL('../scripts/arkivklar.mjs', import.meta.url));
|
|
const NAA = '2026-08-02T10:00:00.000Z';
|
|
|
|
function withTmp(fn) {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'arkivklar-'));
|
|
try {
|
|
fn(tmp);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
// ==================== (17a-c) path-confinement som lesegrense ====================
|
|
|
|
test('(17a) lesemaal avviser ../-traversering ut av bundle-rota', () => {
|
|
withTmp((tmp) => {
|
|
const rot = join(tmp, 'bundle');
|
|
mkdirSync(rot, { recursive: true });
|
|
writeFileSync(join(tmp, 'hemmelig.md'), '# utenfor\n');
|
|
|
|
// Assertionen pinner det LEKSIKALSKE laget («maal-sti ... avvist»), ikke
|
|
// bare «avvist». Med en loesere regex bestod denne casen paa
|
|
// realpath-lagets melding -- som ogsaa inneholder «utenfor bundle-rot» --
|
|
// og det leksikalske laget kunne fjernes uten at noen case roednet
|
|
// (mutasjons-verifisert: den gjorde nettopp det).
|
|
assert.throws(
|
|
() => lesemaal(rot, '../hemmelig.md'),
|
|
/maal-sti utenfor bundle-rot avvist/,
|
|
'../-escape skal avvises av det leksikalske laget',
|
|
);
|
|
});
|
|
});
|
|
|
|
test('(17b) lesemaal avviser symlenke som peker UT av bundle-rota', () => {
|
|
withTmp((tmp) => {
|
|
const rot = join(tmp, 'bundle');
|
|
const utenfor = join(tmp, 'utenfor');
|
|
mkdirSync(rot, { recursive: true });
|
|
mkdirSync(utenfor, { recursive: true });
|
|
writeFileSync(join(utenfor, 'hemmelig.md'), '# utenfor\n');
|
|
|
|
// Symlenken ligger INNE i bundlen og er leksikalsk uskyldig: den
|
|
// resolverer under rota. Kun realpath avsloerer at den peker ut.
|
|
symlinkSync(utenfor, join(rot, 'lenke'));
|
|
|
|
assert.throws(
|
|
() => lesemaal(rot, 'lenke/hemmelig.md'),
|
|
/symlink-escape/,
|
|
'symlenke ut av bundlen skal avvises av realpath-laget',
|
|
);
|
|
});
|
|
});
|
|
|
|
test('(17c) lesemaal avviser soesken-katalog med samme prefiks', () => {
|
|
withTmp((tmp) => {
|
|
// Klassisk prefiks-felle: basen /uploads slipper /uploads-other gjennom
|
|
// dersom prefikssjekken mangler path.sep.
|
|
const rot = join(tmp, 'uploads');
|
|
const soesken = join(tmp, 'uploads-other');
|
|
mkdirSync(rot, { recursive: true });
|
|
mkdirSync(soesken, { recursive: true });
|
|
writeFileSync(join(soesken, 'secret.txt'), 'hemmelig\n');
|
|
|
|
// Som (17a): pinner det leksikalske laget, ellers er det path.sep-sjekken
|
|
// denne casen finnes for som blir utestet.
|
|
assert.throws(
|
|
() => lesemaal(rot, '../uploads-other/secret.txt'),
|
|
/maal-sti utenfor bundle-rot avvist/,
|
|
'soesken-katalog med delt prefiks skal avvises av path.sep-sjekken',
|
|
);
|
|
});
|
|
});
|
|
|
|
test('(17d) lesemaal skiller ENOENT fra confinement-brudd', () => {
|
|
withTmp((tmp) => {
|
|
const rot = join(tmp, 'bundle');
|
|
mkdirSync(rot, { recursive: true });
|
|
|
|
// En sti som er LOVLIG innenfor rota, men ikke finnes, skal gi ENOENT --
|
|
// ikke en confinement-feil. Kalleren maa kunne skille de to.
|
|
assert.throws(
|
|
() => lesemaal(rot, 'finnes-ikke.md'),
|
|
(err) => err.code === 'ENOENT',
|
|
'manglende fil innenfor rota skal gi ENOENT, ikke confinement-brudd',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ==================== klassifisering mot §§ 7 og 30 ====================
|
|
|
|
test('klassifiser: navngitte kategorier i § 7 (stat) og § 30 (kommune)', () => {
|
|
// Laast arkitektur-beslutning 8: styringslinjene er PARALLELLE. En type kan
|
|
// treffe en kategori i begge, og da skal BEGGE rapporteres -- modulen velger
|
|
// ikke linje for virksomheten.
|
|
const tildeling = klassifiser('Tildelingsbrev');
|
|
assert.equal(tildeling.stat, 'tildelingsbrev');
|
|
assert.equal(tildeling.kommune, null);
|
|
assert.equal(tildeling.sikring, false);
|
|
|
|
const status = klassifiser('Status');
|
|
assert.equal(status.stat, 'rapportar');
|
|
assert.equal(status.kommune, 'tertialrapportering');
|
|
|
|
const virksomhetsplan = klassifiser('Virksomhetsplan');
|
|
assert.equal(virksomhetsplan.stat, null);
|
|
assert.equal(virksomhetsplan.kommune, 'handlingsprogram');
|
|
|
|
const retro = klassifiser('Retrospektiv');
|
|
assert.equal(retro.stat, 'evalueringa');
|
|
assert.equal(retro.kommune, 'aarsmelding');
|
|
});
|
|
|
|
test('klassifiser: ikke-navngitt type faller til sikringsparagrafen § 3', () => {
|
|
// Bevaringsforskrifta § 3: det som ikke er nevnt, skal bevares inntil
|
|
// Nasjonalarkivet har fastsett reglar. Ukjent er derfor ALDRI «kan slettes».
|
|
for (const type of ['OKR', 'Overordnede OKR', 'Notat', 'Dokument', 'Heltukjent']) {
|
|
const k = klassifiser(type);
|
|
assert.equal(k.sikring, true, `${type} skal falle til § 3`);
|
|
assert.equal(k.stat, null, `${type} skal ikke paastaas navngitt i § 7`);
|
|
assert.equal(k.kommune, null, `${type} skal ikke paastaas navngitt i § 30`);
|
|
}
|
|
});
|
|
|
|
// ==================== traversering av en ekte bundle ====================
|
|
|
|
test('traverserBundle: klassifiserer fixturens filer og er deterministisk', () => {
|
|
const a = traverserBundle(FIXTURE);
|
|
const b = traverserBundle(FIXTURE);
|
|
|
|
assert.deepEqual(a, b, 'to kjoeringer skal gi identisk resultat (sortert traversering)');
|
|
assert.ok(a.filer.length >= 8, `forventet >= 8 konseptfiler, fant ${a.filer.length}`);
|
|
|
|
// index.md er navigasjon, ikke konsept -- den skal ikke klassifiseres.
|
|
assert.equal(
|
|
a.filer.filter((f) => f.rel.endsWith('index.md')).length,
|
|
0,
|
|
'index.md skal holdes utenfor klassifiseringen',
|
|
);
|
|
|
|
const tildeling = a.filer.find((f) => f.rel === 'strategisk-kontekst/tildelingsbrev-2026.md');
|
|
assert.ok(tildeling, 'fixturens tildelingsbrev skal finnes');
|
|
assert.equal(tildeling.type, 'Tildelingsbrev');
|
|
assert.equal(tildeling.stat, 'tildelingsbrev');
|
|
|
|
const retro = a.filer.find((f) => f.rel === 'historikk/retrospektiv-T3-2025.md');
|
|
assert.ok(retro, 'fixturens retrospektiv skal finnes');
|
|
assert.equal(retro.kommune, 'aarsmelding');
|
|
});
|
|
|
|
test('traverserBundle: symlenket underkatalog som peker ut avvises', () => {
|
|
withTmp((tmp) => {
|
|
const rot = join(tmp, 'bundle');
|
|
const utenfor = join(tmp, 'utenfor');
|
|
mkdirSync(join(rot, 'dokumenter'), { recursive: true });
|
|
mkdirSync(utenfor, { recursive: true });
|
|
writeFileSync(join(utenfor, 'hemmelig.md'), '---\ntype: Notat\n---\n\n# ute\n');
|
|
writeFileSync(join(rot, 'dokumenter', 'ok.md'), '---\ntype: Notat\n---\n\n# inne\n');
|
|
symlinkSync(utenfor, join(rot, 'lekkasje'));
|
|
|
|
assert.throws(
|
|
() => traverserBundle(rot),
|
|
/symlink-escape/,
|
|
'traverseringen skal stoppe paa en symlenket katalog som peker ut',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ==================== (18) rapporten tar ikke stilling ====================
|
|
|
|
// FORBUDTE ORD skannes i den GENERERTE RAPPORTEN, aldri i kildefila.
|
|
// scripts/arkivklar.mjs og lib/arkivklar.mjs baerer selv ordene «slett»/«kassasjon»
|
|
// i forklarende prosa («ingen slettemodell finnes») -- en vakt som skannet kilden
|
|
// ville felt sin egen forbydende tekst og maattet mykes opp til den ble verdiloes.
|
|
// Det er S48-defektklassen; skann utfallet, ikke intensjonen.
|
|
const IMPERATIV = /\b(slett|slette|slettes|kasser|kassere|kasseres|fjern|fjernes)\b/i;
|
|
|
|
test('(18a) rapporten inneholder ingen kassasjons- eller slette-imperativ', () => {
|
|
const linjer = rapport(traverserBundle(FIXTURE), { naa: NAA }).split('\n');
|
|
const treff = linjer.filter((l) => IMPERATIV.test(l)).map((l) => l.trim());
|
|
assert.deepEqual(
|
|
treff,
|
|
[],
|
|
`rapporten skal aldri instruere kassasjon/sletting:\n${treff.join('\n')}`,
|
|
);
|
|
});
|
|
|
|
test('(18b) parser-sanity: IMPERATIV-vakten fanger faktisk et slikt imperativ', () => {
|
|
// Uten denne ville (18a) bestaatt trivielt paa en regex som ikke matcher noe.
|
|
assert.ok(IMPERATIV.test('Disse filene kan slettes etter 2027.'), 'vakten skal treffe et ekte imperativ');
|
|
assert.ok(!IMPERATIV.test('Vurderingen hoerer til virksomhetens dokumentasjonsplan.'), 'legitim prosa skal gaa klar');
|
|
});
|
|
|
|
test('(18c) rapporten baerer paragrafhenvisning og vurderingsformulering', () => {
|
|
const ut = rapport(traverserBundle(FIXTURE), { naa: NAA });
|
|
for (const forventet of ['§ 7', '§ 30', '§ 3', 'arkivlova § 8', 'arkivforskrifta § 12 b', 'vurder']) {
|
|
assert.ok(ut.toLowerCase().includes(forventet.toLowerCase()), `rapporten mangler «${forventet}»`);
|
|
}
|
|
});
|
|
|
|
test('(18d) rapporten navngir den ukjente tilstanden etter arkivforskrifta § 1 bokstav c', () => {
|
|
// Vurderingen er betinget av om dokumentasjonen ALLEREDE forvaltes som arkiv i
|
|
// et annet system -- en tilstand verktoeyet ikke kan se. Rapporten maa si det,
|
|
// ikke stilltiende anta det ene eller det andre.
|
|
const ut = rapport(traverserBundle(FIXTURE), { naa: NAA });
|
|
assert.ok(/§ 1 bokstav c/.test(ut), 'rapporten skal navngi arkivforskrifta § 1 bokstav c');
|
|
assert.ok(/ukjent/i.test(ut), 'rapporten skal navngi tilstanden som ukjent');
|
|
});
|
|
|
|
test('(18e) rapporten er deterministisk (to kjoeringer byte-identisk)', () => {
|
|
const a = rapport(traverserBundle(FIXTURE), { naa: NAA });
|
|
const b = rapport(traverserBundle(FIXTURE), { naa: NAA });
|
|
assert.equal(a, b, 'samme input + samme klokke skal gi byte-identisk rapport');
|
|
});
|
|
|
|
// ==================== (18f-h) CLI-form og exit-koder ====================
|
|
|
|
function kjor(args, env = {}) {
|
|
try {
|
|
const stdout = execFileSync(process.execPath, [SCRIPT, ...args], {
|
|
encoding: 'utf8',
|
|
env: { ...process.env, OKR_NOW: NAA, ...env },
|
|
});
|
|
return { code: 0, stdout };
|
|
} catch (e) {
|
|
return { code: e.status, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
|
|
}
|
|
}
|
|
|
|
test('(18f) CLI paa fixturen gir exit 0 og skriver rapporten til stdout', () => {
|
|
const r = kjor([FIXTURE]);
|
|
assert.equal(r.code, 0, `forventet exit 0, fikk ${r.code}`);
|
|
assert.ok(/§ 7/.test(r.stdout), 'stdout skal baere rapporten');
|
|
assert.deepEqual(
|
|
r.stdout.split('\n').filter((l) => IMPERATIV.test(l)),
|
|
[],
|
|
'CLI-utskriften skal heller ikke baere slette-imperativ',
|
|
);
|
|
});
|
|
|
|
test('(18g) CLI uten argument gir exit 2 (bruksfeil)', () => {
|
|
assert.equal(kjor([]).code, 2, 'manglende bundle-rot skal gi exit 2');
|
|
});
|
|
|
|
test('(18h) CLI paa ikke-eksisterende rot gir exit 2', () => {
|
|
withTmp((tmp) => {
|
|
assert.equal(kjor([join(tmp, 'finnes-ikke')]).code, 2, 'ukjent rot skal gi exit 2');
|
|
});
|
|
});
|
|
|
|
test('(18i) CLI skriver rapport atomisk til oppgitt fil, uten aa roere bundlen', () => {
|
|
withTmp((tmp) => {
|
|
const ut = join(tmp, 'arkivklar-rapport.md');
|
|
const r = kjor([FIXTURE, ut]);
|
|
assert.equal(r.code, 0, `forventet exit 0, fikk ${r.code}`);
|
|
const skrevet = readFileSync(ut, 'utf8');
|
|
assert.ok(/§ 7/.test(skrevet), 'fila skal baere rapporten');
|
|
assert.deepEqual(
|
|
readdirSync(tmp).filter((n) => n.endsWith('.tmp')),
|
|
[],
|
|
'ingen .tmp skal lekke (atomisk temp + rename)',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ==================== (19) kommandoflaten og registrering ====================
|
|
|
|
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
const les = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
|
|
|
// Presedens: commands/sporing.md:4 -- en kommando uten skriveverktoey KAN ikke
|
|
// skrive, uansett hva prosaen lover. Tool-lista er den haandhevbare grensen;
|
|
// prosaen er det ikke. /okr:arkivklar er raadgivende og skal aldri kunne endre
|
|
// treet den vurderer.
|
|
const SKRIVEVERKTOEY = ['Write', 'Edit', 'NotebookEdit', 'MultiEdit'];
|
|
|
|
function allowedTools(rel) {
|
|
const m = les(rel).match(/^allowed-tools:\s*(.+)$/m);
|
|
assert.ok(m, `${rel}: mangler allowed-tools i frontmatter`);
|
|
return m[1].split(',').map((t) => t.trim());
|
|
}
|
|
|
|
test('(19a) arkivklar-kommandoen deklarerer ingen skriveverktoey', () => {
|
|
const tools = allowedTools('commands/arkivklar.md');
|
|
const forbudte = tools.filter((t) => SKRIVEVERKTOEY.includes(t));
|
|
assert.deepEqual(
|
|
forbudte,
|
|
[],
|
|
`/okr:arkivklar er lesende og skal ikke ha skriveverktoey; fant: ${forbudte.join(', ')}`,
|
|
);
|
|
assert.ok(tools.includes('Read'), 'kommandoen trenger Read');
|
|
assert.ok(tools.includes('Bash'), 'kommandoen trenger Bash for aa kjoere skriptet');
|
|
});
|
|
|
|
test('(19b) parser-sanity: vakten feiler faktisk om et skriveverktoey legges til', () => {
|
|
// Uten denne beviser (19a) bare at lista ikke inneholder noe -- den kunne
|
|
// bestaatt paa en tom/uparset liste. Her mates vakten en liste som SKAL felle.
|
|
const mutant = ['Read', 'Bash', 'Glob', 'Write'];
|
|
assert.deepEqual(
|
|
mutant.filter((t) => SKRIVEVERKTOEY.includes(t)),
|
|
['Write'],
|
|
'vakten skal fange Write om det legges til',
|
|
);
|
|
});
|
|
|
|
test('(19c) arkivklar er registrert paa alle kommandoflater', () => {
|
|
// D6-beslutning 7: en levert form skal synes paa ALLE flater samtidig.
|
|
// Sjekklista er commands/<navn>.md, commands/help.md, CLAUDE.md, README.md --
|
|
// help.md og README.md var nettopp de to som ble oversett i steg 12.
|
|
const mangler = [];
|
|
for (const f of ['CLAUDE.md', 'README.md', 'commands/help.md']) {
|
|
if (!/okr:arkivklar/.test(les(f))) mangler.push(f);
|
|
}
|
|
assert.deepEqual(mangler, [], `/okr:arkivklar mangler paa flate(r):\n${mangler.join('\n')}`);
|
|
});
|
|
|
|
test('(19d) GDPR-posisjonen staar i governance og ender paa «behold, men skjerm»', () => {
|
|
const gov = les('skills/okr-offentlig-sektor/references/okr-offentlig-governance.md');
|
|
for (const forventet of [
|
|
'klart fastsett', // arkivlova § 13 tredje punktum, ordrett
|
|
'andre måtar enn ved kassasjon',
|
|
'17 nr. 3', // GDPR art. 17 nr. 3 b og d
|
|
'LOV-2025-06-20-96',
|
|
'FOR-2025-12-19-2729',
|
|
'Nasjonalarkivet',
|
|
]) {
|
|
assert.ok(gov.includes(forventet), `governance mangler «${forventet}»`);
|
|
}
|
|
assert.ok(/sperrer|skjerm/i.test(gov), 'posisjonen skal formuleres som «sletter ikke, sperrer»');
|
|
|
|
// Datatilsynets vedtakskompetanse er KUN DELVIS verifisert (403 paa
|
|
// Nasjonalarkivets side). Den skal derfor ikke siteres med paragrafnummer.
|
|
assert.ok(
|
|
!/Datatilsynet[^.]{0,80}§\s*\d/.test(gov),
|
|
'Datatilsynets vedtakskompetanse skal ikke siteres med paragrafnummer (kun delvis verifisert)',
|
|
);
|
|
});
|
|
|
|
test('(18j) CLI nekter aa skrive rapporten inn i bundlen den vurderer', () => {
|
|
// Skriptets header lover at rapporten ALDRI havner i bundlen. Prosa som lover
|
|
// noe koden ikke haandhever er nettopp det (19a) avviser for allowed-tools --
|
|
// samme standard maa gjelde her. Rapporten i treet ville dessuten blitt
|
|
// klassifisert av neste kjoering, altsaa selv-forurensning.
|
|
//
|
|
// Kjoerer mot en TMP-bundle, ikke den delte fixturen: i RED-fasen (foer vakten
|
|
// fantes) skrev nettopp denne casen `rapport.md` inn i tests/fixtures/ og
|
|
// roednet okf-check-suiten. Det er STATE-gotchaen «CLI-VERIFY SKRIVER I
|
|
// FIXTUREN» -- en test som kan skitne til delt state, gjoer det til slutt.
|
|
withTmp((tmp) => {
|
|
const rot = join(tmp, 'bundle');
|
|
mkdirSync(join(rot, 'dokumenter'), { recursive: true });
|
|
writeFileSync(join(rot, 'dokumenter', 'notat.md'), '---\ntype: Notat\n---\n\n# Notat\n');
|
|
|
|
const r = kjor([rot, join(rot, 'rapport.md')]);
|
|
assert.equal(r.code, 1, `forventet exit 1, fikk ${r.code}`);
|
|
assert.match(r.stderr ?? '', /inne i bundlen/i, 'feilmeldingen skal si hvorfor');
|
|
assert.ok(!existsSync(join(rot, 'rapport.md')), 'ingenting skal vaere skrevet i bundlen');
|
|
|
|
// Utenfor bundlen skal det fortsatt gaa fint.
|
|
const ok = kjor([rot, join(tmp, 'rapport.md')]);
|
|
assert.equal(ok.code, 0, `rapport utenfor bundlen skal gaa fint, fikk ${ok.code}`);
|
|
assert.ok(existsSync(join(tmp, 'rapport.md')), 'rapporten skal vaere skrevet utenfor bundlen');
|
|
});
|
|
});
|