mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Интеграционный тест запускает настоящий accept.mjs в канонической Linux-среде и подтверждает fail-closed отказ до записи эталонов. Issue: #641 User-Visible: no
203 lines
12 KiB
JavaScript
203 lines
12 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { cpSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } 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, captureEnv = 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'));
|
||
// #350/#593: сцена, добавленная в матрицу, живёт без эталона до первой
|
||
// приёмки. Настоящий прогон отчитывается по ней `missing-baseline`, и
|
||
// фикстура обязана моделировать это же, иначе `accept.mjs` справедливо
|
||
// ругается на неполный отчёт.
|
||
const results = GOLDEN_SCENARIOS.map((scenario) => {
|
||
const source = resolve(BASELINES, `${scenario.id}.png`);
|
||
const actual = resolve(dir, 'actual', `${scenario.id}.png`);
|
||
if (!existsSync(source)) {
|
||
// Кадр у новой сцены есть (его и смотрит ревьюер), эталона ещё нет.
|
||
// Байты берём у любой принятой сцены: провенанс не о содержимом кадра.
|
||
copyFileSync(resolve(BASELINES, `${GOLDEN_SCENARIOS[0].id}.png`), actual);
|
||
return {
|
||
id: scenario.id,
|
||
status: 'missing-baseline',
|
||
actualSha256: createHash('sha256').update(readFileSync(actual)).digest('hex'),
|
||
baselineSha256: null,
|
||
diffRatio: null,
|
||
};
|
||
}
|
||
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: captureEnv ?? {
|
||
GITHUB_RUN_ID: '1', GITHUB_RUN_ATTEMPT: '1',
|
||
GITHUB_REPOSITORY: 'Matysh/houseplan-card', GITHUB_SHA: 'a'.repeat(40),
|
||
},
|
||
}), platform }
|
||
: capture;
|
||
}
|
||
writeFileSync(resolve(dir, 'golden-report.json'), `${JSON.stringify(report, null, 2)}\n`);
|
||
return dir;
|
||
}
|
||
|
||
/** Приёмка в изолированный каталог эталонов: рабочий репозиторий не трогаем. */
|
||
const HOST_TEST_ALLOWANCE = process.platform === 'linux'
|
||
? ''
|
||
: '#576: unit-тест приёмки из закреплённого Windows toolchain';
|
||
|
||
function accept(from, { reason = HOST_TEST_ALLOWANCE, expectFailure = false } = {}) {
|
||
const sandbox = mkdtempSync(resolve(tmpdir(), 'hp-golden-571-baselines-'));
|
||
cpSync(BASELINES, sandbox, { recursive: true });
|
||
const env = { ...process.env };
|
||
if (reason) env.HP_ALLOW_FOREIGN_CAPTURE = reason; else delete env.HP_ALLOW_FOREIGN_CAPTURE;
|
||
try {
|
||
// Сцены без эталона объявляются явно: `--expect-new` — это утверждение
|
||
// «я посмотрел на новый кадр», и обойти его фикстура не должна.
|
||
const fresh = GOLDEN_SCENARIOS
|
||
.filter((scenario) => !existsSync(resolve(BASELINES, `${scenario.id}.png`)))
|
||
.map((scenario) => scenario.id);
|
||
const stdout = execFileSync(process.execPath,
|
||
[resolve(ROOT, 'demo/golden/accept.mjs'), '--reviewed', `--from=${from}`, `--baselines=${sandbox}`,
|
||
...(fresh.length ? [`--expect-new=${fresh.join(',')}`] : [])],
|
||
{ 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.localAttestation, null, 'CI source is not presented as local WSL');
|
||
assert.deepEqual(index.foreignCapture, HOST_TEST_ALLOWANCE ? { reason: HOST_TEST_ALLOWANCE } : null,
|
||
'не-Linux хост теста оставляет явный след осознанного обхода');
|
||
rmSync(from, { recursive: true, force: true });
|
||
});
|
||
|
||
test('#641: локальная Linux-съёмка без CI и WSL-аттестации отвергается до записи', {
|
||
skip: process.platform !== 'linux' && 'целевой guard исполняется в канонической Linux-среде приёмки',
|
||
}, () => {
|
||
const from = fixture({ platform: 'linux', captureEnv: {} });
|
||
assert.equal(existsSync(resolve(from, 'wsl-attestation.json')), false,
|
||
'обычный golden:capture не должен неявно получать WSL-аттестацию');
|
||
const before = JSON.parse(readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8'));
|
||
const { error, index } = accept(from, { reason: '', expectFailure: true });
|
||
assert.match(error, /локальная Linux-съёмка не аттестована/);
|
||
assert.deepEqual(index, before, 'отказ обязан произойти до изменения эталонов и индекса');
|
||
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, { reason: '', 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 });
|
||
});
|