G8 실제 rollback 증명 종료와 비-secure origin 회기 리뷰 크래시 수정
G8 마지막 게이트인 receipt-bound 실제 image rollback을 격리 NAS vignette-preview-20260807 에서 실행해 종료했다. Gate6 계약 정정: 감사 대상 current API 이미지가 com.docker.compose.project/service/version image label 을 갖고 있어 "helper 의 compose label 0개" 계약은 감사되지 않은 다른 이미지를 쓰지 않는 한 성립하지 않는다. 계약을 key 부재가 아니라 소속(membership) 으로 바꿔 launch-nas-preview-g8-helpers.py 에 구현했다. image 상속 label 을 baseline 으로 읽고 container 의 모든 compose label 이 baseline 과 같거나 선언된 격리 override 인지 검사하며, 최종 project 는 target 이 아니고 service 는 api/web/db/proxy 가 아니어야 한다. docker run argv 에 target label 을 주입하면 fake-runner 테스트가 먼저 깨진다 (37/37). 실행 결과: - rollback-old receipt nas-g8-723eeef22eab05e63e3fafb0 -> 79ec../c530.. - restore-current receipt nas-g8-2738846cf2cf4fbe8ce0fc26 -> 52e0../6fdb.. - release gate/approval 각 2회 멱등, audit.ci_lifecycle_event rollback/executed 2, audit.ci_human_approval_event authorize_rollback 2, silent auto-promotion 0 - HMAC journal 6-record 체인 검증, health 3/3, OpenAPI 126, auth 401, Web 200 - helper 0, listener 0, 비밀 env 파기. down/volume rm/prune 미실행, 공개 런타임 미접촉 - 계획했던 Windows SSH 터널은 NAS sshd 가 direct-tcpip 를 거부해 사용할 수 없어 sshd 설정 변경 대신 같은 격리 계약의 NAS-side probe 컨테이너로 실행했다 비-secure origin 크래시 수정: 배포된 NAS 프리뷰(평문 HTTP, 비-localhost)에 회기 스펙을 돌려 24건 실패를 확인했고 원인은 하나였다. crypto.randomUUID 는 secure context 전용인데 제품 코드 18곳이 fallback 없이 호출했고 RuptureRepairCard 는 렌더 시점 호출이라 회기 리뷰 라우트 전체가 error boundary 로 떨어졌다. 릴리스 게이트 108/108 은 localhost 후보 스택에서만 돌아 이 경로를 밟은 적이 없다. src/lib/uuid.ts 의 randomUuid() 로 통일하고 fallback 도 crypto.getRandomValues 를 우선 사용해 idempotency key 의 예측 불가능성을 유지했다. 회귀는 insecure-context-uuid.spec.ts 6/6 으로 고정했다(직접 호출 0건 검사 포함). 이 수정은 아직 NAS 에 배포하지 않았다. 검증: API 898, gateway 58, executor 28, probe 11, helper launcher 37, release agent 21, ruff clean, web api-types/typecheck/build, SSOT FAIL 0, SSOT unit 5/5, dashboard E2E 10/10, 학생 폐루프 실 DB 브라우저 4/4(일회용 클론), crypto 수정 후 기존 스펙 회귀 70/70, 복원된 NAS 실제 브라우저 SSE->DB 리뷰 PASS. 부수 발견(열린 항목): 공개 API 가 engine=false 로 degraded 인데 워치독이 이를 감지하지 못한다. engine 판정이 게이트웨이 /health 의 ok 만 보고 claude readiness probe 를 돌리지 않기 때문이다. 같은 .env 와 같은 CLI 로 새 게이트웨이를 다른 포트에 띄우면 즉시 ready 이므로 상주 프로세스의 세션만 죽은 형태다. TODO A절과 대시보드에 기록했다. 이 커밋은 파일 단위로 담겼다. 위 파일들에는 이전 세션의 미커밋 G0~G8 작업이 함께 들어 있으며, hunk 를 쪼개면 대시보드/체커/TODO 정합성이 깨져 SSOT 체커가 실패한다.
This commit is contained in:
parent
76d0b9ae9b
commit
93dd8f82d7
22 changed files with 10057 additions and 473 deletions
|
|
@ -86,7 +86,7 @@ async function openDashboard(page: Page, url: string) {
|
|||
if (url.startsWith("http")) {
|
||||
expect(response?.ok(), `${url} should load over HTTP`).toBeTruthy();
|
||||
}
|
||||
await expect(page.locator("#phase-rail .pr-seg")).toHaveCount(5);
|
||||
await expect(page.locator("#phase-rail .pr-seg")).toHaveCount(6);
|
||||
expect(errors).toEqual([]);
|
||||
}
|
||||
|
||||
|
|
@ -144,9 +144,10 @@ async function readMetrics(page: Page): Promise<DashboardMetrics> {
|
|||
|
||||
function expectDerivedMetrics(metrics: DashboardMetrics) {
|
||||
expect(metrics.total).toBeGreaterThan(0);
|
||||
expect(metrics.done).toBe(metrics.total);
|
||||
expect(metrics.doing).toBe(0);
|
||||
expect(metrics.done).toBe(33);
|
||||
expect(metrics.doing).toBe(2);
|
||||
expect(metrics.planned).toBe(0);
|
||||
expect(metrics.total).toBe(metrics.done + metrics.doing + metrics.planned);
|
||||
expect(metrics.ownerBoard).toBe(
|
||||
metrics.ownerColumns.block + metrics.ownerColumns.decide + metrics.ownerColumns.ext,
|
||||
);
|
||||
|
|
@ -169,10 +170,14 @@ function expectDerivedMetrics(metrics: DashboardMetrics) {
|
|||
filterOwner: String(metrics.ownerCards),
|
||||
filterCrit: String(metrics.crit),
|
||||
});
|
||||
expect(metrics.phaseSegments).toBe(5);
|
||||
expect(metrics.phaseSegments).toBe(6);
|
||||
expect(metrics.trackRows).toBeGreaterThan(1);
|
||||
expect(metrics.trackNames).not.toContain("");
|
||||
expect(metrics.stackSegments).toEqual([String(metrics.done)]);
|
||||
expect(metrics.stackSegments).toEqual(
|
||||
[metrics.done, metrics.doing, metrics.planned]
|
||||
.filter((count) => count > 0)
|
||||
.map(String),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectVisibleCards(page: Page, expected: number) {
|
||||
|
|
@ -213,7 +218,7 @@ test.describe("dev dashboard static command center", () => {
|
|||
expectDerivedMetrics(initial);
|
||||
|
||||
await page.locator('[data-filter="planned"]').click();
|
||||
await expectVisibleCards(page, 0);
|
||||
await expectVisibleCards(page, initial.planned);
|
||||
await expect(page.locator("#board .track-group").first()).not.toBeVisible();
|
||||
await page.locator('[data-filter="done"]').focus();
|
||||
await page.keyboard.press("Enter");
|
||||
|
|
@ -222,7 +227,7 @@ test.describe("dev dashboard static command center", () => {
|
|||
await page.locator('[data-filter="planned"]').focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(page.locator('[data-filter="planned"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await expectVisibleCards(page, 0);
|
||||
await expectVisibleCards(page, initial.planned);
|
||||
|
||||
const rapidSequence = ["all", "crit", "doing", "owner", "planned", "done", "all", "crit", "all"];
|
||||
await page.evaluate((filters) => {
|
||||
|
|
@ -243,7 +248,7 @@ test.describe("dev dashboard static command center", () => {
|
|||
await expect(firstHead).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(firstCard).toHaveClass(/open/);
|
||||
await page.locator('[data-filter="planned"]').click();
|
||||
await expectVisibleCards(page, 0);
|
||||
await expectVisibleCards(page, initial.planned);
|
||||
await page.locator('[data-filter="all"]').click();
|
||||
await expect(firstHead).toHaveAttribute("aria-expanded", "true");
|
||||
await firstHead.click();
|
||||
|
|
|
|||
108
apps/web/e2e/insecure-context-uuid.spec.ts
Normal file
108
apps/web/e2e/insecure-context-uuid.spec.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* `crypto.randomUUID` only exists on secure origins. The isolated NAS preview
|
||||
* is plain HTTP on a LAN/Tailnet address, where calling it directly threw
|
||||
* `crypto.randomUUID is not a function` at render time and replaced the whole
|
||||
* session-review route with the error boundary.
|
||||
*
|
||||
* These tests run against the Vite dev server so the module can be imported
|
||||
* directly, and pin that idempotency keys stay available without a secure
|
||||
* context.
|
||||
*/
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
test.describe("insecure-context idempotency keys", () => {
|
||||
test("randomUuid keeps working when crypto.randomUUID is unavailable", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
// `randomUUID` lives on Crypto.prototype, so shadow it with an own
|
||||
// undefined property instead of deleting it.
|
||||
Object.defineProperty(globalThis.crypto, "randomUUID", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
const probe = await page.evaluate(async () => {
|
||||
const module = await import("/src/lib/uuid.ts");
|
||||
return {
|
||||
randomUuidPresent:
|
||||
typeof (globalThis.crypto as { randomUUID?: unknown }).randomUUID,
|
||||
getRandomValuesPresent: typeof globalThis.crypto.getRandomValues,
|
||||
values: [module.randomUuid(), module.randomUuid(), module.randomUuid()],
|
||||
};
|
||||
});
|
||||
|
||||
expect(probe.randomUuidPresent).toBe("undefined");
|
||||
expect(probe.getRandomValuesPresent).toBe("function");
|
||||
for (const value of probe.values) {
|
||||
expect(value).toMatch(UUID_V4);
|
||||
}
|
||||
expect(new Set(probe.values).size).toBe(3);
|
||||
});
|
||||
|
||||
test("randomUuid degrades once more when Web Crypto is missing entirely", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
// `randomUUID` lives on Crypto.prototype, so shadow it with an own
|
||||
// undefined property instead of deleting it.
|
||||
Object.defineProperty(globalThis.crypto, "randomUUID", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.crypto, "getRandomValues", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
const probe = await page.evaluate(async () => {
|
||||
const module = await import("/src/lib/uuid.ts");
|
||||
const api = globalThis.crypto as {
|
||||
randomUUID?: unknown;
|
||||
getRandomValues?: unknown;
|
||||
};
|
||||
return {
|
||||
randomUuidPresent: typeof api.randomUUID,
|
||||
getRandomValuesPresent: typeof api.getRandomValues,
|
||||
values: [module.randomUuid(), module.randomUuid()],
|
||||
};
|
||||
});
|
||||
|
||||
expect(probe.randomUuidPresent).toBe("undefined");
|
||||
expect(probe.getRandomValuesPresent).toBe("undefined");
|
||||
const values = probe.values;
|
||||
for (const value of values) {
|
||||
expect(value).toMatch(UUID_V4);
|
||||
}
|
||||
expect(new Set(values).size).toBe(2);
|
||||
});
|
||||
|
||||
test("no product source calls crypto.randomUUID without the fallback", async () => {
|
||||
const { readFileSync, readdirSync, statSync } = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const root = path.join(process.cwd(), "src");
|
||||
const offenders: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = path.join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!/\.(ts|tsx)$/.test(entry)) continue;
|
||||
if (full.endsWith(path.join("lib", "uuid.ts"))) continue;
|
||||
if (readFileSync(full, "utf8").includes("crypto.randomUUID")) {
|
||||
offenders.push(path.relative(root, full));
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue