Files
houseplan-card/test/golden-capture-provenance.test.mjs
Codex 3f95dac451 fix: индекс эталонов хранит платформу съёмки, а не платформу приёмки (#571)
Аудит 14.09, §4 и §9. `golden-report.json` не нёс платформу вовсе, поэтому
приёмщик вызывал `captureEnvironment()` у себя и записывал СВОЮ платформу как
платформу кадров. На `ad4000f9` это дало `"platform": "win32"` у кадров,
снятых Linux-прогоном 34853080375, а причина осознанного обхода осталась в
stdout и в индекс не попала — хотя `AGENTS.md` обещает след в обоих местах.
Сами PNG были и остаются целы: 101 свидетель совпал байт-в-байт. Врал
провенанс.

Что сделано:

- отчёт съёмки получил раздел `capture`: платформа, архитектура, сборка
  Chromium, отпечаток материала и — в CI — прогон с попыткой и SHA. Схема
  отчёта поднята до 2;
- приёмка читает среду съёмки из отчёта. Гейт чужой среды теперь судит обе
  стороны: съёмку (из отчёта) и приёмку (свою). Отказ — до единой записи;
- индекс эталонов поднят до схемы 2 и различает `capturedOn` и `acceptedOn`,
  несёт раздел `capture` и причину осознанного обхода в `foreignCapture`;
- отчёт схемы 1 платформы съёмки не несёт физически: это отдельная явная
  ветка, `capturedOn` уезжает `null`. Выдумывать платформу нельзя — ровно этим
  задача и вызвана;
- `--baselines=<dir>` у приёмки: без него проверить «отказ произошёл ДО
  записи» можно было бы только порчей рабочего дерева, то есть никак (#556).

Осознанно отменено решение #455 «не трогать run.mjs»: ради ГЕЙТА цена
фингерпринта не окупалась, ради ПРОВЕНАНСА окупилась — платформу кадров знает
только тот, кто их снял. Плата разовая: пересобран бандл, индекс скриншотов
документации переснят отдельным коммитом. Тест #455 переписан под новый
инвариант, а не удалён.

Свидетели: `test/golden-capture-provenance.test.mjs` — семь проверок, все пять
сценариев приёмки из issue, включая «отказ до записи» и «подмена PNG и неполный
артефакт по-прежнему fail-closed». Мутанты `golden-index-invents-capture-platform`
(возвращает платформу приёмщика) и `golden-report-provenance-optional`
(разрешает отчёт схемы 2 без провенанса) прогнаны лично: оба краснеют.

npm test 2762/2761/0 fail, typecheck чистый.

Issue: #571
User-Visible: no
2026-09-17 23:19:56 +03:00

153 lines
8.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CAPTURE_PROVENANCE_SCHEMA, captureProvenance, reportCaptureProvenance,
} from '../scripts/capture-environment.mjs';
import { GOLDEN_INDEX_SCHEMA, indexCapturedOn } from '../demo/golden/policy.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from '../demo/golden/matrix.mjs';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
// #571. Отчёт съёмки не нёс платформу, и приёмщик записывал в индекс СВОЮ:
// артефакт Linux-прогона 34853080375, принятый на Windows, дал на `ad4000f9`
// запись `"platform": "win32"` у кадров, которых Windows не снимала. Причина
// осознанного обхода при этом жила только в stdout.
//
// Проверки гоняют НАСТОЯЩИЙ `accept.mjs` в отдельном каталоге эталонов: писать
// в рабочий репозиторий тест не имеет права, а подменять модуль — значит
// проверять не то, что исполняется на приёмке.
const ROOT = resolve(fileURLToPath(new URL('../', import.meta.url)));
const BASELINES = resolve(ROOT, 'demo/golden/baselines');
/** Артефакт съёмки: кадры равны принятым эталонам, отчёт — с нужным провенансом. */
function fixture({ platform = 'linux', schema = CAPTURE_PROVENANCE_SCHEMA, capture = undefined } = {}) {
const dir = mkdtempSync(resolve(tmpdir(), 'hp-golden-571-'));
mkdirSync(resolve(dir, 'actual'), { recursive: true });
const index = JSON.parse(readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8'));
const results = GOLDEN_SCENARIOS.map((scenario) => {
const source = resolve(BASELINES, `${scenario.id}.png`);
const actual = resolve(dir, 'actual', `${scenario.id}.png`);
copyFileSync(source, actual);
const sha = createHash('sha256').update(readFileSync(actual)).digest('hex');
return { id: scenario.id, status: 'passed', actualSha256: sha, baselineSha256: sha, diffRatio: 0 };
});
const report = {
schema,
mode: 'verify',
generatedAt: new Date().toISOString(),
matrixVersion: GOLDEN_MATRIX_VERSION,
buildFingerprint: sourceFingerprint(ROOT),
chromium: index.chromium,
results,
};
if (schema >= CAPTURE_PROVENANCE_SCHEMA) {
report.capture = capture === undefined
? { ...captureProvenance({ chromium: index.chromium, buildFingerprint: report.buildFingerprint, env: {} }), platform }
: capture;
}
writeFileSync(resolve(dir, 'golden-report.json'), `${JSON.stringify(report, null, 2)}\n`);
return dir;
}
/** Приёмка в изолированный каталог эталонов: рабочий репозиторий не трогаем. */
function accept(from, { reason = '', expectFailure = false } = {}) {
const sandbox = mkdtempSync(resolve(tmpdir(), 'hp-golden-571-baselines-'));
execFileSync('cp', ['-r', BASELINES + '/.', sandbox]);
const env = { ...process.env };
if (reason) env.HP_ALLOW_FOREIGN_CAPTURE = reason; else delete env.HP_ALLOW_FOREIGN_CAPTURE;
try {
const stdout = execFileSync(process.execPath,
[resolve(ROOT, 'demo/golden/accept.mjs'), '--reviewed', `--from=${from}`, `--baselines=${sandbox}`],
{ cwd: ROOT, env, encoding: 'utf8' });
if (expectFailure) throw new Error('приёмка обязана была отказать, а прошла');
return { stdout, index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')), sandbox };
} catch (error) {
if (!expectFailure) throw error;
return {
error: String(error.stderr || error.message),
index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')),
sandbox,
};
}
}
test('#571 AC1: артефакт Linux принимается, обе стороны провенанса записаны', () => {
const from = fixture({ platform: 'linux' });
const { index } = accept(from);
assert.equal(index.schema, GOLDEN_INDEX_SCHEMA);
assert.equal(index.capturedOn, 'linux', 'платформа КАДРОВ — из отчёта');
assert.equal(index.acceptedOn, process.platform, 'платформа приёмки — своя');
assert.equal(indexCapturedOn(index), 'linux');
assert.equal(index.capture.chromium, index.chromium);
assert.equal(index.foreignCapture, null, 'канон обхода не требует');
rmSync(from, { recursive: true, force: true });
});
test('#571 AC2: чужая среда съёмки без причины — отказ до записи', () => {
const from = fixture({ platform: 'win32' });
const before = readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8');
const { error, index } = accept(from, { expectFailure: true });
assert.match(error, /приёмка отказана/);
assert.match(error, /win32/);
assert.equal(index.capturedOn ?? null, JSON.parse(before).capturedOn ?? null,
'индекс обязан остаться нетронутым: отказ до записи');
rmSync(from, { recursive: true, force: true });
});
test('#571 AC1: чужая среда съёмки с причиной — причина уезжает в индекс', () => {
const from = fixture({ platform: 'win32' });
const reason = 'аудит #571: проверяю ветку осознанного обхода';
const { index, stdout } = accept(from, { reason });
assert.equal(index.capturedOn, 'win32');
assert.equal(index.acceptedOn, process.platform);
assert.deepEqual(index.foreignCapture, { reason });
assert.match(stdout, /Чужая среда разрешена осознанно/);
rmSync(from, { recursive: true, force: true });
});
test('#571 схема 2 fail-closed: раздел capture обязателен', () => {
assert.throws(() => reportCaptureProvenance({ schema: 2 }), /обязан нести раздел capture/);
assert.throws(() => reportCaptureProvenance({ schema: 2, capture: { arch: 'x64' } }), /не называет платформу/);
const from = fixture({ capture: { arch: 'x64', chromium: '1' } });
const { error } = accept(from, { expectFailure: true });
assert.match(error, /не называет платформу съёмки/);
rmSync(from, { recursive: true, force: true });
});
test('#571 старая схема — отдельная явная ветка, а не подстановка своей платформы', () => {
assert.deepEqual(reportCaptureProvenance({ schema: 1 }), { provenance: null, legacy: true });
const from = fixture({ schema: 1 });
const { index, stdout } = accept(from);
assert.equal(index.capturedOn, null, 'выдумывать платформу кадров нельзя');
assert.equal(index.acceptedOn, process.platform);
assert.match(stdout, /Отчёт старой схемы/);
rmSync(from, { recursive: true, force: true });
});
test('#571 индекс любой схемы читается одним правилом', () => {
assert.equal(indexCapturedOn({ schema: 1, platform: 'linux' }), 'linux', 'схема 1: platform — это кадры');
assert.equal(indexCapturedOn({ schema: 2, capturedOn: 'linux', acceptedOn: 'win32' }), 'linux');
assert.equal(indexCapturedOn({ schema: 2, acceptedOn: 'win32' }), null, 'схема 2 без capturedOn ничего не обещает');
assert.equal(indexCapturedOn(null), null);
});
test('#571 подмена PNG и неполный артефакт по-прежнему fail-closed', () => {
const tampered = fixture();
const victim = resolve(tampered, 'actual', `${GOLDEN_SCENARIOS[0].id}.png`);
writeFileSync(victim, Buffer.concat([readFileSync(victim), Buffer.from([0])]));
assert.match(accept(tampered, { expectFailure: true }).error, /candidate changed after capture/);
rmSync(tampered, { recursive: true, force: true });
const incomplete = fixture();
rmSync(resolve(incomplete, 'actual', `${GOLDEN_SCENARIOS[1].id}.png`));
assert.match(accept(incomplete, { expectFailure: true }).error, /review candidate missing/);
rmSync(incomplete, { recursive: true, force: true });
});