평문 환경 숙의 연습 해시 보정
This commit is contained in:
parent
21461ab323
commit
ba5e6326d5
9 changed files with 262 additions and 14 deletions
|
|
@ -477,12 +477,18 @@ test.describe("G4 숙의 연습", () => {
|
||||||
);
|
);
|
||||||
const episodeBody = submitted[0].episode as {
|
const episodeBody = submitted[0].episode as {
|
||||||
episode_id: string;
|
episode_id: string;
|
||||||
attempts: Array<{ attempt_id: string }>;
|
attempts: Array<{
|
||||||
|
attempt_id: string;
|
||||||
|
utterance_template_id?: string | null;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
expect(episodeBody.episode_id).toMatch(/^oas-g4-episode-[a-f0-9-]+$/);
|
expect(episodeBody.episode_id).toMatch(/^oas-g4-episode-[a-f0-9-]+$/);
|
||||||
expect(episodeBody.attempts[0].attempt_id).toMatch(
|
expect(episodeBody.attempts[0].attempt_id).toMatch(
|
||||||
/^oas-g4-attempt-[a-f0-9-]+$/,
|
/^oas-g4-attempt-[a-f0-9-]+$/,
|
||||||
);
|
);
|
||||||
|
expect(episodeBody.attempts[0].utterance_template_id).toBe(
|
||||||
|
"utterance-sha256:c06db0f06811c540ea7d985c7d4e3b880e978e33524128538d6f0ab7b23362aa",
|
||||||
|
);
|
||||||
|
|
||||||
await card.getByRole("button", { name: /05:04.*발화로 이동/ }).click();
|
await card.getByRole("button", { name: /05:04.*발화로 이동/ }).click();
|
||||||
await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute(
|
await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,24 @@ import { expect, test } from "@playwright/test";
|
||||||
* context.
|
* 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}$/;
|
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}$/;
|
||||||
|
const SHA256_VECTORS = [
|
||||||
|
{
|
||||||
|
value: "",
|
||||||
|
digest: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "abc",
|
||||||
|
digest: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "a".repeat(56),
|
||||||
|
digest: "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "지금 이 이야기를 조금 더 다뤄도 괜찮을까요?",
|
||||||
|
digest: "c06db0f06811c540ea7d985c7d4e3b880e978e33524128538d6f0ab7b23362aa",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
test.describe("insecure-context idempotency keys", () => {
|
test.describe("insecure-context idempotency keys", () => {
|
||||||
test("randomUuid keeps working when crypto.randomUUID is unavailable", async ({
|
test("randomUuid keeps working when crypto.randomUUID is unavailable", async ({
|
||||||
|
|
@ -83,6 +101,78 @@ test.describe("insecure-context idempotency keys", () => {
|
||||||
expect(new Set(values).size).toBe(2);
|
expect(new Set(values).size).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sha256Hex matches known vectors when crypto.subtle is unavailable", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Object.defineProperty(globalThis.crypto, "subtle", {
|
||||||
|
value: undefined,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const probe = await page.evaluate(async (vectors) => {
|
||||||
|
const module = await import("/src/lib/sha256.ts");
|
||||||
|
return {
|
||||||
|
subtlePresent: typeof globalThis.crypto.subtle,
|
||||||
|
digests: await Promise.all(
|
||||||
|
vectors.map(({ value }) => module.sha256Hex(value)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}, SHA256_VECTORS);
|
||||||
|
|
||||||
|
expect(probe.subtlePresent).toBe("undefined");
|
||||||
|
expect(probe.digests).toEqual(
|
||||||
|
SHA256_VECTORS.map(({ digest }) => digest),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sha256Hex falls back when a partial subtle implementation throws", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
let digestCalls = 0;
|
||||||
|
Object.defineProperty(globalThis, "__sha256DigestCalls", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => digestCalls,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis.crypto, "subtle", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
digest: async () => {
|
||||||
|
digestCalls += 1;
|
||||||
|
throw new DOMException(
|
||||||
|
"partial Web Crypto implementation",
|
||||||
|
"NotSupportedError",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const probe = await page.evaluate(async (vectors) => {
|
||||||
|
const module = await import("/src/lib/sha256.ts");
|
||||||
|
const digests = await Promise.all(
|
||||||
|
vectors.map(({ value }) => module.sha256Hex(value)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
subtlePresent: typeof globalThis.crypto.subtle,
|
||||||
|
digestCalls: (
|
||||||
|
globalThis as typeof globalThis & { __sha256DigestCalls: number }
|
||||||
|
).__sha256DigestCalls,
|
||||||
|
digests,
|
||||||
|
};
|
||||||
|
}, SHA256_VECTORS);
|
||||||
|
|
||||||
|
expect(probe.subtlePresent).toBe("object");
|
||||||
|
expect(probe.digestCalls).toBe(SHA256_VECTORS.length);
|
||||||
|
expect(probe.digests).toEqual(
|
||||||
|
SHA256_VECTORS.map(({ digest }) => digest),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("no product source calls crypto.randomUUID without the fallback", async () => {
|
test("no product source calls crypto.randomUUID without the fallback", async () => {
|
||||||
const { readFileSync, readdirSync, statSync } = await import("node:fs");
|
const { readFileSync, readdirSync, statSync } = await import("node:fs");
|
||||||
const path = await import("node:path");
|
const path = await import("node:path");
|
||||||
|
|
@ -105,4 +195,28 @@ test.describe("insecure-context idempotency keys", () => {
|
||||||
walk(root);
|
walk(root);
|
||||||
expect(offenders).toEqual([]);
|
expect(offenders).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("no product source calls subtle.digest outside the sha256 fallback", async () => {
|
||||||
|
const { readFileSync, readdirSync, statSync } = await import("node:fs");
|
||||||
|
const path = await import("node:path");
|
||||||
|
const root = path.join(process.cwd(), "src");
|
||||||
|
const fallbackPath = path.join("lib", "sha256.ts");
|
||||||
|
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(fallbackPath)) continue;
|
||||||
|
if (/\bsubtle\s*\??\.\s*digest\s*\(/.test(readFileSync(full, "utf8"))) {
|
||||||
|
offenders.push(path.relative(root, full));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(root);
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
116
apps/web/src/lib/sha256.ts
Normal file
116
apps/web/src/lib/sha256.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
const SHA256_INITIAL = new Uint32Array([
|
||||||
|
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f,
|
||||||
|
0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SHA256_ROUND = new Uint32Array([
|
||||||
|
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b,
|
||||||
|
0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01,
|
||||||
|
0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7,
|
||||||
|
0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||||
|
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152,
|
||||||
|
0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
|
||||||
|
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
|
||||||
|
0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||||
|
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819,
|
||||||
|
0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08,
|
||||||
|
0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f,
|
||||||
|
0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||||
|
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function rotateRight(value: number, bits: number): number {
|
||||||
|
return (value >>> bits) | (value << (32 - bits));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function portableSha256(bytes: Uint8Array): string {
|
||||||
|
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64;
|
||||||
|
const padded = new Uint8Array(paddedLength);
|
||||||
|
padded.set(bytes);
|
||||||
|
padded[bytes.length] = 0x80;
|
||||||
|
|
||||||
|
const view = new DataView(padded.buffer);
|
||||||
|
const bitLength = bytes.length * 8;
|
||||||
|
view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000));
|
||||||
|
view.setUint32(paddedLength - 4, bitLength >>> 0);
|
||||||
|
|
||||||
|
const state = new Uint32Array(SHA256_INITIAL);
|
||||||
|
const words = new Uint32Array(64);
|
||||||
|
for (let offset = 0; offset < paddedLength; offset += 64) {
|
||||||
|
for (let index = 0; index < 16; index += 1) {
|
||||||
|
words[index] = view.getUint32(offset + index * 4);
|
||||||
|
}
|
||||||
|
for (let index = 16; index < 64; index += 1) {
|
||||||
|
const previous = words[index - 15];
|
||||||
|
const recent = words[index - 2];
|
||||||
|
const sigma0 =
|
||||||
|
rotateRight(previous, 7) ^
|
||||||
|
rotateRight(previous, 18) ^
|
||||||
|
(previous >>> 3);
|
||||||
|
const sigma1 =
|
||||||
|
rotateRight(recent, 17) ^
|
||||||
|
rotateRight(recent, 19) ^
|
||||||
|
(recent >>> 10);
|
||||||
|
words[index] =
|
||||||
|
(words[index - 16] + sigma0 + words[index - 7] + sigma1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let a = state[0];
|
||||||
|
let b = state[1];
|
||||||
|
let c = state[2];
|
||||||
|
let d = state[3];
|
||||||
|
let e = state[4];
|
||||||
|
let f = state[5];
|
||||||
|
let g = state[6];
|
||||||
|
let h = state[7];
|
||||||
|
for (let index = 0; index < 64; index += 1) {
|
||||||
|
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
|
||||||
|
const choose = (e & f) ^ (~e & g);
|
||||||
|
const temporary1 =
|
||||||
|
(h + sum1 + choose + SHA256_ROUND[index] + words[index]) >>> 0;
|
||||||
|
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
|
||||||
|
const majority = (a & b) ^ (a & c) ^ (b & c);
|
||||||
|
const temporary2 = (sum0 + majority) >>> 0;
|
||||||
|
h = g;
|
||||||
|
g = f;
|
||||||
|
f = e;
|
||||||
|
e = (d + temporary1) >>> 0;
|
||||||
|
d = c;
|
||||||
|
c = b;
|
||||||
|
b = a;
|
||||||
|
a = (temporary1 + temporary2) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
state[0] = (state[0] + a) >>> 0;
|
||||||
|
state[1] = (state[1] + b) >>> 0;
|
||||||
|
state[2] = (state[2] + c) >>> 0;
|
||||||
|
state[3] = (state[3] + d) >>> 0;
|
||||||
|
state[4] = (state[4] + e) >>> 0;
|
||||||
|
state[5] = (state[5] + f) >>> 0;
|
||||||
|
state[6] = (state[6] + g) >>> 0;
|
||||||
|
state[7] = (state[7] + h) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(state, (word) => word.toString(16).padStart(8, "0")).join(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sha256Hex(value: string): Promise<string> {
|
||||||
|
const bytes = new TextEncoder().encode(value);
|
||||||
|
const subtle = globalThis.crypto?.subtle;
|
||||||
|
if (subtle) {
|
||||||
|
try {
|
||||||
|
return bytesToHex(new Uint8Array(await subtle.digest("SHA-256", bytes)));
|
||||||
|
} catch {
|
||||||
|
// Some embedded or insecure runtimes expose Web Crypto partially.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return portableSha256(bytes);
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
} from "./deliberatePracticeApi";
|
} from "./deliberatePracticeApi";
|
||||||
import "./deliberate-practice.css";
|
import "./deliberate-practice.css";
|
||||||
import { randomUuid } from "../../lib/uuid";
|
import { randomUuid } from "../../lib/uuid";
|
||||||
|
import { sha256Hex } from "../../lib/sha256";
|
||||||
|
|
||||||
interface PracticeTurn {
|
interface PracticeTurn {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -195,14 +196,7 @@ function splitCounterevidence(value: string): string[] {
|
||||||
|
|
||||||
async function phraseFingerprint(phrase: string): Promise<string> {
|
async function phraseFingerprint(phrase: string): Promise<string> {
|
||||||
const normalized = phrase.normalize("NFKC").trim().replace(/\s+/g, " ");
|
const normalized = phrase.normalize("NFKC").trim().replace(/\s+/g, " ");
|
||||||
const digest = await crypto.subtle.digest(
|
return `utterance-sha256:${await sha256Hex(normalized)}`;
|
||||||
"SHA-256",
|
|
||||||
new TextEncoder().encode(normalized),
|
|
||||||
);
|
|
||||||
const hex = Array.from(new Uint8Array(digest), (byte) =>
|
|
||||||
byte.toString(16).padStart(2, "0"),
|
|
||||||
).join("");
|
|
||||||
return `utterance-sha256:${hex}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function turnForEvidence(
|
function turnForEvidence(
|
||||||
|
|
|
||||||
|
|
@ -380,6 +380,16 @@ apps/web/test-results/g7-voice-consent-mobile.png
|
||||||
`/assets/index-*.js`를 차단하지 못한 환경 계약 누락이다. 두 entry를 함께 차단하도록 수정했고 Vite source 2/2와
|
`/assets/index-*.js`를 차단하지 못한 환경 계약 누락이다. 두 entry를 함께 차단하도록 수정했고 Vite source 2/2와
|
||||||
production preview 2/2를 각각 통과했다. 전체 execute를 즉시 반복하지 않고 이 수정의 새 clean commit·archive를
|
production preview 2/2를 각각 통과했다. 전체 execute를 즉시 반복하지 않고 이 수정의 새 clean commit·archive를
|
||||||
다시 결속한 뒤 한 번만 재실행한다.
|
다시 결속한 뒤 한 번만 재실행한다.
|
||||||
|
- 후속 clean commit `21461ab3…6fde`, tree `5c5f12a8…524e`, 2회 동일 archive `20d49694…fa1a` execute는
|
||||||
|
candidate **112/112**를 통과하고 NAS 새 API/Web image `d5021950…`/`376a3aa8…`까지 올렸다. 그러나 실제 NAS-origin
|
||||||
|
postdeploy의 G4 deliberate-practice desktop/mobile 2건이 110/112에서 실패해 active-state commit 전에 이전
|
||||||
|
`52e0e816…`/`6fdbb646…`로 자동 rollback했고 health·auth 401·OpenAPI 126·G0~G8 route와 image ID를 재검증해
|
||||||
|
rollback `verified`로 종료했다.
|
||||||
|
- focused exact-image 평문 origin에서 요청이 API까지 가지 않고 일반 오류로 끝나는 것을 재현했다. 원인은
|
||||||
|
문장 원문을 보내지 않기 위한 fingerprint가 `crypto.subtle.digest`에만 의존해 insecure origin에서 예외가 난 것이다.
|
||||||
|
원문 외부 전송 없이 portable SHA-256 fallback을 추가하고 표준 digest
|
||||||
|
`c06db0f0…62aa`를 payload에서 exact 검증했다. localhost secure origin 2/2와 실제 Tailnet insecure origin 2/2가
|
||||||
|
같은 digest로 통과했다. 이 수정의 새 clean commit만 다시 결속해 전체 execute를 재개한다.
|
||||||
|
|
||||||
#### P0 수정 완료 — 회기 timestamp UTC 전송과 KST 표시 날짜
|
#### P0 수정 완료 — 회기 timestamp UTC 전송과 KST 표시 날짜
|
||||||
|
|
||||||
|
|
@ -683,7 +693,7 @@ node.exe .\node_modules\@playwright\test\cli.js test e2e/session-layout.spec.ts
|
||||||
--project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1 --reporter=line
|
--project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1 --reporter=line
|
||||||
```
|
```
|
||||||
|
|
||||||
전체 Playwright inventory는 614 tests / 44 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를
|
전체 Playwright inventory는 620 tests / 44 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를
|
||||||
제품 failure로 오인하지 않는다. 같은 blocker가 두 번 반복되면 전체 재시도 대신 원인·증거·수정 계획을 먼저 보고한다.
|
제품 failure로 오인하지 않는다. 같은 blocker가 두 번 반복되면 전체 재시도 대신 원인·증거·수정 계획을 먼저 보고한다.
|
||||||
|
|
||||||
## 9. 핵심 변경 파일
|
## 9. 핵심 변경 파일
|
||||||
|
|
|
||||||
|
|
@ -503,6 +503,9 @@
|
||||||
닫기 우회는 없다. 첫 clean commit `5221f79e` execute는 API/types/build와 candidate DB-backed 회기 후반까지
|
닫기 우회는 없다. 첫 clean commit `5221f79e` execute는 API/types/build와 candidate DB-backed 회기 후반까지
|
||||||
통과했지만 Vite 전용 `/src/main.tsx` 차단 테스트가 production hashed entry를 차단하지 못해 110/2에서
|
통과했지만 Vite 전용 `/src/main.tsx` 차단 테스트가 production hashed entry를 차단하지 못해 110/2에서
|
||||||
mutation 없이 중단됐다. source와 production preview에서 수정 회귀 2/2씩 통과했다. 이 테스트 수정의 새 clean
|
mutation 없이 중단됐다. source와 production preview에서 수정 회귀 2/2씩 통과했다. 이 테스트 수정의 새 clean
|
||||||
|
commit `21461ab3` candidate 112/112 뒤 실제 NAS-origin G4 2건이 `crypto.subtle` 부재로 실패했고, release agent는
|
||||||
|
이전 image·health·auth·OpenAPI를 검증해 rollback했다. 문장 원문을 보내지 않는 portable SHA-256 fallback은
|
||||||
|
secure localhost 2/2와 Tailnet insecure origin 2/2에서 동일 표준 digest를 통과했다. 이 수정의 새 clean
|
||||||
commit·HEAD/tree/archive를 결속해 실제
|
commit·HEAD/tree/archive를 결속해 실제
|
||||||
`http://100.116.83.60:8088` origin에서 전체 회기
|
`http://100.116.83.60:8088` origin에서 전체 회기
|
||||||
E2E 0 failure·SSE→DB review·student returned-practice desktop/mobile을 다시 검증하기 전에는 G8 전체를 DONE으로
|
E2E 0 failure·SSE→DB review·student returned-practice desktop/mobile을 다시 검증하기 전에는 G8 전체를 DONE으로
|
||||||
|
|
|
||||||
|
|
@ -656,6 +656,7 @@
|
||||||
<p class="dg-note">188차 적용(2026-08-09): 회기 시간 P0를 우회 없이 수정했다. browser-facing <code>started_at</code>/<code>ended_at</code>과 learner/teacher dashboard timestamp는 UTC <code>+00:00</code>을 반환하고, 리뷰의 달력 날짜는 서버 locale 대신 KST(+09:00)를 사용한다. backend 관련 65 passed, 후속 voice 계약을 포함한 API 전체 921 passed, Ruff·compile·web typecheck를 통과했다. 고유 Compose DB/API/Web/engine과 <code>Asia/Seoul</code> browser에서 코칭 이력 focused 1/1은 active/null·UTC suffix·10분 미만 경과·timebar/dialog 0·reload 후 coach mark 실제 클릭과 source-pack dialog를 확인했다. 종료 뒤 container·volume·listener 0이다. 이제 새 clean commit 결속과 NAS-origin 전체 E2E가 남았다.</p>
|
<p class="dg-note">188차 적용(2026-08-09): 회기 시간 P0를 우회 없이 수정했다. browser-facing <code>started_at</code>/<code>ended_at</code>과 learner/teacher dashboard timestamp는 UTC <code>+00:00</code>을 반환하고, 리뷰의 달력 날짜는 서버 locale 대신 KST(+09:00)를 사용한다. backend 관련 65 passed, 후속 voice 계약을 포함한 API 전체 921 passed, Ruff·compile·web typecheck를 통과했다. 고유 Compose DB/API/Web/engine과 <code>Asia/Seoul</code> browser에서 코칭 이력 focused 1/1은 active/null·UTC suffix·10분 미만 경과·timebar/dialog 0·reload 후 coach mark 실제 클릭과 source-pack dialog를 확인했다. 종료 뒤 container·volume·listener 0이다. 이제 새 clean commit 결속과 NAS-origin 전체 E2E가 남았다.</p>
|
||||||
<p class="dg-note">189차 적용(2026-08-09): G7 외부 실행 전 P0를 소스에서 닫았다. production runner exit 0은 canonical checker 0·<code>gate_closed=true</code>에 결속하고, browser Origin allowlist를 API/WSS/admin/topology transport host·scheme과 분리했다. capture는 최소 3,120초·ceil+terminal sample이며 checker는 voice/runtime/topology 공통 교집합 3,000초를 강제한다. Windows 증거는 detached-clean commit/tree, runner/collector/checker SHA, <code>psutil==6.1.1</code>을 매 sample 전후 검증한다. fresh public launcher는 mutation 전에 source·Python·cloudflared·config SHA를 고정하고, legacy API와 exact-config cloudflared를 bounded 교체해 새 PID/start/exe·command SHA/실제 cwd의 raw command-line 없는 receipt를 남긴다. receipt destination은 runtime mutation 전에 create/flush/atomic-replace 권한을 검사하고 기존 receipt를 보존한 채 원자 게시한다. 검증: G7 runner/checker/topology 86/86, launcher/sidecar 80/80, 통합 166/166, API 921, gateway 58, Web api-types·typecheck·build, 자기주도 desktop/mobile 6/6, Ruff·compile·PS5.1 parser·diff-check PASS. 실제 public 실행·마이크·사람 평가는 하지 않았으므로 G7은 external GATE다.</p>
|
<p class="dg-note">189차 적용(2026-08-09): G7 외부 실행 전 P0를 소스에서 닫았다. production runner exit 0은 canonical checker 0·<code>gate_closed=true</code>에 결속하고, browser Origin allowlist를 API/WSS/admin/topology transport host·scheme과 분리했다. capture는 최소 3,120초·ceil+terminal sample이며 checker는 voice/runtime/topology 공통 교집합 3,000초를 강제한다. Windows 증거는 detached-clean commit/tree, runner/collector/checker SHA, <code>psutil==6.1.1</code>을 매 sample 전후 검증한다. fresh public launcher는 mutation 전에 source·Python·cloudflared·config SHA를 고정하고, legacy API와 exact-config cloudflared를 bounded 교체해 새 PID/start/exe·command SHA/실제 cwd의 raw command-line 없는 receipt를 남긴다. receipt destination은 runtime mutation 전에 create/flush/atomic-replace 권한을 검사하고 기존 receipt를 보존한 채 원자 게시한다. 검증: G7 runner/checker/topology 86/86, launcher/sidecar 80/80, 통합 166/166, API 921, gateway 58, Web api-types·typecheck·build, 자기주도 desktop/mobile 6/6, Ruff·compile·PS5.1 parser·diff-check PASS. 실제 public 실행·마이크·사람 평가는 하지 않았으므로 G7은 external GATE다.</p>
|
||||||
<p class="dg-note">190차 적용(2026-08-09): exact include 52개를 clean commit <code>5221f79e…1c69</code>으로 고정하고 tree <code>e4f15001…308b</code>, 2회 동일 archive <code>1109bf86…0f4b</code>를 결속했다. fresh NAS dump <code>6f4b95a7…b529f</code>(1,015,222 bytes·TOC 1,752/TABLE DATA 129) 뒤 execute는 API 921·types·typecheck·build·insecure-context 6/6·candidate DB 회기 후반까지 통과했지만 <b>110/112</b>에서 승격 전에 fail-closed했다. 실패 2건은 Vite entry <code>/src/main.tsx</code>만 차단하던 boot 진단 테스트가 production hashed entry <code>/assets/index-*.js</code>를 차단하지 못한 환경 계약 누락이었다. 두 entry를 함께 차단하도록 고쳐 Vite source 2/2와 production preview 2/2를 통과했다. NAS mutation·rollback·DB migration은 0이며, 테스트 수정의 새 clean commit을 재결속한 한 번의 execute가 남았다.</p>
|
<p class="dg-note">190차 적용(2026-08-09): exact include 52개를 clean commit <code>5221f79e…1c69</code>으로 고정하고 tree <code>e4f15001…308b</code>, 2회 동일 archive <code>1109bf86…0f4b</code>를 결속했다. fresh NAS dump <code>6f4b95a7…b529f</code>(1,015,222 bytes·TOC 1,752/TABLE DATA 129) 뒤 execute는 API 921·types·typecheck·build·insecure-context 6/6·candidate DB 회기 후반까지 통과했지만 <b>110/112</b>에서 승격 전에 fail-closed했다. 실패 2건은 Vite entry <code>/src/main.tsx</code>만 차단하던 boot 진단 테스트가 production hashed entry <code>/assets/index-*.js</code>를 차단하지 못한 환경 계약 누락이었다. 두 entry를 함께 차단하도록 고쳐 Vite source 2/2와 production preview 2/2를 통과했다. NAS mutation·rollback·DB migration은 0이며, 테스트 수정의 새 clean commit을 재결속한 한 번의 execute가 남았다.</p>
|
||||||
|
<p class="dg-note">191차 적용(2026-08-09): 후속 clean commit <code>21461ab3…6fde</code>·tree <code>5c5f12a8…524e</code>·archive <code>20d49694…fa1a</code>는 candidate <b>112/112</b>를 통과하고 NAS 새 API/Web <code>d5021950…</code>/<code>376a3aa8…</code>를 올렸다. 실제 NAS-origin postdeploy G4 desktop/mobile 2건이 110/112에서 실패하자 active-state commit 전에 이전 <code>52e0e816…</code>/<code>6fdbb646…</code>로 자동 rollback했고 health·auth 401·OpenAPI 126·G0~G8 route·image ID를 재검증해 rollback <b>verified</b>로 종료했다. exact-image 평문 origin에서 문장 fingerprint의 <code>crypto.subtle.digest</code> 의존이 요청 전 예외를 내는 원인을 확인했다. 원문 외부 전송 없이 portable SHA-256 fallback을 추가하고 표준 digest를 payload에 exact 고정해 secure localhost 2/2와 Tailnet insecure origin 2/2를 통과했다. 새 clean commit의 전체 candidate+NAS-origin 0 failure 전까지 G8은 runtime REVALIDATION이다.</p>
|
||||||
<div class="dg-principles" aria-label="디자인 생성 가드레일">
|
<div class="dg-principles" aria-label="디자인 생성 가드레일">
|
||||||
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
|
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
|
||||||
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>
|
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>
|
||||||
|
|
@ -1066,7 +1067,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
|
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
|
||||||
<tr><td>Design SSOT / auth visual</td><td><code>npm run check:design-ssot</code> / <code>npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line</code> / <code>npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=line</code></td><td>SSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed.</td></tr>
|
<tr><td>Design SSOT / auth visual</td><td><code>npm run check:design-ssot</code> / <code>npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line</code> / <code>npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=line</code></td><td>SSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed.</td></tr>
|
||||||
<tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-08-09 현재 수집은 <b>614 tests / 44 files</b>다. 현 작업트리의 614개 전체 GREEN은 아직 검증 전이며, current 핵심 fixture는 불변 110 + 최신 dashboard 10의 분할 GREEN이다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다.</td></tr>
|
<tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-08-09 현재 수집은 <b>620 tests / 44 files</b>다. 현 작업트리의 620개 전체 GREEN은 아직 검증 전이며, current 핵심 fixture는 불변 110 + 최신 dashboard 10의 분할 GREEN이다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다.</td></tr>
|
||||||
<tr><td>Refactor governance P1~P8</td><td><code>ruff check app</code> / <code>pytest -q app</code> / <code>pytest -q engine_gateway</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code> / <code>npm run check:design-ssot</code> / <code>npm run check:dead-code</code> / <code>npm run check:duplication</code> / <code>npm run build</code> / <code>npm audit --audit-level=high</code> / full Playwright</td><td>Backend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 <code>ops/refactor-governance-2026-07-15.md</code>.</td></tr>
|
<tr><td>Refactor governance P1~P8</td><td><code>ruff check app</code> / <code>pytest -q app</code> / <code>pytest -q engine_gateway</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code> / <code>npm run check:design-ssot</code> / <code>npm run check:dead-code</code> / <code>npm run check:duplication</code> / <code>npm run build</code> / <code>npm audit --audit-level=high</code> / full Playwright</td><td>Backend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 <code>ops/refactor-governance-2026-07-15.md</code>.</td></tr>
|
||||||
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
|
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
|
||||||
<tr><td>Outcome & Alliance OS G0</td><td><code>py -3.11 -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_measurement_contract.py apps/api/app/test_runtime_schema_ssot.py -q</code> / <code>scripts/check-measurement-ledger.sql</code> / measurement·API contract checks / web typecheck / DB-backed <code>session-persistence</code> focused E2E 3종</td><td>G0 contract/schema 11 passed, 기존 backend 100 passed, auth 39 passed. Python→JSON Schema→TypeScript→PostgreSQL enum·필수필드 계약이 일치하고 8개 deterministic benchmark가 검증됐다. Live PostgreSQL에서 learner/client/evaluator 가시 행 1/1/2, 교차 누수 0, append-only guard 2를 확인했다. 학습자 턴→교수자 대시보드, 워크시트 검수, 종료 deep 평가→durable 리뷰 E2E는 각각 1 passed. G0/AOS-001~004 완료.</td></tr>
|
<tr><td>Outcome & Alliance OS G0</td><td><code>py -3.11 -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_measurement_contract.py apps/api/app/test_runtime_schema_ssot.py -q</code> / <code>scripts/check-measurement-ledger.sql</code> / measurement·API contract checks / web typecheck / DB-backed <code>session-persistence</code> focused E2E 3종</td><td>G0 contract/schema 11 passed, 기존 backend 100 passed, auth 39 passed. Python→JSON Schema→TypeScript→PostgreSQL enum·필수필드 계약이 일치하고 8개 deterministic benchmark가 검증됐다. Live PostgreSQL에서 learner/client/evaluator 가시 행 1/1/2, 교차 누수 0, append-only guard 2를 확인했다. 학습자 턴→교수자 대시보드, 워크시트 검수, 종료 deep 평가→durable 리뷰 E2E는 각각 1 passed. G0/AOS-001~004 완료.</td></tr>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타
|
||||||
| API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
| API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
||||||
| 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
| 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
||||||
| 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
| 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
||||||
| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 614 tests / 44 files · 현 작업트리 전체 GREEN 미검증 |
|
| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 620 tests / 44 files · 현 작업트리 전체 GREEN 미검증 |
|
||||||
|
|
||||||
핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.**
|
핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.**
|
||||||
**E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다.
|
**E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다.
|
||||||
|
|
@ -271,8 +271,8 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
|
||||||
|
|
||||||
### 3.6 실측 테스트 개수 (현재)
|
### 3.6 실측 테스트 개수 (현재)
|
||||||
|
|
||||||
2026-08-09 `npx playwright test --list` 기준 **현재 수집 614 tests / 44 files**다.
|
2026-08-09 `npx playwright test --list` 기준 **현재 수집 620 tests / 44 files**다.
|
||||||
이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 604개 완주는 아직 증거가 없으며,
|
이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 620개 완주는 아직 증거가 없으며,
|
||||||
과거 전체 GREEN 기록과 이번 focused/release gate 결과를 구분해 적는다.
|
과거 전체 GREEN 기록과 이번 focused/release gate 결과를 구분해 적는다.
|
||||||
|
|
||||||
- **병렬 시나리오**: 166 tests (desktop 83 + mobile 83)
|
- **병렬 시나리오**: 166 tests (desktop 83 + mobile 83)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,10 @@
|
||||||
110/112에서 production hashed entry를 차단하지 못한 boot-diagnostic 테스트 desktop/mobile 2건으로 승격 전
|
110/112에서 production hashed entry를 차단하지 못한 boot-diagnostic 테스트 desktop/mobile 2건으로 승격 전
|
||||||
중단됐다. NAS mutation 0이다. `/src/main.tsx`와 `/assets/index-*.js`를 함께 차단하는 수정은 Vite source 2/2,
|
중단됐다. NAS mutation 0이다. `/src/main.tsx`와 `/assets/index-*.js`를 함께 차단하는 수정은 Vite source 2/2,
|
||||||
production preview 2/2를 통과했으며 새 clean commit으로 한 번만 재실행한다.
|
production preview 2/2를 통과했으며 새 clean commit으로 한 번만 재실행한다.
|
||||||
|
후속 `21461ab3` candidate는 112/112를 통과했지만 실제 NAS-origin deliberate-practice 2건이
|
||||||
|
`crypto.subtle.digest` 부재로 실패했고 이전 이미지 rollback·health/auth/OpenAPI 검증은 완료됐다. 원문을 서버로
|
||||||
|
보내지 않는 portable SHA-256 fallback과 exact digest assertion은 secure localhost 2/2·Tailnet insecure 2/2를
|
||||||
|
통과했다. 새 clean commit을 결속한 전체 execute 0 failure가 여전히 종료조건이다.
|
||||||
|
|
||||||
> B1 완료 항목(학생 리뷰 1~5 척도 320px 반응형 재배치, 셸 구분선, 세션 종료 UX/다크테마, 아바타 SVG 리그 복귀와 래스터 비활성화, SEO/공유 카드, 레이아웃 정렬, 권한 위임,
|
> B1 완료 항목(학생 리뷰 1~5 척도 320px 반응형 재배치, 셸 구분선, 세션 종료 UX/다크테마, 아바타 SVG 리그 복귀와 래스터 비활성화, SEO/공유 카드, 레이아웃 정렬, 권한 위임,
|
||||||
> 메일링, 아카이브 API, TTS voice map, 빈상태 레이아웃, 평가 실패 복구 UX, SSE 저장, live-coach 표면화,
|
> 메일링, 아카이브 API, TTS voice map, 빈상태 레이아웃, 평가 실패 복구 UX, SSE 저장, live-coach 표면화,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue