diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a28f5d9..981aa1f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -31,7 +31,7 @@ Core invariants: 11. External legal-source updates produce explicit re-evaluation, not silent rewriting. ## Current implementation -The active MVP is a React/Vite browser workspace. State is in memory and there is no production persistence or publication backend. The seven PRD steps are routed to distinct editing surfaces. The collection taxonomy is metadata only. `src/policy.ts` owns deterministic collection-selection/no-collection/mode/purpose/path and non-collection authoring-completeness findings; `src/App.tsx` owns browser orchestration, explicit no-collection and transfer-status capture, warning-to-source navigation, stale dependent-fact invalidation, and deterministic preview rendering. `src/AuthoringFocusController.tsx` is a browser interaction adapter: after explicit rail, previous/next, or review-warning navigation changes the active editing surface, it moves programmatic focus to that surface's heading without changing domain state, intercepting ordinary field interaction, or overriding the separate preview-focus shortcut. +The active MVP is a React/Vite browser workspace. State is in memory and there is no production persistence or publication backend. The browser can download a deterministic versioned JSON draft containing normalized operator-authored facts and readiness finding codes; this local portability projection is not publication, persistence, or legal approval. The seven PRD steps are routed to distinct editing surfaces. The collection taxonomy is metadata only. `src/policy.ts` owns deterministic collection-selection/no-collection/mode/purpose/path and non-collection authoring-completeness findings; `src/App.tsx` owns browser orchestration, explicit no-collection and transfer-status capture, warning-to-source navigation, stale dependent-fact invalidation, and deterministic preview rendering. `src/AuthoringFocusController.tsx` is a browser interaction adapter: after explicit rail, previous/next, or review-warning navigation changes the active editing surface, it moves programmatic focus to that surface's heading without changing domain state, intercepting ordinary field interaction, or overriding the separate preview-focus shortcut. Authoring completeness is deliberately separate from legal sufficiency. Current readiness rules prove that product-defined fact responsibilities were explicitly addressed; they do not assert that a policy complies with law. Source/effective-date-bound legal validation belongs to the Legal Source Registry -> Review & Publication boundary. diff --git a/CHANGELOG.md b/CHANGELOG.md index d22276c..e5ecd76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable product changes are recorded here. PolicyWeave is pre-release; entri ## Unreleased ### Added +- Deterministic local JSON draft export with a versioned `snake_case` contract, normalized operator-authored facts, explicit incomplete/review-ready state, readiness finding codes, and fail-closed rejection of service URLs containing credentials, query, or fragment components. Unresolved collection mode is serialized as `null`, not the UI empty-string sentinel, and object-URL cleanup is deferred until after download navigation starts. The browser download performs no network transfer and does not claim publication. - PostgreSQL restart and custom-format dump/restore evidence that preserves NULL-safe complete service/collection-item values, a collecting-without-retention cross-state fixture, and independent no-collection and applies-retention facts, then re-executes no-collection plus both retention-status/rule contradictions against the restored database. - PostgreSQL two-session concurrency evidence that observes real lock waits, rejects a collection-item writer racing with a no-collection update, and proves competing same-item UPSERTs converge to one row carrying the second writer's label, mode, and path with NULL-safe complete-value assertions and without timing-based transaction sleeps. - PostgreSQL 18 runtime contract coverage for migration apply/down/apply cycles, item-key UPSERT idempotency, and deferred rejection of no-collection, missing-retention-rule, and revision-owner contradictions. The database remains CI-only and is not a hosted product backend. @@ -16,7 +17,7 @@ All notable product changes are recorded here. PolicyWeave is pre-release; entri - Explicit unresolved/yes/no states for third-party provision and international transfer, with dependent detail capture only for confirmed `yes` cases. - Regression coverage for all seven step routes, zero-inferred startup facts, first-responsibility startup state, explicit no-collection state and stale-item invalidation, independent retention authority and stale-period invalidation, collection-mode/path confirmation, seven-step readiness, explicit no-transfer attestations, transfer-dependent fact invalidation, whitespace normalization, service URL projection, warning navigation, collection-path/purpose separation, stale collection evidence invalidation, buyer-facing publication guidance, non-deceptive handling of unshipped affordances, authored focus-indicator contrast, and authoring-step focus transfer. - Product/technical gap ledger, architecture, technical requirements, security baseline, and legal-source/accessibility traceability. -- Playwright/axe browser evidence harness covering desktop, tablet, and mobile rendering; horizontal overflow; keyboard activation and focus transfer; explicit no-collection progression; retention-status transitions and stale-period invalidation; effective 200% browser-zoom reflow from the desktop profile; serious/critical automated accessibility findings; and exact-head screenshot artifacts. +- Playwright/axe browser evidence harness covering desktop, tablet, and mobile rendering; horizontal overflow; keyboard activation and focus transfer; explicit no-collection progression; retention-status transitions and stale-period invalidation; effective 200% browser-zoom reflow from the desktop profile; serious/critical automated accessibility findings; real-browser JSON download events with mouse, keyboard, and touch activation; fixed filename; JSON MIME; byte-stable repeated exports; review-ready payload semantics; success and preparation/activation-error object-URL cleanup; and exact-head screenshot artifacts. ### Changed - PostgreSQL negative-path evidence now matches each expected domain error message, so an unrelated SQL or connection failure cannot masquerade as a passing invariant check. @@ -36,6 +37,7 @@ All notable product changes are recorded here. PolicyWeave is pre-release; entri - Public-readiness includes product-defined service name/URL, explicit retention status and any required period, transfer-status/detail, and privacy-contact completeness. - Service URL and privacy-contact email are shape-validated as usability contracts without claiming endpoint reachability or mailbox ownership. - Credential-bearing service URLs are rejected and withheld from the review projection so embedded usernames or passwords cannot leak into a generated draft. +- Query- or fragment-bearing service URLs are rejected consistently by readiness, preview, and export so a source fact cannot be silently rewritten to a different destination. - Blank transfer state is no longer treated as an implicit `none`; explicit `없음` confirmation is required, while `있음` requires dependent recipient/purpose or country/recipient facts. - Changing a transfer status away from `있음` clears its dependent details so stale customer facts cannot silently revive. - Disabling a collection item clears its collection mode, processing purpose, and collection-path evidence so re-enabling cannot silently revive stale customer facts. @@ -45,7 +47,7 @@ All notable product changes are recorded here. PolicyWeave is pre-release; entri - Step-rail, previous/next, and review-warning navigation now transfers programmatic focus to the newly active step heading; ordinary form controls and the dedicated preview shortcut are excluded from that transfer. - Review-warning navigation now lets the browser scroll the focused owner heading into view; the previous `preventScroll` option could leave that heading hundreds of pixels above the desktop or mobile viewport. - The publication-area CTA describes a readiness check and directs the operator to responsible review rather than exposing internal implementation boundaries. -- Unshipped JSON export is visibly disabled as `준비 중`, the redundant no-op `검토본 생성` control was removed, and the document title is non-interactive status text. +- JSON export now downloads the current structured draft locally, contains download preparation and activation exceptions, reports a retry action through the existing live status output, and revokes the temporary object URL whenever allocation succeeded; the redundant no-op `검토본 생성` control remains removed, and the document title remains non-interactive status text. - Authored generic and custom-checkbox keyboard focus outlines now use the high-contrast `--green` token; a CSS regression test computes and enforces at least 3:1 contrast against white instead of relying on a low-contrast focus color. - Responsive review behavior and mobile publication feedback were repaired during PR review. - Responsive CSS contract tests use literal media-query regular expressions, removing the Semgrep dynamic-RegExp finding without suppressing or weakening the scanner gate. @@ -58,3 +60,4 @@ All notable product changes are recorded here. PolicyWeave is pre-release; entri - A product persistence adapter, durable hosted storage, tenant authorization, immutable audit history, encryption, operational backup/restore, and production-scale contention evidence. Bounded CI database execution, including process restart and dump/restore, does not constitute a hosted runtime. - Authenticated immutable publication revisions and public URL lifecycle. - Hosted tenant/security/operability evidence and endpoint load testing. +- Versioned DB-backed ko/en/ja/zh/vi/es/de/fr translation resources and localized export acceptance evidence. diff --git a/docs/PRD.md b/docs/PRD.md index 3c6b577..0767c4c 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -19,7 +19,7 @@ PolicyWeave는 법률 문장을 임의로 창작하는 도구가 아니다. 운 - 제3자 제공과 국외 이전의 명시적 `있음`/`없음` 확인; `있음`일 때만 종속 상세 사실 요구 - 공개 전 검토 요약과 버전 정보 - 정적 공개 URL 발행 계약(후속 백엔드에서 구현) -- JSON 내보내기 가능한 정책 데이터 모델 +- 버전이 명시된 JSON으로 현재 정책 사실과 검토 상태를 로컬 내보내기 ## 비목표 @@ -45,5 +45,6 @@ PolicyWeave는 법률 문장을 임의로 창작하는 도구가 아니다. 운 - 제3자 제공 또는 국외 이전을 `있음`으로 확인한 경우 해당 수령자/목적 또는 국가/수령자 사실까지 확인되어야 한다. - `없음` 확인은 명시적 운영자 사실이며, 이전에 입력한 종속 제공·이전 상세 사실은 상태 변경 시 폐기된다. - 모든 경고는 해당 입력 단계로 이동할 수 있다. +- JSON 내보내기는 확인되지 않은 값을 임의 사실로 채우지 않고, 자격정보가 포함된 잘못된 서비스 URL을 파일에 기록하지 않으며, 네트워크 전송 없이 현재 작성 사실과 검토 상태를 재현한다. - 모바일에서도 작성과 미리보기를 전환할 수 있다. - 키보드만으로 모든 입력과 주요 동작을 수행할 수 있다. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index a480736..4c74b3a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -26,7 +26,11 @@ Protected assets include policy facts, contact details, processing descriptions, 6. Validate and encode user-entered content at output boundaries; do not treat imported HTML/Markdown/source material as executable instructions. 7. Define backup/restore, incident response, access review, retention/deletion, and evidence collection before claiming SOC 2 readiness. Map hosted controls toward CSAP and SOC 2 without describing an unassessed product as certified. 8. Tests/docs use fictionalized organizations and people; real personal/institutional names are not fixtures. -9. Credential-bearing service URLs are invalid and are withheld from the review projection; operators must provide a credential-free HTTP(S) location. +9. Service URLs containing credentials, query, or fragment components are invalid and withheld from the review projection; operators must provide a credential-free HTTP(S) location whose destination can be exported without lossy rewriting. ## Verification Security posture is head-specific. A successful predecessor scan, unresolved finding dismissal, or queued security workflow is not passing evidence. Merge/release decisions must reacquire the exact current head's organization-required security/SAST/review checks. + + +## Local JSON export +The export path serializes only the current in-memory PolicyWeave draft and deterministic readiness codes into a browser Blob. It makes no network request, uses a fixed filename rather than customer-controlled path text, and defers object-URL revocation until the next task after initiating the download so browsers with deferred navigation can consume the Blob. If local download preparation or activation throws, the exception is contained and the existing live status output directs the operator to retry. A temporary object URL is revoked exactly when allocation succeeded; preparation failure before allocation has no fabricated cleanup target. A service URL containing username, password, query, or fragment components is omitted from the file and remains represented by the `service_url_format` finding. The same shared validator withholds it from preview and readiness, preventing token disclosure and destination-changing rewrites. The file is still customer-controlled sensitive data; operators remain responsible for its storage and transfer. This control is not encryption, persistence, publication, backup, authorization, or mid-transfer cancellation evidence. diff --git a/docs/TRD.md b/docs/TRD.md index 425d035..a8abba1 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -6,10 +6,11 @@ This TRD covers the pre-release PolicyWeave browser workspace and the contracts ## Current runtime - React + TypeScript + Vite browser application. - Structured authoring state is in browser memory; no production database or backend exists. +- Local JSON export projects the current draft through `createPolicyExport` into deterministic `schema_version = 1` data with normalized facts, explicit `incomplete`/`review_ready` state, and readiness finding codes. Service URLs containing credentials, query, or fragment components export as `null` and remain blocking rather than being rewritten to another destination; unresolved collection modes export as `null`. Download uses a browser Blob/object URL, defers URL revocation until the next task so deferred browser navigation can consume it, contains preparation and activation exceptions with a retry message in the existing live status output, and performs no network request. - `src/policy.ts` owns deterministic review logic for collection selection/no-collection attestation/mode/purpose/path and the non-collection authoring-completeness findings for service identity, explicit retention status/period, transfer statuses/details, and privacy contact. - `src/App.tsx` provides the seven-step authoring flow, review navigation, explicit collection/retention/transfer-status capture, stale dependent-fact invalidation, and deterministic preview projection. - `src/AuthoringFocusController.tsx` keeps explicit step navigation and review-warning jumps aligned with the newly active step by moving programmatic focus to its heading after the React update and allowing the browser to reveal that target; ordinary form controls and the dedicated preview shortcut are outside this behavior. -- The current CI contract is lint, Vitest, TypeScript/Vite build, and Playwright Chromium browser evidence plus live organization-required security/review workflows. Browser cases cover desktop/tablet/mobile rendering, keyboard-triggered focus transfer, the explicit no-collection path, retention-status transitions with stale-period invalidation, effective 200% browser-zoom reflow from the desktop layout viewport, horizontal overflow, serious/critical axe findings, and per-project screenshots retained as an exact-head artifact. +- The current CI contract is lint, Vitest, TypeScript/Vite build, and Playwright Chromium browser evidence plus live organization-required security/review workflows. Browser cases cover desktop/tablet/mobile rendering, keyboard-triggered focus transfer, the explicit no-collection path, retention-status transitions with stale-period invalidation, effective 200% browser-zoom reflow from the desktop layout viewport, horizontal overflow, serious/critical axe findings, real download events with mouse, keyboard, and touch activation, fixed filename, JSON MIME, byte-stable repeated exports, review-ready payload semantics, object-URL cleanup on success and simulated activation failure, contained pre-allocation failure, failure announcement, and per-project screenshots retained as an exact-head artifact. - Muted small text uses one authored color token whose contrast is regression-tested against every current surface background at a minimum 4.5:1 ratio; browser axe remains the integration authority for rendered combinations. ## Functional contracts @@ -18,7 +19,7 @@ This TRD covers the pre-release PolicyWeave browser workspace and the contracts 3. Collection readiness requires either at least one explicitly selected collection item or an explicit no-collection attestation. The no-collection attestation and selected items are mutually exclusive; contradictory state fails closed. Turning the attestation on clears selected items and their mode/purpose/path evidence, and later turning it off does not revive those stale facts. 4. Every selected collection item requires an explicit collection mode, nonblank processing purpose, and nonblank collection-path evidence. Each missing responsibility is counted independently and navigates to its owning step. 5. Retention is an independent operator fact, not a consequence of collection state. The retention step uses explicit unresolved/`applies`/`none` status. Unresolved blocks readiness. `applies` requires a nonblank retention period or end condition; `none` requires no period. Changing away from `applies` clears the previous period so stale retention evidence cannot revive. No-collection never auto-selects `none`. -6. Service name, service URL, third-party provision status, international-transfer status, privacy-contact owner, and privacy-contact email are product-defined readiness facts and block readiness while unresolved. Service URL must be an absolute HTTP(S) URL; contact email must satisfy a minimal address-shape check. These syntax checks do not claim endpoint reachability or mailbox ownership. +6. Service name, service URL, third-party provision status, international-transfer status, privacy-contact owner, and privacy-contact email are product-defined readiness facts and block readiness while unresolved. Service URL must be an absolute HTTP(S) URL without credentials, query, or fragment components so preview and export preserve one destination; contact email must satisfy a minimal address-shape check. These syntax checks do not claim endpoint reachability or mailbox ownership. 7. Third-party provision and international transfer use explicit unresolved/yes/no status. `no` is an operator attestation; `yes` requires its dependent facts. Changing either status away from `yes` clears dependent details to prevent stale evidence revival. 8. Blank/whitespace authoring facts are normalized as unresolved where that fact is required by the explicit governing status. 9. Disabling a collection item invalidates dependent collection-mode, processing-purpose, and collection-path evidence; re-enabling requires renewed confirmation. @@ -37,6 +38,9 @@ The separation between collection and retention follows the PIPC Standard Person - Hosted web endpoints, when introduced, use non-blocking/asynchronous handling and require realistic k6 tests before a p95 <=20 ms page/API claim is recorded. - Production does not depend on synthetic demo data. +## Local draft portability +The JSON file is a draft portability artifact, not a publication receipt, immutable revision, legal approval, or persistence backup. It may be exported while incomplete so operators can inspect and transfer their authored work without converting blanks into `none`. Contract changes require a new schema version and compatibility evidence; the current fixed filename avoids using customer-controlled text as a filesystem name. Preparation/activation-error recovery does not establish cancellation of an in-progress browser transfer. + ## Hosted persistence/publication entry criteria Before network persistence lands, define a versioned policy-data schema, migration policy, 3NF relational model, per-item UPSERT/idempotency rules, immutable publication receipt, supersession/rollback semantics, tenant/purpose authorization, audit evidence, encryption/key management, retention/deletion behavior, and backup/restore testing. Use two-or-more-word semantic persistence object names in `snake_case` by default. The revision model must preserve explicit no-collection and explicit retention status independently; `none` must not be materialized from collection absence, and an inapplicable/non-retained state must not carry a live `retention_rule` value. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index a34225c..7170119 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -52,11 +52,26 @@ The restart/restore slice remains bounded CI evidence and does not enable a host On 2026-09-09 the review-to-merge loop collapsed the writer stack through ordinary merge commits without force-push, branch deletion, or protection changes, following the PR #9 precedent for unprotected stacked branches. PRs #2, #3, #4, #5, #6, #7, and #8 merged bottom-up between 01:10:44Z and 01:14:13Z, each with exact-head `verify` GREEN, zero unresolved review threads, and MERGEABLE/CLEAN state re-fetched immediately before its merge. The local-only restore-contract commit `878d0ce` was pushed as a new branch and opened as successor PR #14 on the PR #8 head; its Codex P2 review correctly showed the URL and `collection_without_retention_count` markers could not detect a coupled revision-`...0003` seed-plus-query change from `none` to `applies`. Commit `d0da193` binds the `...0003`/`none` seed tuple, its `support_email` item seed, and the revision-specific query predicates together; a negative control proved the old markers pass that coupled mutation while the new predicates fail it, and the review thread was resolved with that evidence. CI `34298653319` passed on `d0da193`, and PR #14 merged at 01:25:12Z. PR #13 (dependency pin and toolchain classification for issue #12) merged at 01:19:32Z on its immutable GREEN pair of head `eff7ec19f511` and CI `34251045288`, advancing the docs readiness branch to `bbea5c9`, whose fresh `verify` (`34298735574`) is GREEN. No PR was closed: 9 merges leave drafts #1, #10, and #11 open, each retaining a valid delta. `develop` still declares 16 `latest` occurrences, which explains PR #1's `dependency-review` failure; the pinned manifest (zero `latest`) sits at the top of the side chain and reaches `develop` only through the PR #10 to #11 to down-chain successor path. PR #1's remaining failures are the two CodeQL compatibility analyses plus `dependency-review`; `verify`, Noema, OpenCode, Strix, Semgrep, Trivy, OSV, and Scorecard are GREEN on its current head. Central-workflow run logs live in the organization central repository, so a leaf-side run fetch returns 404; such incidents go through the central owner path rather than leaf-side churn or bypass. The chain tip carrying the full writer delta is `origin/codex/policyweave-concurrent-writer-evidence` (`df9eef5`), with sibling successors `codex/policyweave-json-export` (PR #10, draft; its buyer-visible export enablement needs product review plus a base update now that its base moved) and this ledger branch stacked above it. The repository has no scheduled workflow (only `ci.yml`, no `schedule` trigger), so the hourly cadence is served by the autoresearch loop's own status messages rather than an in-repo scheduler. This remains CI durability evidence with no hosted adapter, tenant authorization, audit, encryption, or released datastore. +The local-draft portability slice converts the previously disabled JSON affordance into a deterministic versioned export. Test-only head `553c1a62514286b0a2e57621072a192556594314` produced exact RED CI `34210028140`: the four new contracts failed while 62 predecessor tests passed. The minimal projection normalizes authored values, keeps unresolved states explicit, exports readiness finding codes, omits credential-bearing service URLs, and downloads through a fixed-name browser Blob without network transfer. This is a mutable draft artifact, not `publication_revision`, durable persistence, backup, authorization, or legal approval. Exact-head review then found two portability defects: immediate object-URL revocation could race deferred WebKit navigation, and an unresolved collection mode leaked the UI sentinel `""` into schema v1. Test-only head `e3861409e3d5f786a1a6756178db71912d1edec0` produced RED CI `34212535756` with precisely 65 passed/2 failed. The minimal repair defers revocation to the next task and narrows the exported mode to the selected enum or `null`; implementation head `eaf18cb4115c1419263b132b5693325e2be42a8d` passed lint, 67/67 tests, build, and all PostgreSQL evidence before final documentation sealing. A later CodeRabbit review found that direct `Object.defineProperty` URL mocks survived `restoreAllMocks()` and that accepted service URLs could copy query/fragment secrets into JSON. Test-first head `0f1a608428681aebfc9187f3d867c59f9905171b` added both regression contracts, but CI `34213620870` was cancelled before execution when the concurrent writer advanced the same branch, so it is not claimed as RED evidence. Concurrent child `5c803b1d2369d4149566b1bf30b83248a46c74c1` preserves that test head and initially canonicalized accepted URLs to credential-free origin/path; `90a3d6810ab213af7fb332df0e88f3475109488e` replaces the URL overrides with restorable Vitest spies. The later P1 correction below supersedes that lossy canonicalization. + +Design-assurance then identified that the local download itself had only jsdom/mocked-anchor evidence. Test-only head `7be6d366540ae87ee2504dba0020bf9d31ab811c` adds a Playwright contract that consumes the real Chromium download event in desktop, tablet, and mobile projects, checks the fixed filename, reads and parses the downloaded file, and verifies schema version, incomplete state, authored service name, explicit null, and finding codes. Immutable test-head run `34215521539` passed 69/69 Vitest contracts, the TypeScript/Vite build, PostgreSQL 18.6 migration/concurrency/restart/custom restore, and 16 Playwright/axe cases with two scoped reflow skips; artifact `10051741653` is bound by digest `sha256:9210de5928ff1ab16a0862c8f988b872517af9f28368288e074df187aa04a615`. No production change was needed; this closes the browser-download evidence gap without claiming import, publication, or hosted persistence. + +The next exact-head design review correctly limited that evidence to mouse activation and one incomplete Korean-language state. Test-only head `798f2c486d19968f7f858219670279cec636fd6b` retains the real download path and adds keyboard activation across desktop/tablet/mobile, touch activation in the touch-enabled mobile project, JSON MIME inspection, byte equality across repeated exports, complete `review_ready` state with no findings, and one-to-one object-URL revocation. CI `34220766193` passed 69/69 Vitest contracts, TypeScript/Vite build, PostgreSQL 18.6 migration/concurrency/restart/custom restore, and 21 Playwright/axe cases with six intentional project-scope skips. Artifact `10053675937` is bound by digest `sha256:0f600da884ed4a42e2839e05e1dec5c6deda2be6747fb6550a0161315c1e709a`. No production change was needed. + +The activation-failure pass then found that a browser exception from the generated download link still revoked its object URL but escaped as an unhandled page error and left the operator without a next action. Test-only head `bc4c7f1e16cf5cb2ae8606efe81d8201bbd578de` produced exact RED CI `34223146923`: 69/69 Vitest and every PostgreSQL step passed, while the new desktop Chromium case received an empty live output instead of the required retry guidance. Minimal implementation head `d8117e2a9a52a7c42941252d77e2327b18d903a2` catches only activation exceptions, reuses the existing live status output, and preserves deferred cleanup in `finally`. CI `34223403382` passed 69/69 Vitest, the production build, all PostgreSQL evidence, and 22 Playwright/axe cases with eight intentional project-scope skips. Artifact `10054717353` is bound by digest `sha256:9e58d8a112d4228128794cc35bcb9600e1715c1efb491d0ec9d382d2d2598a3d`. In-progress transfer cancellation and versioned ko/en/ja/zh/vi/es/de/fr resources remain open rather than being inferred from activation-error recovery. + +The preparation-failure pass then found that Blob/object-URL allocation still occurred before the existing error boundary. A browser allocation exception therefore escaped as a page error and left the live status output empty. Test-only head `83f189ebf78aab9124745bc4209a281cf6f11fa0` produced exact RED CI `34232255893`: 71/71 Vitest, build, and all PostgreSQL evidence passed, while the new desktop Chromium scene failed after receiving an empty output; the browser result was one failed, 22 passed, and ten intentional project-scope skips. Minimal implementation head `8d637f96ad22874645c25b4834b2ce37ae575ddd` extends the same `try` boundary across export preparation and activation, reuses the established retry message, and schedules revocation only when allocation returned a URL. CI `34232543645` passed 71/71 Vitest, build, PostgreSQL migration/concurrency/restart/restore, and 23 Playwright/axe cases with ten scoped skips; artifact `10058444165` is bound by digest `sha256:62b957f180d698b6714b46a89c2adcbb17bbd16152e6ae8820ee55eb84818958`. No retry loop, secondary error channel, or fabricated cleanup target was added. + +The subsequent Codex P1 review found that the validator and preview accepted query-dependent or hash-routed service URLs while export silently removed those components, allowing a `review_ready` artifact to name a different destination from the authored source fact. Test-only head `01ab876a228d8cf9caadce2a4760ca3a1ffeedb7` covered a normal query-dependent URL, a hash-routed URL, and a secret-bearing combination; exact RED CI `34224341164` failed that contract before concurrent writer `3f16e062e5d68dd9ec4b839514ddc2aa396a4358` supplied the shared root fix without rewriting history. Successor `c2f4887080d572d6c16347395b306905f18e6846` added the missing buyer-facing preview regression, but its first integration accidentally narrowed the export matrix to the secret-bearing combination. Canonical child `170a65830fddcdc688ef53cdc793a84dbc4d0025` restores every valid matrix case while retaining the preview contract: query/fragment URLs remain `service_url_format` blockers, are withheld from preview, and export as `null` rather than being rewritten. CI `34225294729` passed 70/70 Vitest, the production build, all PostgreSQL evidence, and 22 Playwright/axe cases with eight intentional project-scope skips; artifact `10055464028` is bound by digest `sha256:90e30f973dcf490e226412039c8b3e43d50f0655dd7f5dc901f8d8f93100d845`. This intentionally narrows admissible v1 URLs instead of manufacturing a different customer fact. + +The final URL-boundary follow-up found that WHATWG `URL.search` and `URL.hash` are empty for authored bare delimiters even though serialization retains `?` or `#`. Test-first head `0580f466837bc5b000a54e79732b08bf83a3fd2f` added both empty-delimiter cases to the existing query/hash/secret matrix; CI `34230043117` passed lint and failed in `npm test` before the source repair. Minimal child `9d92a9ea716c5427161450df34ef142caaaf64e6` checks the canonical serialization for actual delimiters, rejecting bare query/fragment markers through the one readiness/preview/export boundary. Positive edge coverage at `a6cf635fcfc082fafacff13b658108fd076fd474` separately proves encoded `%3F`/`%23` pathname data remains admissible and byte-preserved, preventing a later raw-input substring check from over-blocking legitimate path data. Exact source-fix CI `34230291396` passed 70/70 Vitest, build, PostgreSQL migration/concurrency/restart/restore, and 22 Playwright/axe cases with eight intentional project-scope skips; artifact `10057683181` is bound by digest `sha256:6d2e7fc1ddafefdbd55e9c13b716dca123ba148dc753d65efb26d1bb5e3c26f4`. This closes the reviewed delimiter bypass without widening URL parsing or adding another validator. + ## Current baseline | Area | Evidence | Status | Commercialization gap | Owner/action | Next verification | | --- | --- | --- | --- | --- | --- | | Guided authoring | PRD, ADR-0002, seven routed editors, first-responsibility startup, `getReview`, explicit retention `getDraftReview`, no-collection/transfer states | Repaired foundation | Fresh state no longer skips or falsely completes step 1; collection, retention, transfer and other responsibilities fail closed independently; collection-path evidence remains unstructured free text and legal sufficiency is deliberately separate | Policy Fact Authoring: preserve deterministic completeness and independent authority; add structured path-evidence types only when a real integration/use case proves the need | Exact-head unit/UI edge tests, then browser E2E | +| Draft portability | Versioned `createPolicyExport`, fixed-name browser Blob download, explicit readiness state/finding codes, unsafe-URL rejection across preview/readiness/export, keyboard/touch activation, JSON MIME, repeat-byte checks, preparation/activation-error guidance, and allocation-aware success/error URL cleanup | Implemented foundation; bounded exact-head browser download GREEN | No import/migration contract, in-progress cancellation evidence, localized resource contract, or immutable publication semantics; exported files remain operator-controlled sensitive data | Policy Fact Authoring: preserve deterministic schema-versioned projection without network transfer, lossy URL rewriting, or inferred facts | Future version compatibility, cancellation, locale, and fail-closed import tests before schema evolution | | Customer-fact authority | Zero-inferred startup facts; truthful initial rail state; explicit no-collection; independent explicit retention status; explicit transfer states; stale dependent-fact invalidation | Repaired | No known buyer-facing authority dead end remains in the in-memory seven-step fact model; hosted persistence must preserve these independent states without deriving one from another | Policy Fact Authoring: encode collection and retention as separate revision-owned facts; no automatic no-collection→no-retention rule | Persistence/schema invariant tests and exact-head UI tests | | Review workspace | Live preview, total blocker count, warning-to-owner navigation, buyer-facing readiness guidance, deterministic step-heading focus transfer, Playwright viewport/screenshot harness | Implemented foundation; offscreen focus repaired and exact-head browser verified | Automated desktop/tablet/mobile focus-scroll evidence is GREEN; broader interaction coverage remains bounded | UX: retain exact-head artifacts, then extend interaction coverage | Exact-head screenshots, keyboard/focus and accessibility checks | | Accessibility | Semantic controls, visible focus behavior, focus-token >=3:1 regression, muted-text >=4.5:1 authored-surface regression, jsdom focus transition, axe/browser, focused-heading viewport checks, responsive retention transitions, and effective 200% browser-zoom reflow | Partial; bounded exact-head browser GREEN | Native browser UI zoom automation, screen-reader, and manual WCAG evidence remain absent | UX/Test Engineering: add a manual interaction record and remaining cases without claiming conformance from automation alone | Exact-head WCAG/browser matrix plus screen-reader and manual evidence | @@ -64,7 +79,7 @@ On 2026-09-09 the review-to-merge loop collapsed the writer stack through ordina | Policy model | ADRs, ARCHITECTURE, TRD, ADR-0003, Proposed ERD, up/down migration, schema, runtime, two-session concurrency, restart, and dump/restore contract tests | Proposed 3NF foundation; PostgreSQL 18 exact-head CI execution required; browser runtime remains memory-only | Apply/down/apply, exact negative errors, observed lock waits, conflicting-fact rejection, same-item UPSERT convergence with NULL-safe complete label/mode/path assertions, process restart, and custom-format restore with a collection/no-retention cross-state, NULL-safe complete service name/URL and item assertions, and restored no-collection plus both retention contradiction checks are implemented; authorization, audit, encryption, deletion, and production-scale contention remain unproved | Platform: retain exact-head PostgreSQL evidence, then add the hosted authorization/audit boundary while keeping the adapter disabled | Tenant authorization, immutable audit, and encryption tests | | Publication | Readiness CTA truthfully does not pretend to publish; immutable `publication_revision` is designed | Planned | No authenticated approval, immutable publication, supersession, rollback, or public URL lifecycle | Review & Publication: implement after persistence/security entry criteria | Authorization, replay/digest, supersession tests | | Security/privacy | `docs/SECURITY.md`; local-first runtime; SHA-pinned checkout | Baseline documented | Hosted tenant model, encryption/key handling, audit/incident/retention evidence absent | Platform/Security: threat-model hosted boundary before backend | Exact-head security tests and org scans | -| Tests | 63 unit/UI/schema/workflow regressions plus PostgreSQL runtime/concurrency/restore scripts, thirteen Playwright/axe cases, two project-scoped skips, and a screenshot artifact contract | Improved; exact-head unit/build/browser/PostgreSQL GREEN required | Repository-wide 100% execution/docstring coverage is not yet evidenced | Test Engineering: measure coverage and extend realistic edge cases | Exact-head coverage + browser/database evidence | +| Tests | 71 unit/UI/schema/workflow regressions plus PostgreSQL runtime/concurrency/restore scripts, 23 Playwright/axe passes, ten intentional project-scope skips, real-browser interaction/download/error contracts, and a screenshot artifact contract | Improved; bounded exact-head unit/build/browser/PostgreSQL GREEN | Repository-wide 100% execution/docstring coverage is not yet evidenced | Test Engineering: measure coverage and extend realistic edge cases | Exact-head coverage + browser/database evidence | | Performance | Static Vite client | Unevidenced | No realistic buyer-flow browser performance baseline; no network backend exists for meaningful k6 endpoint evidence | Operability: record browser baseline now; add k6 only when hosted network surfaces exist | Real measurements before latency claims | | CI/security merge gate | Repo CI plus active organization ruleset-required workflows; immutable Node 24-based checkout, setup/cache, and artifact-upload action releases | Live external gate; warning-free evidence is re-fetched for the exact merge candidate | Every branch movement invalidates predecessor evidence and stale approval; current hosted jobs may remain queued before runner assignment and dependency/reviewer control-plane failures can fail closed independently | Re-fetch exact-head workflows/reviews; use the central owner path for runner/dependency-review incidents rather than leaf-side churn or bypass | Terminal exact-head checks with no action-runtime deprecation warnings + independent approval + resolved threads | diff --git a/src/App.test.tsx b/src/App.test.tsx index 32e95c0..ed4ca98 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,9 +1,18 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import App from './App' -afterEach(cleanup) +const originalCreateObjectUrl = URL.createObjectURL +const originalRevokeObjectUrl = URL.revokeObjectURL + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.useRealTimers() + expect(URL.createObjectURL).toBe(originalCreateObjectUrl) + expect(URL.revokeObjectURL).toBe(originalRevokeObjectUrl) +}) function openCollectionStep(container: HTMLElement) { fireEvent.click(container.querySelectorAll('.rail li button')[1]) @@ -67,12 +76,40 @@ describe('policy editing workflow', () => { expect(reviewDraft).toContain('서비스 URL 형식') }) - it('아직 제공하지 않는 내보내기와 생성 기능을 클릭 가능한 동작처럼 노출하지 않는다', () => { + it('query 또는 fragment가 있는 서비스 URL은 다른 위치로 재작성하지 않고 수정 대상으로 남긴다', () => { + const { container } = render() + const serviceUrl = container.querySelector('input[name="serviceUrl"]')! + + for (const value of ['https://example.test/app?tenant=acme', 'https://example.test/#/privacy']) { + fireEvent.change(serviceUrl, { target: { value } }) + const reviewDraft = container.querySelector('.paper')?.textContent ?? '' + expect(reviewDraft).not.toContain(value) + expect(reviewDraft).toContain('서비스 URL 형식') + } + }) + + it('작성 사실을 JSON 파일로 로컬 내보내고 제공하지 않는 생성 기능은 노출하지 않는다', () => { + vi.useFakeTimers() + const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockImplementation((fileBlob) => { + void fileBlob + return 'blob:policyweave-draft' + }) + const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined) + const clickDownload = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + const { container } = render() const buttons = Array.from(container.querySelectorAll('button')) const exportButton = buttons.find((button) => button.textContent?.includes('JSON 내보내기')) - expect(exportButton?.disabled).toBe(true) - expect(exportButton?.textContent).toContain('준비 중') + expect(exportButton?.disabled).toBe(false) + expect(exportButton?.textContent).not.toContain('준비 중') + + fireEvent.click(exportButton!) + expect(createObjectUrl).toHaveBeenCalledOnce() + expect(createObjectUrl.mock.calls[0][0]).toBeInstanceOf(Blob) + expect(clickDownload).toHaveBeenCalledOnce() + expect(revokeObjectUrl).not.toHaveBeenCalled() + vi.runAllTimers() + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:policyweave-draft') expect(buttons.find((button) => button.textContent?.includes('개인정보처리방침'))).toBeUndefined() expect(container.querySelector('.document-name')?.tagName).toBe('SPAN') expect(Array.from(container.querySelectorAll('.preview button')).some((button) => button.textContent?.includes('검토본 생성'))).toBe(false) diff --git a/src/App.tsx b/src/App.tsx index 9899035..b05850e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react' import { AlertTriangle, Check, ChevronDown, ExternalLink, FileText, Link, Save } from 'lucide-react' -import { DraftFacts, getCompletedSteps, getDraftReview, getReview, initialFacts, initialItems, isWebServiceUrl, PolicyItem, steps } from './policy' +import { createPolicyExport, DraftFacts, getCompletedSteps, getDraftReview, getReview, initialFacts, initialItems, isWebServiceUrl, PolicyItem, steps } from './policy' type FactField = { key: keyof DraftFacts @@ -48,6 +48,7 @@ function FactStep({ current, title, description, fields, facts, setFacts, setCur setFacts: (facts: DraftFacts) => void setCurrent: (step: number) => void }) { + /** Updates one fact and clears dependent facts when its owning state makes them inapplicable. */ const update = (key: keyof DraftFacts, value: string) => { const next = { ...facts, [key]: value } as DraftFacts if (key === 'retentionStatus' && value !== 'applies') next.retentionPeriod = '' @@ -80,7 +81,9 @@ function CollectionForm({ items, setItems, noCollectionAttested, setNoCollection setNoCollectionAttested: (attested: boolean) => void setCurrent: (step: number) => void }) { + /** Applies one collection-item patch while preserving every sibling item. */ const update = (id: string, patch: Partial) => setItems(items.map((item) => item.id === id ? { ...item, ...patch } : item)) + /** Enforces a no-collection attestation by clearing facts that would contradict it. */ const setNoCollection = (attested: boolean) => { if (attested) setItems(items.map((item) => ({ ...item, enabled: false, purpose: '', detail: '', mode: '' }))) setNoCollectionAttested(attested) @@ -110,6 +113,7 @@ function CollectionForm({ items, setItems, noCollectionAttested, setNoCollection /** Captures processing purposes for collection items explicitly selected by the operator. */ function PurposeForm({ items, setItems, noCollectionAttested, setCurrent }: { items: PolicyItem[]; setItems: (items: PolicyItem[]) => void; noCollectionAttested: boolean; setCurrent: (step: number) => void }) { const enabled = items.filter((item) => item.enabled) + /** Updates the processing purpose for one selected collection item only. */ const updatePurpose = (id: string, purpose: string) => setItems(items.map((item) => item.id === id ? { ...item, purpose } : item)) return

3. 처리 목적

선택한 개인정보 항목마다 실제 처리 목적을 연결합니다. 목적이 없는 항목은 공개 검토를 통과할 수 없습니다.

@@ -216,9 +220,29 @@ export default function App() { const completedSteps = useMemo(() => getCompletedSteps(items, noCollectionAttested, facts), [items, noCollectionAttested, facts]) const blockingCount = collectionReview.blockingCount + draftFindings.length const [message, setMessage] = useState('') + /** Reports readiness for responsible review without claiming that a publication occurred. */ function publish() { setMessage(blockingCount ? '필수 확인 항목을 먼저 입력하세요.' : '필수 확인이 완료되었습니다. 현재 검토본을 책임자와 검토하고 필요한 사실을 보완하세요.') } + /** Downloads the deterministic local export and revokes its object URL after activation. */ + function exportDraft() { + let fileUrl: string | null = null + try { + fileUrl = URL.createObjectURL(new Blob([`${JSON.stringify(createPolicyExport(items, noCollectionAttested, facts), null, 2)}\n`], { type: 'application/json' })) + const downloadLink = document.createElement('a') + downloadLink.href = fileUrl + downloadLink.download = 'policyweave-draft.json' + downloadLink.click() + setMessage('') + } catch { + setMessage('JSON 파일을 내보내지 못했습니다. 다시 시도하세요.') + } finally { + if (fileUrl) { + const disposableFileUrl = fileUrl + setTimeout(() => URL.revokeObjectURL(disposableFileUrl), 0) + } + } + } return
-
PolicyWeave{facts.serviceName || '내 서비스'} 개인정보처리방침작성 중버전 0.1.0 (임시저장) 브라우저 작업 중
+
PolicyWeave{facts.serviceName || '내 서비스'} 개인정보처리방침작성 중버전 0.1.0 (임시저장) 브라우저 작업 중
검토 요약확인을 마친 뒤 공개 준비 상태를 확인하세요.
필수 확인 {blockingCount}건
권장 검토 {collectionReview.recommended.length}건
{message}
diff --git a/src/policy-export.test.ts b/src/policy-export.test.ts new file mode 100644 index 0000000..a2b9f5b --- /dev/null +++ b/src/policy-export.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { createPolicyExport, initialFacts, initialItems } from './policy' + +describe('policy JSON export', () => { + it('projects normalized operator facts into a deterministic versioned draft', () => { + const items = initialItems.map((item) => item.id === 'email' + ? { ...item, enabled: true, mode: '필수' as const, purpose: ' Account access ', detail: ' Signup form ' } + : item) + const facts = { + ...initialFacts, + serviceName: ' Buyer Portal ', + serviceUrl: ' https://buyer.example.test/privacy ', + retentionStatus: 'none' as const, + thirdPartyStatus: 'no' as const, + internationalStatus: 'no' as const, + privacyOfficerName: ' Privacy Team ', + privacyOfficerEmail: ' privacy@example.test ', + } + + expect(createPolicyExport(items, false, facts)).toEqual({ + schema_version: 1, + document_state: 'review_ready', + policy_facts: { + service_profile: { + service_name: 'Buyer Portal', + service_url: 'https://buyer.example.test/privacy', + }, + no_collection_attested: false, + collection_items: [{ + collection_item_key: 'email', + collection_item_label: '이메일 주소', + collection_mode: '필수', + collection_path: 'Signup form', + processing_purpose: 'Account access', + }], + retention: { + retention_status: 'none', + retention_period: null, + }, + third_party_transfer: { + transfer_status: 'no', + recipient_name: null, + transfer_purpose: null, + }, + international_transfer: { + transfer_status: 'no', + destination_country: null, + recipient_name: null, + }, + privacy_contact: { + contact_name: 'Privacy Team', + contact_email: 'privacy@example.test', + }, + }, + review_finding_codes: [], + }) + }) + + it('preserves unresolved state and finding codes without inventing facts', () => { + const exported = createPolicyExport(initialItems, false, initialFacts) + + expect(exported.document_state).toBe('incomplete') + expect(exported.policy_facts.collection_items).toEqual([]) + expect(exported.policy_facts.retention).toEqual({ + retention_status: null, + retention_period: null, + }) + expect(exported.review_finding_codes).toContain('collection_selection') + expect(exported.review_finding_codes).toContain('service_name') + expect(exported.review_finding_codes).toContain('retention_status') + }) + + it('keeps UI empty-string sentinels out of the portable schema types', () => { + const exported = createPolicyExport(initialItems, false, initialFacts) + + expectTypeOf(exported.policy_facts.retention.retention_status).toEqualTypeOf<'applies' | 'none' | null>() + expectTypeOf(exported.policy_facts.third_party_transfer.transfer_status).toEqualTypeOf<'yes' | 'no' | null>() + expectTypeOf(exported.policy_facts.international_transfer.transfer_status).toEqualTypeOf<'yes' | 'no' | null>() + }) + + it('normalizes an unresolved collection mode without leaking the UI empty-string sentinel', () => { + const items = initialItems.map((item) => item.id === 'email' + ? { ...item, enabled: true, purpose: 'Account access', detail: 'Signup form' } + : item) + + const exported = createPolicyExport(items, false, initialFacts) + + expect(exported.policy_facts.collection_items[0].collection_mode).toBeNull() + expect(exported.review_finding_codes).toContain('collection_mode:email') + }) + + it('rejects query and fragment service URLs instead of rewriting the authored destination', () => { + for (const serviceUrl of [ + 'https://example.test/app?tenant=acme', + 'https://example.test/#/privacy', + 'https://example.test/privacy?', + 'https://example.test/privacy#', + 'https://example.test/privacy?access_token=query-secret#fragment-secret', + ]) { + const exported = createPolicyExport(initialItems, false, { ...initialFacts, serviceUrl }) + + expect(exported.policy_facts.service_profile.service_url).toBeNull() + expect(exported.review_finding_codes).toContain('service_url_format') + } + + const secretExport = createPolicyExport(initialItems, false, { + ...initialFacts, + serviceUrl: 'https://example.test/privacy?access_token=query-secret#fragment-secret', + }) + expect(JSON.stringify(secretExport)).not.toContain('query-secret') + expect(JSON.stringify(secretExport)).not.toContain('fragment-secret') + }) + + it('preserves encoded question marks and hashes as pathname data', () => { + const exported = createPolicyExport(initialItems, false, { + ...initialFacts, + serviceUrl: 'https://example.test/privacy%3Fpolicy%23section', + }) + + expect(exported.policy_facts.service_profile.service_url).toBe('https://example.test/privacy%3Fpolicy%23section') + expect(exported.review_finding_codes).not.toContain('service_url_format') + }) + + it('does not export credentials embedded in an invalid service URL', () => { + const exported = createPolicyExport(initialItems, false, { + ...initialFacts, + serviceUrl: 'https://operator:secret@example.test', + }) + + expect(exported.policy_facts.service_profile.service_url).toBeNull() + expect(exported.review_finding_codes).toContain('service_url_format') + expect(JSON.stringify(exported)).not.toContain('operator:secret') + }) +}) diff --git a/src/policy.ts b/src/policy.ts index c792e31..f74a770 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -62,16 +62,24 @@ export const initialFacts: DraftFacts = { export const steps = ['서비스 정보', '수집 항목', '처리 목적', '보유 기간', '제3자 제공', '국외 이전', '개인정보 보호 담당자'] -/** Returns whether a service URL is an absolute HTTP(S) web location suitable for a buyer-facing policy target. */ -export function isWebServiceUrl(value: string) { +/** Returns a canonical credential-free HTTP(S) service URL, or null when the address is not admissible. */ +function normalizeWebServiceUrl(value: string): string | null { try { const url = new URL(value) - return (url.protocol === 'https:' || url.protocol === 'http:') && Boolean(url.hostname) && !url.username && !url.password + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || !url.hostname || url.username || url.password) return null + const normalized = url.toString() + if (normalized.includes('?') || normalized.includes('#')) return null + return normalized } catch { - return false + return null } } +/** Returns whether a service URL is an absolute HTTP(S) web location suitable for a buyer-facing policy target. */ +export function isWebServiceUrl(value: string) { + return normalizeWebServiceUrl(value) !== null +} + /** Applies the minimal address-shape contract needed for a usable contact channel without claiming mailbox existence. */ function isContactEmail(value: string) { return /^[^\s@]+@[^\s@]+$/.test(value) @@ -93,6 +101,7 @@ export function getReview(items: PolicyItem[], noCollectionAttested = false) { /** Derives non-collection authoring findings without inferring retention state from collection state. */ export function getDraftReview(facts: DraftFacts, _noCollectionAttested = false): DraftFinding[] { const findings: DraftFinding[] = [] + /** Appends one stable finding only when its owning operator-authored value is blank. */ const addWhenBlank = (value: string, code: string, step: number, label: string) => { if (!value.trim()) findings.push({ code, step, label }) } @@ -148,3 +157,97 @@ export function getCompletedSteps(items: PolicyItem[], noCollectionAttested: boo return completed } + + +/** Versioned local export of operator-authored policy facts and deterministic readiness evidence. */ +export type PolicyDraftExport = { + schema_version: 1 + document_state: 'incomplete' | 'review_ready' + policy_facts: { + service_profile: { + service_name: string | null + service_url: string | null + } + no_collection_attested: boolean + collection_items: Array<{ + collection_item_key: string + collection_item_label: string + collection_mode: Exclude | null + collection_path: string | null + processing_purpose: string | null + }> + retention: { + retention_status: Exclude | null + retention_period: string | null + } + third_party_transfer: { + transfer_status: Exclude | null + recipient_name: string | null + transfer_purpose: string | null + } + international_transfer: { + transfer_status: Exclude | null + destination_country: string | null + recipient_name: string | null + } + privacy_contact: { + contact_name: string | null + contact_email: string | null + } + } + review_finding_codes: string[] +} + +/** Creates a deterministic draft export without network access, inferred facts, or credential-bearing service URLs. */ +export function createPolicyExport(items: PolicyItem[], noCollectionAttested: boolean, facts: DraftFacts): PolicyDraftExport { + /** Normalizes optional human-entered text without inventing a non-empty fact. */ + const trimOrNull = (value: string) => value.trim() || null + const collectionReview = getReview(items, noCollectionAttested) + const reviewFindingCodes = [ + ...(collectionReview.selectionMissing ? ['collection_selection'] : []), + ...(collectionReview.collectionContradiction ? ['collection_contradiction'] : []), + ...collectionReview.modeBlocking.map((item) => `collection_mode:${item.id}`), + ...collectionReview.pathBlocking.map((item) => `collection_path:${item.id}`), + ...collectionReview.blocking.map((item) => `processing_purpose:${item.id}`), + ...getDraftReview(facts, noCollectionAttested).map((finding) => finding.code), + ] + const serviceUrl = facts.serviceUrl.trim() + + return { + schema_version: 1, + document_state: reviewFindingCodes.length === 0 ? 'review_ready' : 'incomplete', + policy_facts: { + service_profile: { + service_name: trimOrNull(facts.serviceName), + service_url: normalizeWebServiceUrl(serviceUrl), + }, + no_collection_attested: noCollectionAttested, + collection_items: collectionReview.enabled.map((item) => ({ + collection_item_key: item.id, + collection_item_label: item.label, + collection_mode: item.mode || null, + collection_path: trimOrNull(item.detail ?? ''), + processing_purpose: trimOrNull(item.purpose), + })), + retention: { + retention_status: facts.retentionStatus || null, + retention_period: facts.retentionStatus === 'applies' ? trimOrNull(facts.retentionPeriod) : null, + }, + third_party_transfer: { + transfer_status: facts.thirdPartyStatus || null, + recipient_name: facts.thirdPartyStatus === 'yes' ? trimOrNull(facts.thirdPartyRecipient) : null, + transfer_purpose: facts.thirdPartyStatus === 'yes' ? trimOrNull(facts.thirdPartyPurpose) : null, + }, + international_transfer: { + transfer_status: facts.internationalStatus || null, + destination_country: facts.internationalStatus === 'yes' ? trimOrNull(facts.internationalCountry) : null, + recipient_name: facts.internationalStatus === 'yes' ? trimOrNull(facts.internationalRecipient) : null, + }, + privacy_contact: { + contact_name: trimOrNull(facts.privacyOfficerName), + contact_email: trimOrNull(facts.privacyOfficerEmail), + }, + }, + review_finding_codes: reviewFindingCodes, + } +} diff --git a/tests/e2e/authoring.spec.ts b/tests/e2e/authoring.spec.ts index 84aff66..891dd70 100644 --- a/tests/e2e/authoring.spec.ts +++ b/tests/e2e/authoring.spec.ts @@ -1,5 +1,31 @@ import AxeBuilder from '@axe-core/playwright' -import { expect, test } from '@playwright/test' +import { expect, test, type Download, type Page } from '@playwright/test' + +async function readDownload(download: Download) { + const downloadStream = await download.createReadStream() + downloadStream.setEncoding('utf8') + let downloadContent = '' + for await (const contentChunk of downloadStream) downloadContent += contentChunk + return downloadContent +} + +async function installDownloadAudit(page: Page) { + await page.addInitScript(() => { + const audit = { created: [] as Array<{ url: string; type: string }>, revoked: [] as string[] } + const createObjectUrl = URL.createObjectURL.bind(URL) + const revokeObjectUrl = URL.revokeObjectURL.bind(URL) + Object.defineProperty(window, '__policyweaveDownloadAudit', { value: audit }) + URL.createObjectURL = (object) => { + const url = createObjectUrl(object) + audit.created.push({ url, type: object instanceof Blob ? object.type : '' }) + return url + } + URL.revokeObjectURL = (url) => { + audit.revoked.push(url) + revokeObjectUrl(url) + } + }) +} test('renders a truthful responsive initial workspace without serious accessibility violations', async ({ page }, testInfo) => { await page.goto('/') @@ -109,3 +135,144 @@ test('reflows the core authoring flow at an effective 200% browser zoom', async await page.keyboard.press('Enter') await expect(page.getByRole('heading', { level: 1, name: '2. 수집 항목' })).toBeFocused() }) + +test('downloads a versioned policy draft with real browser payload semantics', async ({ page }) => { + await page.goto('/') + await page.getByLabel('서비스 이름').fill('Buyer Portal') + + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: /JSON 내보내기/ }).click() + const download = await downloadPromise + + expect(download.suggestedFilename()).toBe('policyweave-draft.json') + const downloadContent = await readDownload(download) + + const exportedDraft = JSON.parse(downloadContent) + expect(exportedDraft).toMatchObject({ + schema_version: 1, + document_state: 'incomplete', + policy_facts: { + service_profile: { + service_name: 'Buyer Portal', + service_url: null, + }, + }, + }) + expect(exportedDraft.review_finding_codes).toEqual(expect.arrayContaining(['service_url', 'collection_selection'])) +}) + +test('keeps keyboard exports byte-stable and revokes every JSON object URL', async ({ page }) => { + await installDownloadAudit(page) + await page.goto('/') + await page.getByLabel('서비스 이름').fill('Buyer Portal') + + const exportButton = page.getByRole('button', { name: /JSON 내보내기/ }) + const exportedBytes: string[] = [] + for (let exportAttempt = 0; exportAttempt < 2; exportAttempt += 1) { + const downloadPromise = page.waitForEvent('download') + await exportButton.focus() + await page.keyboard.press('Enter') + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('policyweave-draft.json') + exportedBytes.push(await readDownload(download)) + } + + expect(exportedBytes[1]).toBe(exportedBytes[0]) + await expect.poll(() => page.evaluate(() => { + const audit = (window as typeof window & { __policyweaveDownloadAudit: { created: Array<{ url: string; type: string }>; revoked: string[] } }).__policyweaveDownloadAudit + return { created: audit.created.length, revoked: audit.revoked.length } + })).toEqual({ created: 2, revoked: 2 }) + const downloadAudit = await page.evaluate(() => (window as typeof window & { + __policyweaveDownloadAudit: { created: Array<{ url: string; type: string }>; revoked: string[] } + }).__policyweaveDownloadAudit) + expect(downloadAudit.created.map(({ type }) => type)).toEqual(['application/json', 'application/json']) + expect(new Set(downloadAudit.created.map(({ url }) => url)).size).toBe(2) + expect(downloadAudit.revoked).toEqual(downloadAudit.created.map(({ url }) => url)) +}) + +test('reports a download activation failure and revokes its JSON object URL', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'One browser profile proves the activation-error lifecycle.') + + await installDownloadAudit(page) + await page.addInitScript(() => { + const click = HTMLAnchorElement.prototype.click + HTMLAnchorElement.prototype.click = function () { + if (this.download === 'policyweave-draft.json') throw new Error('simulated download activation failure') + click.call(this) + } + }) + await page.goto('/') + + const pageErrors: Error[] = [] + page.on('pageerror', (error) => pageErrors.push(error)) + await page.getByRole('button', { name: /JSON 내보내기/ }).click() + await expect(page.locator('output')).toHaveText('JSON 파일을 내보내지 못했습니다. 다시 시도하세요.') + + await expect.poll(() => page.evaluate(() => { + const audit = (window as typeof window & { + __policyweaveDownloadAudit: { created: Array<{ url: string; type: string }>; revoked: string[] } + }).__policyweaveDownloadAudit + return { created: audit.created.map(({ url }) => url), revoked: audit.revoked } + })).toEqual({ created: [expect.any(String)], revoked: [expect.any(String)] }) + const downloadAudit = await page.evaluate(() => (window as typeof window & { + __policyweaveDownloadAudit: { created: Array<{ url: string; type: string }>; revoked: string[] } + }).__policyweaveDownloadAudit) + expect(downloadAudit.revoked).toEqual(downloadAudit.created.map(({ url }) => url)) + expect(pageErrors).toEqual([]) +}) + +test('reports an object URL creation failure without leaking a page error', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'One browser profile proves the pre-activation error boundary.') + + await page.addInitScript(() => { + URL.createObjectURL = () => { + throw new Error('simulated object URL creation failure') + } + }) + await page.goto('/') + + const pageErrors: Error[] = [] + page.on('pageerror', (error) => pageErrors.push(error)) + await page.getByRole('button', { name: /JSON 내보내기/ }).click() + await expect(page.locator('output')).toHaveText('JSON 파일을 내보내지 못했습니다. 다시 시도하세요.') + expect(pageErrors).toEqual([]) +}) + +test('exports a review-ready no-collection draft without unresolved findings', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'One complete export proves state semantics; layout coverage is exercised separately.') + + await page.goto('/') + await page.getByLabel('서비스 이름').fill('Example Service') + await page.getByLabel('서비스 URL').fill('https://example.test/privacy') + await page.locator('.rail').getByRole('button', { name: /수집 항목/ }).click() + await page.getByRole('checkbox', { name: '개인정보를 수집하지 않음으로 확인' }).check() + await page.locator('.rail').getByRole('button', { name: /보유 기간/ }).click() + await page.getByLabel('개인정보 보유 여부').selectOption('none') + await page.locator('.rail').getByRole('button', { name: /제3자 제공/ }).click() + await page.getByLabel('제3자 제공 여부').selectOption('no') + await page.locator('.rail').getByRole('button', { name: /국외 이전/ }).click() + await page.getByLabel('국외 이전 여부').selectOption('no') + await page.locator('.rail').getByRole('button', { name: /개인정보 보호 담당자/ }).click() + await page.getByLabel('담당자 또는 담당 부서').fill('Privacy Team') + await page.getByLabel('연락 이메일').fill('privacy@example.test') + await expect(page.getByText('7/7 완료')).toBeVisible() + + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: /JSON 내보내기/ }).click() + const exportedDraft = JSON.parse(await readDownload(await downloadPromise)) + + expect(exportedDraft.document_state).toBe('review_ready') + expect(exportedDraft.review_finding_codes).toEqual([]) +}) + +test('accepts touch activation for the mobile JSON download', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'mobile-chromium', 'Touch activation is scoped to the touch-enabled mobile project.') + + await page.goto('/') + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: /JSON 내보내기/ }).tap() + const download = await downloadPromise + + expect(download.suggestedFilename()).toBe('policyweave-draft.json') + expect(JSON.parse(await readDownload(download)).schema_version).toBe(1) +})