diff --git a/.github/workflows/codeql-required.yml b/.github/workflows/codeql-required.yml new file mode 100644 index 00000000..aa6b33e3 --- /dev/null +++ b/.github/workflows/codeql-required.yml @@ -0,0 +1,56 @@ +name: CodeQL Required + +on: + # No base-branch filter: stacked PRs target feature branches, and their + # exact contributor heads still require CodeQL evidence before integration. + pull_request: + push: + branches: ["develop", "master"] + schedule: + - cron: "15 2 * * 6" + +permissions: + contents: read + +concurrency: + group: codeql-required-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + language: + - javascript-typescript + - python + steps: + - name: Checkout exact revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Verify exact checkout + env: + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + test "$actual_sha" = "$EXPECTED_CHECKOUT_SHA" + + - name: Initialize CodeQL + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + with: + category: "/language:${{ matrix.language }}" + upload: never + upload-database: false diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 1a9461d5..00000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: CodeQL - -on: - pull_request: - branches: ["develop"] - push: - branches: ["develop", "master"] - schedule: - - cron: "15 2 * * 6" - -permissions: - contents: read - -concurrency: - group: codeql-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - language: - - javascript-typescript - - python - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 - with: - languages: ${{ matrix.language }} - - - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 - with: - category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 6795d40e..a37ae586 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -16,16 +16,47 @@ jobs: dependency-review: runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout exact revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - name: Verify exact checkout + env: + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + shell: bash + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + test "$actual_sha" = "$EXPECTED_CHECKOUT_SHA" + + - name: Resolve current protected base revision + id: resolve_live_base + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + shell: bash + run: | + set -euo pipefail + test -n "$BASE_REF" + + result="$(git ls-remote --exit-code origin "refs/heads/$BASE_REF")" + mapfile -t live_base_matches <<<"$result" + test "${#live_base_matches[@]}" -eq 1 + read -r live_base_sha live_base_ref extra <<<"${live_base_matches[0]}" + test "$live_base_ref" = "refs/heads/$BASE_REF" + test -z "${extra:-}" + printf '%s\n' "$live_base_sha" | grep -Eq '^[0-9a-f]{40}$' + + echo "Resolved refs/heads/$BASE_REF to $live_base_sha for dependency comparison" + echo "base_sha=$live_base_sha" >>"$GITHUB_OUTPUT" + - name: Check dependency review support id: dependency_review_support env: GH_TOKEN: ${{ github.token }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_SHA: ${{ steps.resolve_live_base.outputs.base_sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} REPOSITORY: ${{ github.repository }} shell: bash @@ -38,35 +69,52 @@ jobs: exit 0 fi + test -n "$BASE_SHA" + test -n "$HEAD_SHA" + api_url="${GITHUB_API_URL:-https://api.github.com}" + relationship_file="$(mktemp)" response_file="$(mktemp)" - status="$( - curl -fsS -o "$response_file" -w '%{http_code}' \ + trap 'rm -f "$relationship_file" "$response_file"' EXIT + + relationship_http_status="$( + curl -sS -o "$relationship_file" -w '%{http_code}' \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" \ - || true + "${api_url}/repos/${REPOSITORY}/compare/${BASE_SHA}...${HEAD_SHA}" )" + if [ "$relationship_http_status" != "200" ]; then + echo "::error::Live-base ancestry evidence is unavailable (HTTP ${relationship_http_status})." + exit 1 + fi - if [ "$status" = "200" ]; then - echo "supported=true" >>"$GITHUB_OUTPUT" - exit 0 + comparison_status="$(jq -er '.status' "$relationship_file")" + if [ "$comparison_status" != "ahead" ] && [ "$comparison_status" != "identical" ]; then + echo "::error::Exact contributor head does not contain the resolved live protected base (status: ${comparison_status}). Refresh the branch before interpreting dependency differences." + exit 1 fi - if [ "$status" = "403" ] || [ "$status" = "404" ]; then - echo "::warning::Dependency review is unavailable for ${REPOSITORY}; skipping dependency-review hard gate." - echo "supported=false" >>"$GITHUB_OUTPUT" - exit 0 + status="$( + curl -sS -o "$response_file" -w '%{http_code}' \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" + )" + + if [ "$status" != "200" ]; then + echo "::error::Dependency review comparison evidence is unavailable (HTTP ${status})." + exit 1 fi - echo "::error::Dependency review support check failed with HTTP ${status}." - cat "$response_file" - exit 1 + echo "supported=true" >>"$GITHUB_OUTPUT" - name: Dependency review if: steps.dependency_review_support.outputs.supported == 'true' uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: + base-ref: ${{ steps.resolve_live_base.outputs.base_sha }} + head-ref: ${{ github.event.pull_request.head.sha }} fail-on-severity: moderate comment-summary-in-pr: on-failure diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 10f85b8b..42050924 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -35,10 +35,18 @@ jobs: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - name: Verify exact checkout + env: + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + actual_sha="$(git rev-parse HEAD)" + test "$actual_sha" = "$EXPECTED_CHECKOUT_SHA" + - name: Set up Node.js - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22.13.0' cache: 'npm' @@ -49,14 +57,12 @@ jobs: - name: Select iteration budget id: budget shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_FUZZ_RUNS: ${{ github.event.inputs.fuzz_runs }} run: | - if [ "${{ github.event_name }}" = "schedule" ]; then - echo "runs=200000" >> "$GITHUB_OUTPUT" - elif [ -n "${{ github.event.inputs.fuzz_runs }}" ]; then - echo "runs=${{ github.event.inputs.fuzz_runs }}" >> "$GITHUB_OUTPUT" - else - echo "runs=20000" >> "$GITHUB_OUTPUT" - fi + runs="$(bash scripts/ci/select_fuzz_budget.sh "$EVENT_NAME" "$INPUT_FUZZ_RUNS")" + printf 'runs=%s\n' "$runs" >> "$GITHUB_OUTPUT" - name: Run property fuzz targets env: diff --git a/.github/workflows/osvscanner.yml b/.github/workflows/osvscanner.yml index 806c8086..c383d9c0 100644 --- a/.github/workflows/osvscanner.yml +++ b/.github/workflows/osvscanner.yml @@ -15,20 +15,169 @@ concurrency: jobs: osv-scan: if: github.event_name == 'pull_request' - # Companion SCA lane for manifest evidence. Central .github still owns the - # required review/security/scheduler workflows. - uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@3a7550f43ba5b58905a821ce3a0ed24c4858b3f4 # v2.3.8 + export-results gate + runs-on: ubuntu-latest permissions: actions: read contents: read - security-events: write - with: - scan-args: |- - --maven-registry=https://maven-central.storage-download.googleapis.com/maven2 - --no-resolve - -r - ./ - fail-on-vuln: false + steps: + - name: Checkout current protected base revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.base.ref }} + persist-credentials: false + path: osv-scan-source + + - name: Record resolved protected base checkout + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + test -n "$BASE_REF" + actual_sha="$(cd osv-scan-source && git rev-parse HEAD)" + echo "Resolved refs/heads/$BASE_REF to $actual_sha for the OSV baseline scan" + + - name: Scan current protected base dependencies + id: scan-base + uses: google/osv-scanner-action/osv-scanner-action@6e4298ebc4db23e847df9b2e2de2939d6f066c67 # v2.5.1 + continue-on-error: true + with: + scan-args: |- + --format=json + --output=old-results.json + --maven-registry=https://maven-central.storage-download.googleapis.com/maven2 + --no-resolve + -r + ./osv-scan-source + + - name: Fail closed on incomplete protected-base scan + if: ${{ steps.scan-base.outcome == 'failure' }} + env: + RESULT_FILE: old-results.json + run: | + set -euo pipefail + node --input-type=module <<'NODE' + import { readFileSync } from 'node:fs'; + + const resultFile = process.env.RESULT_FILE; + if (!resultFile) { + console.error('::error::OSV scan completion guard has no result file'); + process.exit(1); + } + + let scanResult; + try { + scanResult = JSON.parse(readFileSync(resultFile, 'utf8')); + } catch { + console.error(`::error::OSV scanner failed without valid JSON evidence in ${resultFile}`); + process.exit(1); + } + + if (!scanResult || !Array.isArray(scanResult.results)) { + console.error(`::error::OSV scanner failed without a results array in ${resultFile}`); + process.exit(1); + } + + const vulnerabilities = scanResult.results + .flatMap((result) => Array.isArray(result?.packages) ? result.packages : []) + .flatMap((entry) => Array.isArray(entry?.vulnerabilities) ? entry.vulnerabilities : []); + + if (vulnerabilities.length === 0) { + console.error(`::error::OSV scanner failed without vulnerability evidence in ${resultFile}; refusing ambiguous or incomplete scan output`); + process.exit(1); + } + NODE + + - name: Checkout exact contributor revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + clean: false + path: osv-scan-source + + - name: Sanitize exact contributor scan tree + run: | + set -euo pipefail + git -C osv-scan-source clean -ffdx + test -z "$(git -C osv-scan-source status --porcelain)" + + - name: Verify exact contributor checkout + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + actual_sha="$(cd osv-scan-source && git rev-parse HEAD)" + test "$actual_sha" = "$EXPECTED_HEAD_SHA" + + - name: Scan exact contributor dependencies + id: scan-head + uses: google/osv-scanner-action/osv-scanner-action@6e4298ebc4db23e847df9b2e2de2939d6f066c67 # v2.5.1 + continue-on-error: true + with: + scan-args: |- + --format=json + --output=new-results.json + --maven-registry=https://maven-central.storage-download.googleapis.com/maven2 + --no-resolve + -r + ./osv-scan-source + + - name: Fail closed on incomplete contributor scan + if: ${{ steps.scan-head.outcome == 'failure' }} + env: + RESULT_FILE: new-results.json + run: | + set -euo pipefail + node --input-type=module <<'NODE' + import { readFileSync } from 'node:fs'; + + const resultFile = process.env.RESULT_FILE; + if (!resultFile) { + console.error('::error::OSV scan completion guard has no result file'); + process.exit(1); + } + + let scanResult; + try { + scanResult = JSON.parse(readFileSync(resultFile, 'utf8')); + } catch { + console.error(`::error::OSV scanner failed without valid JSON evidence in ${resultFile}`); + process.exit(1); + } + + if (!scanResult || !Array.isArray(scanResult.results)) { + console.error(`::error::OSV scanner failed without a results array in ${resultFile}`); + process.exit(1); + } + + const vulnerabilities = scanResult.results + .flatMap((result) => Array.isArray(result?.packages) ? result.packages : []) + .flatMap((entry) => Array.isArray(entry?.vulnerabilities) ? entry.vulnerabilities : []); + + if (vulnerabilities.length === 0) { + console.error(`::error::OSV scanner failed without vulnerability evidence in ${resultFile}; refusing ambiguous or incomplete scan output`); + process.exit(1); + } + NODE + + - name: Compare dependency findings + uses: google/osv-scanner-action/osv-reporter-action@6e4298ebc4db23e847df9b2e2de2939d6f066c67 # v2.5.1 + with: + scan-args: |- + --output=results.sarif + --old=old-results.json + --new=new-results.json + --gh-annotations=true + --fail-on-vuln=true + + - name: Preserve exact-head OSV SARIF + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scopeweave-osv-${{ github.run_id }}-${{ github.run_attempt }} + path: results.sarif + if-no-files-found: error + retention-days: 3 manifest-pattern-coverage: if: github.event_name == 'workflow_dispatch' diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 458d3aa9..34249274 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -24,17 +24,59 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - name: Verify exact checkout + env: + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + actual_sha="$(git rev-parse HEAD)" + if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then + echo "::error::Server Tests checked out $actual_sha, expected $EXPECTED_CHECKOUT_SHA" + exit 1 + fi - name: Setup Node 22.13 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22.13.0 - name: Install run: npm ci - - name: Unit tests (EVM · CPM · baseline · workload) - run: npm run test:unit - - name: API tests (auth · tenancy · RBAC · billing · webhooks · rate limit) - run: npm run test:api + - name: Install Playwright (chromium for coverage) + timeout-minutes: 10 + run: npx playwright install chromium + - name: Exact owned production coverage + id: coverage + run: npm run test:coverage + - name: Coverage failure diagnostics + if: ${{ failure() && steps.coverage.conclusion == 'failure' }} + run: | + found_report=0 + for report in coverage/coverage-final.json coverage/browser-coverage-final.json; do + if [ -f "$report" ]; then + found_report=1 + if ! node scripts/ci/coverage_diagnostics.mjs "$report"; then + echo "::warning::coverage diagnostics could not inspect $report" + fi + fi + done + if [ "$found_report" -eq 0 ]; then + echo "::error::coverage diagnostics unavailable: no Istanbul coverage report found" + exit 1 + fi + - name: Preserve exact coverage failure evidence + if: ${{ failure() && steps.coverage.conclusion == 'failure' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scopeweave-coverage-${{ github.run_id }}-${{ github.run_attempt }} + path: | + coverage/coverage-final.json + coverage/coverage-summary.json + coverage/browser-coverage-final.json + coverage/browser-coverage-summary.json + if-no-files-found: error + retention-days: 3 + - name: Public docstring gate + run: npm run check:python-docstrings - name: app.js stays eval-safe (no top-level import/export) run: node -e "new Function(require('fs').readFileSync('app.js','utf8')); console.log('eval-safe OK')" @@ -44,7 +86,17 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - name: Verify exact checkout + env: + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + actual_sha="$(git rev-parse HEAD)" + if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then + echo "::error::Server Tests checked out $actual_sha, expected $EXPECTED_CHECKOUT_SHA" + exit 1 + fi - name: Setup Node 22.13 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: @@ -52,6 +104,7 @@ jobs: - name: Install run: npm ci - name: Install Playwright (chromium) - run: npx playwright install chromium --with-deps + timeout-minutes: 10 + run: npx playwright install chromium - name: Cloud UI e2e - run: npm run test:e2e:cloud + run: npm run test:e2e diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..a436cfcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,11 +56,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Switched the repository-local OpenCode development configuration from GitHub Models to an NVIDIA NIM-only candidate set while preserving organization-level review-workflow ownership in `ContextualWisdomLab/.github`. +- Moved the repository-owned property-fuzz setup action to immutable + `actions/setup-node` v7.0.0 so its JavaScript action runtime declares Node.js + 24 instead of relying on GitHub's compatibility override for deprecated + Node.js 20, while retaining Node.js 22.13.0 for ScopeWeave itself. - Production planning-analysis requests now combine tenant-bound, server-derived contextual-orchestrator cost attribution with explicit `auto` orchestration mode, delegating provider/model/topology policy to the shared service without weakening ScopeWeave's authenticated, fail-closed transport or response boundary controls. +- Bound repository `Server Tests` to the exact pull-request contributor head (or + exact protected-`develop` push SHA) and fail closed when the runner's actual + checkout differs, preventing synthetic merge results from being mistaken for + contributor-head test evidence. +- Restored protected `Analyze (javascript-typescript)` and `Analyze (python)` + contexts through one exact-head, non-publishing repository CodeQL lane for + `develop`-bound and stacked pull requests while GitHub default setup remains + the sole SARIF publication authority; retired the stale advanced publisher + and removed unused code-scanning write authority from the required lane. +- Made OSV differential scanning resolve the current protected base **ref** at + runner execution instead of treating the pull-request base SHA snapshot as a + live-base authority, verify the immutable contributor head, retain the + baseline result across checkout, preserve the protected-base `scan` identity, + and pin direct scanner/reporter actions to the revision used by upstream + v2.5.0 instead of delegating to synthetic-merge checkout behavior. - Accepted XML whitespace before exact Microsoft Project element delimiters while preserving the linear, regex-free import scanner and rejecting attributes, longer names, non-XML whitespace, nested unmatched blocks, and diff --git a/app.js b/app.js index a04aae71..3092cff7 100644 --- a/app.js +++ b/app.js @@ -236,7 +236,7 @@ async function bootstrap() { } // Optional cloud overlay (loaded as a separate module; undefined offline). - const cloudApi = typeof window !== 'undefined' ? window.ScopeWeaveCloud : null; + const cloudApi = window.ScopeWeaveCloud; cloudApi?.init?.({ hydrateState, renderAll, @@ -540,7 +540,6 @@ function renderAll() { elements.exportCsvButton.title = hasTasks ? '' : '내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'; elements.openGanttButton.title = hasTasks ? '' : '간트 차트로 표시할 작업이 없습니다. 작업을 먼저 추가해주세요.'; - // ⚡ Bolt: Cache parent IDs to convert O(N^2) render loop to O(N) cachedHasChildrenSet.clear(); state.tasks.forEach(task => { if (task.parentId) cachedHasChildrenSet.add(task.parentId); @@ -627,7 +626,6 @@ function createEmptyStateRow() { return row; } -// Cache an unattached td shell so hot render loops clone instead of allocate. let tableCellTemplate = null; function createTableCell(className, content) { if (!tableCellTemplate) { @@ -643,9 +641,6 @@ function createTableCell(className, content) { return cell; } -// ⚡ Bolt: Cache unattached DOM elements as templates to eliminate repetitive -// document.createElement() JS-to-C++ allocation overhead during O(N) table rendering loops. -// Using cloneNode() is measurably faster when creating thousands of rows. let taskRowTemplate = null; let actionCellTemplate = null; let actionStackTemplate = null; @@ -745,7 +740,6 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { return row; } -// ⚡ Bolt: Cache static DOM structures to avoid JS-to-C++ instantiation overhead in hot rendering paths. let dragHandleTemplate = null; function getDragHandleTemplate() { if (!dragHandleTemplate) { @@ -829,7 +823,6 @@ function renderEditorRow(anchorId) { cancelButton.textContent = '취소'; cancelButton.title = '취소 (Esc)'; cancelButton.setAttribute('aria-keyshortcuts', 'Escape'); - // ⚡ Bolt: Attach listener once during creation to prevent O(N) accumulation in renderEditorValidation cancelButton.addEventListener('click', () => closeEditor()); const errors = document.createElement('div'); errors.id = 'editor-errors'; @@ -935,10 +928,6 @@ function createTextCellContent(value, warning = '') { return wrapper; } -// ⚡ Bolt: Cache empty cell DOM structure as a template and use cloneNode(true). -// Repeatedly constructing DOM trees node-by-node in hot render paths causes significant -// JS-to-C++ bridge overhead and GC pressure. Cloning an existing node structure is -// substantially faster (often 2-3x in large grids). let emptyCellTemplate = null; function createEmptyCell() { @@ -1098,7 +1087,6 @@ function handleInlineProgressChange(event) { persistState(); renderAll(); - // 🎨 Palette: Restore focus to the dropdown after full DOM re-render requestAnimationFrame(() => { const dropdown = document.querySelector(`[data-inline-progress="${taskId}"]`); if (dropdown) { @@ -1118,7 +1106,6 @@ function handleRowAction(action, taskId) { persistState(); renderAll(); - // 🎨 Palette: Restore focus to the toggle button after full DOM re-render requestAnimationFrame(() => { const toggleBtn = document.querySelector(`tr[data-task-id="${taskId}"] button[data-action="toggle"]`); if (toggleBtn) { @@ -1153,7 +1140,6 @@ function handleRowAction(action, taskId) { renderAll(); showToast('작업을 삭제했습니다.'); - // 🎨 Palette: Restore focus after deletion to keep keyboard flow requestAnimationFrame(() => { const visibleTasksAfter = getVisibleTasks(); if (visibleTasksAfter.length > 0) { @@ -1207,7 +1193,6 @@ function openEditor({ mode, targetId = null, parentId = null, depth = 1, insertA } renderAll(); - // Focus the first input/select in the editor to keep keyboard users in flow requestAnimationFrame(() => { const firstInput = document.querySelector('.editor-row input:not([type="hidden"]), .editor-row select'); if (firstInput) { @@ -1253,15 +1238,15 @@ function saveEditor() { } if (state.editor.mode === 'create') { - const newTask = { - ...createEmptyTaskDraft(), - ...sanitizeDraft(state.editor.draft), - id: createId(), - parentId: state.editor.parentId, - depth: state.editor.depth, - expanded: true, - isSynthetic: false - }; + const newTask = { + ...createEmptyTaskDraft(), + ...sanitizeDraft(state.editor.draft), + id: createId(), + parentId: state.editor.parentId, + depth: state.editor.depth, + expanded: true, + isSynthetic: false + }; insertTaskAfter(newTask, state.editor.insertAfterId); } @@ -1309,10 +1294,8 @@ function createChildDraft(task) { function sanitizeDraft(draft) { const sanitized = {}; EDITABLE_FIELDS.forEach((field) => { - // 🛡️ Sentinel: Enforce string coercion before trim() to prevent DoS via type confusion sanitized[field] = String(draft?.[field] || '').trim().slice(0, 1000); }); - // 🛡️ Sentinel: Strictly validate against allowed options to prevent injection if (!sanitized.actualProgressStatus || !ACTUAL_PROGRESS_OPTIONS.includes(sanitized.actualProgressStatus)) { sanitized.actualProgressStatus = '미착수(0%)'; } @@ -1370,7 +1353,6 @@ function validateDateRange(startLabel, startValue, endLabel, endValue, errors) { } function computeTaskMetrics() { - // ⚡ Bolt: Cache durationDays during total calculation to avoid recalculating for every task const durationCache = new Map(); const totalDays = state.tasks.reduce((sum, task) => { const duration = calculateDurationDays(task.plannedStartDate, task.plannedEndDate); @@ -1467,7 +1449,6 @@ function calculatePlannedProgressRatio(baseDate, startDate, endDate, durationDay if (compareDateStrings(baseDate, endDate) >= 0) { return 1; } - // Bolt: Reuse passed durationDays if available to avoid redundant Date parsing and calculations. const total = durationDays !== undefined ? durationDays : calculateDurationDays(startDate, endDate); if (total <= 0) { return 1; @@ -1501,7 +1482,6 @@ function getVisibleTasks() { const visible = []; cachedHiddenParentIds.clear(); - // ⚡ Bolt Optimization: Single-pass O(N) visible task filtering to avoid redundant O(N * Depth) tree traversals state.tasks.forEach((task) => { if (cachedHiddenParentIds.has(task.parentId)) { cachedHiddenParentIds.add(task.id); @@ -1534,7 +1514,6 @@ function insertTaskAfter(task, afterId) { } function deleteTaskAndDescendants(taskId) { - // ⚡ Bolt: Replace O(N * Depth) cascading loop with O(N) map-based BFS to prevent UI freeze during deletion const childrenMap = new Map(); state.tasks.forEach(task => { if (task.parentId) { @@ -1587,7 +1566,6 @@ function canReorderWithinLevel(draggedTask, targetTask) { } function getLastRootTaskId() { - // Walk backward to avoid allocating an intermediate roots array. let lastRoot = null; for (let i = state.tasks.length - 1; i >= 0; i -= 1) { if (!state.tasks[i].parentId) { @@ -1738,10 +1716,6 @@ function normalizeImportedTasks(sourceTasks) { if (!Array.isArray(sourceTasks)) { return []; } - // Defensive: a hand-edited or tampered wbs.json / localStorage payload can - // contain non-object entries (null, numbers, arrays). Drop them so a junk - // seed row degrades gracefully instead of throwing an uncaught TypeError - // during bootstrap() (which does not wrap this call in try/catch). const records = sourceTasks.filter(isTaskRecord); records.forEach((task, index) => validateImportedTask(task, index)); const hasExplicitDepth = records.some((task) => task.__depth || task.__id || task.__parentId); @@ -1760,9 +1734,6 @@ function normalizeImportedTasks(sourceTasks) { } function clampImportedDepth(task) { - // The CSV path enforces __depth in {1,2,3} (validateCsvDepth). Apply the same - // contract to the JSON seed path so a tampered wbs.json can't inject an - // out-of-range depth (e.g. "4") that the 3-level renderer never expects. const parsedDepth = Number(task.__depth); if (Number.isInteger(parsedDepth) && parsedDepth >= 1 && parsedDepth <= 3) { return parsedDepth; @@ -2027,8 +1998,6 @@ function validateImportedTasks(tasks) { throw new Error(`존재하지 않는 부모 ID를 참조합니다: ${task.parentId}`); } } - // Detect cycles - // ⚡ Bolt: Use O(1) Map lookup instead of O(N) tasks.find to prevent O(N^2) bottleneck during cycle detection const taskById = new Map(tasks.map(t => [t.id, t])); for (const task of tasks) { let current = task.parentId; @@ -2168,11 +2137,15 @@ async function connectJsonSync() { } try { - state.jsonSyncHandle = await window.showSaveFilePicker({ + const candidate = await window.showSaveFilePicker({ suggestedName: 'wbs.json', types: [{ description: 'JSON Files', accept: { 'application/json': ['.json'] } }] }); - await writeJsonSyncFile(); + if (!candidate || typeof candidate.createWritable !== 'function') { + throw new TypeError('Invalid file handle'); + } + await writeJsonSyncHandle(candidate); + state.jsonSyncHandle = candidate; renderAll(); showToast('wbs.json 자동저장 연결이 완료되었습니다.'); } catch (error) { @@ -2182,13 +2155,17 @@ async function connectJsonSync() { } } +async function writeJsonSyncHandle(handle) { + const writable = await handle.createWritable(); + await writable.write(JSON.stringify(exportJsonArray(), null, 2)); + await writable.close(); +} + async function writeJsonSyncFile() { if (!state.jsonSyncHandle) { return; } - const writable = await state.jsonSyncHandle.createWritable(); - await writable.write(JSON.stringify(exportJsonArray(), null, 2)); - await writable.close(); + await writeJsonSyncHandle(state.jsonSyncHandle); } function exportJsonArray() { @@ -2214,7 +2191,6 @@ function openGanttModal() { state.previousFocus = document.activeElement; elements.ganttModal.classList.remove('hidden'); renderGantt(); - // Focus the modal to handle Escape key properly elements.ganttModal.focus(); } @@ -2310,7 +2286,6 @@ function renderGantt() { return; } - // ⚡ Bolt: Use direct string comparison for minDate/maxDate calculation since plannedTasks already filter for valid dates. const minDate = plannedTasks.reduce((min, task) => (task.plannedStartDate < min ? task.plannedStartDate : min), plannedTasks[0].plannedStartDate); const maxDate = plannedTasks.reduce((max, task) => (task.plannedEndDate > max ? task.plannedEndDate : max), plannedTasks[0].plannedEndDate); const weekdays = buildWeekdayTimeline(minDate, maxDate); @@ -2434,7 +2409,6 @@ function buildWeekdayTimeline(minDate, maxDate) { const days = []; let cursor = getMonday(minDate); const endBoundary = getFriday(maxDate); - // ⚡ Bolt: Use direct string comparison for cursor loop since both are generated valid dates. while (cursor <= endBoundary) { if (!isWeekend(cursor)) { days.push({ @@ -2448,7 +2422,6 @@ function buildWeekdayTimeline(minDate, maxDate) { } function groupTimelineByWeek(days) { - // ⚡ Bolt: Use an O(1) Map instead of O(N) Array.find to avoid O(N^2) bottleneck when grouping timeline days const groups = []; const groupMap = new Map(); days.forEach((day) => { @@ -2570,7 +2543,6 @@ function downloadFile(content, fileName, mimeType) { const link = document.createElement('a'); link.href = url; link.download = fileName; - // Keep generated download links isolated from any browsing context changes. link.rel = 'noopener noreferrer'; document.body.appendChild(link); link.click(); @@ -2589,12 +2561,10 @@ function sanitizeCsvFormulaValue(value) { } function createId(seed = Date.now()) { - // Security enhancement: Prefer crypto.randomUUID for stronger randomness if (typeof crypto !== 'undefined') { if (crypto.randomUUID) { return `task-${crypto.randomUUID()}`; } - // Fallback: use crypto.getRandomValues if randomUUID is unavailable if (crypto.getRandomValues) { const arr = new Uint32Array(2); crypto.getRandomValues(arr); @@ -2604,8 +2574,6 @@ function createId(seed = Date.now()) { throw new Error('Secure random number generation is not supported in this environment'); } -// ⚡ Bolt: Memoize date parsing and validation to reduce GC pressure and expensive Date allocations in tight render loops - function isValidDateString(value) { if (!isValidDateString.cache) isValidDateString.cache = new Map(); const validDateCache = isValidDateString.cache; @@ -2617,7 +2585,6 @@ function isValidDateString(value) { return false; } const isValid = formatDateInput(new Date(dateStringToUtcMs(value))) === value; - // Bolt: Increase cache limits to prevent cache thrashing in large loops. if (validDateCache.size < 10000) { validDateCache.set(value, isValid); } @@ -2631,12 +2598,10 @@ function dateStringToUtcMs(value) { if (dateToUtcMsCache.has(value)) { return dateToUtcMsCache.get(value); } - // Bolt: Avoid split().map() array allocations in tight rendering loops. const year = Number(value.substring(0, 4)); const month = Number(value.substring(5, 7)); const day = Number(value.substring(8, 10)); const ms = Date.UTC(year, month - 1, day); - // Bolt: Increase cache limits to prevent cache thrashing in large loops. if (dateToUtcMsCache.size < 10000) { dateToUtcMsCache.set(value, ms); } @@ -2754,7 +2719,6 @@ function debounce(callback, wait) { return debounced; } -// Export for testing if (typeof window !== 'undefined') { window.validateDraft = validateDraft; window.sanitizeCsvFormulaValue = sanitizeCsvFormulaValue; diff --git a/docs/doctoring/contextual-orchestrator-auto-default.md b/docs/doctoring/contextual-orchestrator-auto-default.md index c3d5d2f5..a1e87937 100644 --- a/docs/doctoring/contextual-orchestrator-auto-default.md +++ b/docs/doctoring/contextual-orchestrator-auto-default.md @@ -2,7 +2,7 @@ ## Status -Active pull-request evidence. This record does not describe protected `develop` until the owning pull request is integrated. +Protected `develop` shipped truth at `df0fa17bd5035af6455c889022c540b4f439e3d6`. This record describes the integrated ScopeWeave boundary; any later protected-tip movement requires fresh verification before release claims. ## Decision boundary diff --git a/docs/doctoring/fuzz-setup-node-runtime.md b/docs/doctoring/fuzz-setup-node-runtime.md new file mode 100644 index 00000000..60b99ffc --- /dev/null +++ b/docs/doctoring/fuzz-setup-node-runtime.md @@ -0,0 +1,47 @@ +# Fuzz workflow Node.js action runtime + +## Status + +Implemented on active PR #523 only until the change reaches protected `develop`. + +## Problem + +The repository-owned `Fuzz` workflow pinned `actions/setup-node` v4.1.0 at commit `39370e3970a6d050c480ffad4ff0ed4d3fdee5af`. GitHub Actions is retiring the Node.js 20 action runtime, so retaining that predecessor action creates avoidable runner compatibility risk. + +This is separate from the Node.js version used to execute ScopeWeave. The workflow continues to request Node.js `22.13.0` for the project-under-test; only the JavaScript runtime bundled by `actions/setup-node` changes. + +## Decision + +Pin the official `actions/setup-node` v7.0.0 release by immutable commit SHA `820762786026740c76f36085b0efc47a31fe5020` in `.github/workflows/fuzz.yml`. + +The official v7.0.0 action metadata declares the Node.js 24 action runtime. The immutable pin preserves supply-chain provenance and avoids relying on a mutable major-version tag. + +## Test-first evidence + +On PR #523, test-only commit `465a26b289b8b3a9f50ea09c45c5eee0a266e8bc` extended `tests/unit/fuzz-exact-head-contract.test.mjs` to require the immutable v7.0.0 setup-node pin and reject the deprecated v4.1.0 pin while the production workflow still used v4.1.0. That commit therefore established the RED contract before production changed. + +Production commit `4c284d6bcb76a70cceb74b6366fb636b5a41c781` changed only the fuzz setup-node action pin from v4.1.0 to v7.0.0. It retained Node.js `22.13.0`, npm caching, least-privilege contents access, exact-contributor-head checkout and runtime attestation, bounded iteration budgets, and the same fuzz command. + +This evidence subsumes the equivalent setup-node and exact-head fuzz work from overlapping PR #547 while keeping #523 as the older canonical CI-integrity owner. + +## Verification contract + +The repaired exact PR head must prove all of the following before integration: + +- `unit-and-api` passes the executable fuzz workflow contract; +- `property fuzz` executes the exact pull-request contributor head with the immutable setup-node v7.0.0 pin; +- the workflow still installs Node.js `22.13.0` for ScopeWeave; +- repository and organization-required security/review gates are evaluated on the same exact head; and +- runner/provider warnings that do not come from repository source remain classified as infrastructure evidence rather than source defects. + +## Rollback + +Reverting to the v4.1.0 pin would deliberately restore the deprecated action runtime and must not be used merely to silence an unrelated CI failure. If v7.0.0 exposes a verified compatibility defect, select a supported immutable setup-node revision that declares a current runner-supported JavaScript runtime and update this contract and evidence together. + +## References + +GitHub. (2025, September 19). *Deprecation of Node 20 on GitHub Actions runners*. GitHub Changelog. https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/ + +GitHub. (2026, July 14). *v7.0.0* [Software release]. GitHub, `actions/setup-node`. https://github.com/actions/setup-node/releases/tag/v7.0.0 + +GitHub. (2026). *actions/setup-node action metadata, v7.0.0 (`820762786026740c76f36085b0efc47a31fe5020`)* [Source code]. GitHub. https://github.com/actions/setup-node/blob/820762786026740c76f36085b0efc47a31fe5020/action.yml diff --git a/docs/doctoring/server-tests-exact-head.md b/docs/doctoring/server-tests-exact-head.md new file mode 100644 index 00000000..d8cdd960 --- /dev/null +++ b/docs/doctoring/server-tests-exact-head.md @@ -0,0 +1,129 @@ +# Exact-head and live-base CI execution evidence + +## Status and authority + +**Status: active PR #523 evidence, not protected-`develop` shipped truth.** + +This record belongs to issue #522 / PR #523. Protected `develop` remains shipped truth until one unchanged integrated head satisfies the live ruleset, deterministic checks, security and dependency gates, resolved-review requirements, and any qualifying independent approval required after the latest push. + +## Buyer/control objective + +A green CI badge is not defensible evidence when the job executed a different revision from the contributor head under review, when a base-sensitive comparison silently used an old pull-request base snapshot, or when a stacked pull request never received a required analysis lane. ScopeWeave therefore keeps four identities separate: + +1. the exact immutable contributor head under review; +2. the pull-request base snapshot recorded in event/PR metadata; +3. the live protected base ref tip independently resolved when base-sensitive evidence executes; and +4. the actual checkout SHA attested by each deterministic/security job. + +GitHub `pull_request` workflows normally expose a synthetic `refs/pull//merge` ref and corresponding merge `GITHUB_SHA`. Synthetic-merge success remains useful integration evidence, but it does not substitute for exact contributor-head execution. Likewise, `github.event.pull_request.base.sha` is historical event metadata rather than ScopeWeave's live protected-base authority. + +## Server Tests exact-head repair + +Before this PR, both jobs in `.github/workflows/server-tests.yml` used `actions/checkout` without an explicit `ref`, so pull-request execution followed GitHub's synthetic merge revision. + +The realistic RED regression was committed at `7c6810211a211bb0fd09c36476b5ea47c1c0af46`. Hosted Server Tests run `31924337433`, job `95109405299`, fetched synthetic merge commit `120def420dec9abe154353fa699e6a69e0388268`; the new contract failed because neither job selected the contributor head. + +Commit `0f247d2e05fd8c9c2f69e617efd369ee7aea005d` changed both jobs to select `${{ github.event.pull_request.head.sha || github.sha }}` with `persist-credentials: false`. Each job binds `EXPECTED_CHECKOUT_SHA` to the same expression, runs `git rev-parse HEAD`, and fails closed if the actual revision differs. The fallback preserves exact protected-branch push execution. + +The control keeps the unprivileged `pull_request` event, `contents: read`, immutable action pins, disabled credential persistence, and existing unit/API/browser-E2E workloads. It adds no secret-bearing contributor execution, merge-ref synthesis, temporary writer workflow, or bypass. + +## Exact owned coverage and provenance + +The current Server Tests lane treats coverage as evidence rather than a best-effort report: + +- server coverage uses c8 with `--all --check-coverage --per-file` and exact 100% statements, branches, functions, and lines over registered owned production modules; +- browser coverage exercises served production `/analytics.js`, `/app.js`, and `/cloud-sync.js`, records SHA-256 for served bytes, independently hashes checked-out source, rejects a source/served provenance mismatch, and requires exact 100% statements, branches, functions, and lines; +- every Playwright page in the test context is instrumented before navigation when created through `context.newPage()`, and coverage is collected before an explicitly closed page becomes unavailable; +- failure diagnostics and uploaded evidence are scoped to a failed coverage step so unrelated test/setup failures do not cascade into misleading coverage errors; and +- structural regression contracts keep the production modules, test cases, exact-head assertions, and coverage thresholds from silently disappearing. + +Coverage success on a predecessor head, synthetic merge, skipped lane, or different served source is non-authorizing. + +## CodeQL required-context, default-setup authority, and stacked-PR repair + +Protected `develop` requires `Analyze (javascript-typescript)` and `Analyze (python)`. Current repository evidence uses **one** checked-in CodeQL workflow for those protected contexts: + +- `.github/workflows/codeql-required.yml` performs real exact-head analysis with `upload: never` and `upload-database: false`; it supplies deterministic required contexts without publishing SARIF or a CodeQL database; +- its job permission is `contents: read` only, because this non-publishing lane has no need for `security-events: write`; +- GitHub CodeQL default setup remains the sole SARIF publication authority; and +- the former repository advanced publisher `.github/workflows/codeql.yml` has been retired instead of leaving a disabled/conflicting advanced setup in source. + +The first replacement attempt at `612dcb6ed0ff17b03e30baacb301dc006bac7d6f` reached CodeQL analysis but GitHub rejected advanced-configuration SARIF publication while default setup was authoritative. GitHub's current troubleshooting guidance states that enabling default setup disables existing advanced CodeQL workflow files and blocks CodeQL analysis API uploads from them; when the workflow is no longer needed, the file should be deleted. Retaining the stale publisher therefore supplied neither trustworthy evidence nor a useful fallback. + +Fresh review later exposed two additional control defects. First, two repository workflows had been configured to emit the same protected `Analyze (...)` names, making required-context provenance ambiguous. Second, both workflows had historically limited `pull_request` to base branch `develop`, so stacked child PRs could receive no repository analysis. + +The final repair sequence is test-first and single-authority: + +1. `4cba72546115a4877705f8e079d6ca635b948161` established a failing contract that default-setup publication authority must not coexist with a checked-in advanced publisher; +2. `c14ac41168af120e584c0d5578e8260b5c40cf79` deleted the stale `.github/workflows/codeql.yml` publisher; +3. `bcad2b61a4220f11ab28d8527c54b2c3ae893b19` narrowed the stacked-PR contract to the remaining required workflow; and +4. `9c5d7e163cf3f114d2811416421b4825a2d1bddc` added a least-privilege regression before `19140bba86f7dc088eeb133465fbc91343edc7fc` removed the unused `security-events: write` permission. + +The surviving required workflow retains immutable CodeQL Action pins, explicit exact-head checkout/runtime attestation, disabled checkout credential persistence, no base-branch filter on `pull_request`, and the unprivileged event boundary. No `pull_request_target`, secret-bearing contributor execution, analysis weakening, SARIF publication duplication, or required-context bypass was introduced. + +## OSV exact-head and live-base differential scanning + +The former repository OSV lane delegated to Google's reusable PR workflow, whose candidate selection follows `$GITHUB_SHA` and therefore the synthetic merge revision on ordinary pull requests. PR #523 instead owns revision selection locally while preserving immutable scanner/reporter pins. + +PR #487 established that `google/osv-scanner-action@v2.5.0` points to `8deb546fdb875b9996d27d4950be7312dac076a1`; the release's direct scanner and reporter steps use `06b2ab4348248b456ee06c9e953637f55e03504f`. PR #523 uses that direct revision while controlling checkout identity itself. + +A second defect was that the baseline originally used `github.event.pull_request.base.sha` and called it the live base. Test-only commit `d8d6d0bd0e3c343b52986856b0df18181639ceb7` required the named protected base ref and rejected the snapshot SHA. Production commit `e527c7fadbdea523905bf985121d0fa9d8809f2b` changed the baseline checkout to `${{ github.event.pull_request.base.ref }}` and records the resolved SHA with `git rev-parse HEAD`. + +The current OSV sequence is: + +1. checkout the current protected base **ref** with credentials disabled; +2. record the resolved base SHA and ref identity; +3. scan the resolved baseline into `old-results.json`; +4. checkout the exact immutable contributor head with credentials disabled and `clean: false` so baseline evidence survives; +5. attest `git rev-parse HEAD == EXPECTED_HEAD_SHA`; +6. scan the exact contributor head into `new-results.json`; +7. compare introduced findings with the pinned reporter; and +8. preserve generated candidate-head SARIF for non-cancelled finding failures according to the current OSV evidence contract. + +A merge or release decision must still resolve protected `develop` again after all checks because the branch can advance after any workflow starts. + +## Executable regression contract + +`tests/unit/workflow-exact-head-contract.test.mjs`, `tests/unit/codeql-stacked-pr-trigger-contract.test.mjs`, `tests/unit/codeql-workflow-supply-chain.test.mjs`, the coverage contracts, and the associated package registrations collectively require: + +- exact contributor-head checkout and runtime SHA attestation for both Server Tests jobs; +- exact contributor-head checkout/runtime attestation for the repository CodeQL required lane and property fuzz; +- disabled checkout credential persistence and no privileged `pull_request_target` path; +- both protected `Analyze (...)` identities/languages from one repository workflow; +- CodeQL required-context analysis with `upload: never`, `upload-database: false`, and no unused code-scanning write permission while GitHub default setup owns publication; +- absence of the stale advanced CodeQL publisher workflow; +- CodeQL execution for stacked PRs as well as `develop`-bound PRs; +- OSV baseline selection by named base ref, with explicit rejection of `github.event.pull_request.base.sha` as live authority; +- OSV exact-head checkout with `clean: false` and runtime SHA verification; +- immutable scanner/reporter/action revisions; and +- exact owned production coverage/provenance requirements, including secondary Playwright pages. + +Structural contracts complement rather than replace hosted runtime evidence. Every changed head must prove its own execution. + +## Evidence semantics and external owner boundaries + +Exact contributor-head checkout reduces evidence ambiguity; it is not code signing, artifact provenance, or a substitute for SAST, dependency review, supply-chain controls, or independent review. + +Neutral, skipped, cancelled, absent, stale, predecessor-head, rate-limited, model-only, status-only, synthetic-only, or configuration-mismatch records are non-passing. + +Organization-owned controls remain separate authorities. In particular, the current `.github` owner lanes for Strix incomplete/provider-failure handling and required OpenCode/Noema formal verdict integrity must integrate through their dedicated writer before ScopeWeave can regenerate and rely on that evidence. ScopeWeave must not reproduce those central controls locally. + +## Rollback and recovery + +Before protected integration, rollback is source-only: remove the PR-owned workflow/test/coverage/doctoring changes together. After protected integration, do not silently restore default pull-request checkout and then label synthetic merge success as contributor-head evidence. Do not restore `github.event.pull_request.base.sha` as live base authority, and do not reintroduce a CodeQL base filter that skips stacked PR heads. + +If CodeQL publication ownership later moves from GitHub default setup back to repository advanced configuration, treat that as an explicit control-plane migration: disable default setup through the authorized GitHub security configuration path, add one reviewed advanced publisher with unique check identity, restore only the permissions needed for publication, and update protected-context, exact-head, and publication-authority regressions together. Do not re-add a competing publisher as a speculative fallback. + +## References + +GitHub. (n.d.). *Actions checkout*. GitHub. https://github.com/actions/checkout + +GitHub. (n.d.). *CodeQL Action analyze action definition*. GitHub. https://github.com/github/codeql-action/blob/main/analyze/action.yml + +GitHub. (n.d.). *Configuring default setup for code scanning*. GitHub Docs. https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/configure-code-scanning/configure-code-scanning + +GitHub. (n.d.). *Events that trigger workflows*. GitHub Docs. https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows + +GitHub. (n.d.). *Two CodeQL workflows*. GitHub Docs. https://docs.github.com/en/code-security/reference/code-scanning/troubleshoot-analysis-errors/two-codeql-workflows + +Google. (2026). *OSV-Scanner Action v2.5.0* [Source code]. GitHub. https://github.com/google/osv-scanner-action/releases/tag/v2.5.0 diff --git a/index.html b/index.html index d24b2a88..8c1a832f 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,8 @@ ScopeWeave Planner + + @@ -116,4 +118,4 @@

간트 차트

- + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 00a99254..ff493704 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "devDependencies": { "@playwright/test": "1.62.1", "c8": "12.0.0", - "fast-check": "4.9.0" + "fast-check": "4.9.0", + "istanbul-lib-coverage": "3.2.2", + "v8-to-istanbul": "9.3.0" }, "engines": { "node": "^22.13.0 || >=23.4.0" diff --git a/package.json b/package.json index 8cefdc74..25b99fc6 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,15 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/attachment-metadata.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/app-edge-coverage.mjs && node tests/api/app-provider-edge-coverage.mjs && node tests/api/app-branch-coverage.mjs && node tests/api/app-residual-branch-coverage.mjs && node tests/api/app-final-branch-coverage.mjs && node tests/api/app-provider-fallback-coverage.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/server-entrypoint.test.mjs && node tests/unit/browser-coverage-failure.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/static-stylesheet-serving.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/playwright-install-timeout-contract.test.mjs && node tests/unit/codeql-workflow-supply-chain.test.mjs && node tests/unit/workflow-exact-head-contract.test.mjs && node tests/unit/dependency-review-exact-head-contract.test.mjs && node tests/unit/coverage-diagnostics-workflow-contract.test.mjs && node tests/unit/codeql-stacked-pr-trigger-contract.test.mjs && node tests/unit/fuzz-exact-head-contract.test.mjs && node tests/unit/osv-configuration-identity.test.mjs && node tests/unit/osv-fail-closed-contract.test.mjs && node tests/unit/package-lock-integrity.test.mjs", + "test:coverage": "npm run test:coverage:server && npm run test:coverage:browser", + "test:coverage:server": "c8 --all --check-coverage --per-file --lines 100 --functions 100 --branches 100 --statements 100 --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/attachment_status.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/clearfolio.mjs --include=server/db.mjs --include=server/orchestrator.mjs --include=server/server.mjs --reporter=text --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:browser": "node scripts/ci/browser_coverage.mjs", + "test:coverage:cases": "npm run test:unit && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", - "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", + "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", "test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js", "fuzz": "node --test tests/fuzz/*.mjs" }, @@ -29,6 +31,8 @@ "devDependencies": { "@playwright/test": "1.62.1", "c8": "12.0.0", - "fast-check": "4.9.0" + "fast-check": "4.9.0", + "istanbul-lib-coverage": "3.2.2", + "v8-to-istanbul": "9.3.0" } } diff --git a/scripts/ci/browser_coverage.mjs b/scripts/ci/browser_coverage.mjs new file mode 100644 index 00000000..6f590f19 --- /dev/null +++ b/scripts/ci/browser_coverage.mjs @@ -0,0 +1,155 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, readdir, rm, mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import coverageLibrary from 'istanbul-lib-coverage'; +import v8ToIstanbul from 'v8-to-istanbul'; +import { reportCoverageProcessingFailure } from './browser_coverage_failure.mjs'; + +const { createCoverageMap } = coverageLibrary; +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const rawRoot = path.join(repositoryRoot, '.coverage-browser'); +const rawDirectory = path.join(rawRoot, 'raw'); +const reportDirectory = path.join(repositoryRoot, 'coverage'); +const expectedBrowserSources = ['analytics.js', 'app.js', 'cloud-sync.js']; + +const normalizeBrowserPath = (url) => { + try { + return decodeURIComponent(new URL(url).pathname).replace(/^\/+/, ''); + } catch { + return null; + } +}; + +const uniqueSorted = (values) => [...new Set(values)].sort((left, right) => left - right); + +const uncoveredLocations = (fileCoverage) => { + const data = fileCoverage.data; + const statements = Object.entries(data.s) + .filter(([, hits]) => hits === 0) + .map(([id]) => data.statementMap[id]?.start?.line) + .filter(Number.isInteger); + const functions = Object.entries(data.f) + .filter(([, hits]) => hits === 0) + .map(([id]) => data.fnMap[id]?.loc?.start?.line) + .filter(Number.isInteger); + const branches = Object.entries(data.b).flatMap(([id, hits]) => + hits.flatMap((count, index) => { + if (count !== 0) return []; + const line = data.branchMap[id]?.locations?.[index]?.start?.line; + return Number.isInteger(line) ? [line] : []; + }), + ); + return { + lines: fileCoverage.getUncoveredLines(), + statements: uniqueSorted(statements), + functions: uniqueSorted(functions), + branches: uniqueSorted(branches), + }; +}; + +const metricSummary = (fileCoverage) => { + const summary = fileCoverage.toSummary().data; + return Object.fromEntries( + ['statements', 'branches', 'functions', 'lines'].map((metric) => [metric, summary[metric]]), + ); +}; + +await rm(rawRoot, { recursive: true, force: true }); +await mkdir(rawDirectory, { recursive: true }); +await mkdir(reportDirectory, { recursive: true }); + +const playwrightCli = path.join(repositoryRoot, 'node_modules', '@playwright', 'test', 'cli.js'); +const testRun = spawnSync(process.execPath, [playwrightCli, 'test'], { + cwd: repositoryRoot, + env: { + ...process.env, + SCOPEWEAVE_BROWSER_COVERAGE: '1', + SCOPEWEAVE_BROWSER_COVERAGE_DIR: rawDirectory, + }, + stdio: 'inherit', +}); +if (testRun.error) throw testRun.error; +if (testRun.status !== 0) { + process.exitCode = testRun.status ?? 1; +} + +try { + const rawFiles = (await readdir(rawDirectory)).filter((name) => name.endsWith('.json')).sort(); + if (rawFiles.length === 0) { + if (testRun.status !== 0) { + console.error('Browser tests failed before any raw browser coverage evidence was emitted.'); + } else { + throw new Error('Browser coverage produced no raw evidence files.'); + } + } else { + const coverageMap = createCoverageMap({}); + const observedSources = new Set(); + for (const rawFile of rawFiles) { + const payload = JSON.parse(await readFile(path.join(rawDirectory, rawFile), 'utf8')); + if (!Array.isArray(payload.entries)) { + throw new Error(`Malformed browser coverage evidence: ${rawFile}`); + } + for (const entry of payload.entries) { + const browserPath = normalizeBrowserPath(entry.url); + if (!expectedBrowserSources.includes(browserPath)) continue; + observedSources.add(browserPath); + const localPath = path.join(repositoryRoot, browserPath); + const localBytes = await readFile(localPath); + const localSource = localBytes.toString('utf8'); + const localSourceSha256 = createHash('sha256').update(localBytes).digest('hex'); + const servedSourceSha256 = payload.servedSourceSha256?.[`/${browserPath}`]; + if (typeof servedSourceSha256 !== 'string') { + throw new Error(`Browser coverage lacks served-source identity for ${browserPath}.`); + } + if (servedSourceSha256 !== localSourceSha256) { + throw new Error(`Browser served source does not match checked-out ${browserPath}.`); + } + if (!Array.isArray(entry.functions)) { + throw new Error(`Browser coverage lacks V8 function ranges for ${browserPath}.`); + } + const converter = v8ToIstanbul(localPath, 0, { source: entry.source ?? localSource }); + await converter.load(); + converter.applyCoverage(entry.functions); + coverageMap.merge(converter.toIstanbul()); + } + } + + for (const expectedSource of expectedBrowserSources) { + if (!observedSources.has(expectedSource)) { + throw new Error(`Browser coverage never observed required production source ${expectedSource}.`); + } + } + + const report = {}; + let incomplete = false; + for (const expectedSource of expectedBrowserSources) { + const localPath = path.join(repositoryRoot, expectedSource); + const fileCoverage = coverageMap.fileCoverageFor(localPath); + const metrics = metricSummary(fileCoverage); + const uncovered = uncoveredLocations(fileCoverage); + report[expectedSource] = { metrics, uncovered }; + for (const metric of ['statements', 'branches', 'functions', 'lines']) { + if (metrics[metric].pct !== 100) incomplete = true; + } + } + + await writeFile( + path.join(reportDirectory, 'browser-coverage-final.json'), + `${JSON.stringify(coverageMap.toJSON(), null, 2)}\n`, + 'utf8', + ); + await writeFile( + path.join(reportDirectory, 'browser-coverage-summary.json'), + `${JSON.stringify(report, null, 2)}\n`, + 'utf8', + ); + console.log('Browser production coverage:', JSON.stringify(report, null, 2)); + if (incomplete) { + throw new Error('Browser production coverage is below 100% statement/branch/function/line coverage.'); + } + } +} catch (coverageError) { + process.exitCode = reportCoverageProcessingFailure(testRun.status, coverageError); +} diff --git a/scripts/ci/browser_coverage_failure.mjs b/scripts/ci/browser_coverage_failure.mjs new file mode 100644 index 00000000..a87f2185 --- /dev/null +++ b/scripts/ci/browser_coverage_failure.mjs @@ -0,0 +1,23 @@ +/** + * Report a browser-coverage processing failure without replacing a failed test run. + * + * When Playwright already failed, its exit status remains the authoritative CI + * result and the later coverage-processing error is emitted as secondary + * diagnostic evidence. When Playwright passed, the coverage-processing error is + * rethrown so malformed, incomplete, or unverifiable coverage still fails closed. + * + * @param {number|null} testStatus Exit status reported by the Playwright child process. + * @param {unknown} coverageError Error raised while processing browser coverage evidence. + * @param {(...parts: unknown[]) => void} [log=console.error] Error logger used for diagnostics. + * @returns {number} The non-zero Playwright exit status that should remain authoritative. + * @throws {unknown} The coverage error when Playwright itself completed successfully. + */ +export function reportCoverageProcessingFailure(testStatus, coverageError, log = console.error) { + if (testStatus === 0) throw coverageError; + + const preservedStatus = Number.isInteger(testStatus) && testStatus !== 0 ? testStatus : 1; + log(`Browser tests failed with exit status ${preservedStatus}.`); + const detail = coverageError instanceof Error ? coverageError.message : String(coverageError); + log(`Browser coverage processing also failed: ${detail}`); + return preservedStatus; +} diff --git a/scripts/ci/coverage_diagnostics.mjs b/scripts/ci/coverage_diagnostics.mjs new file mode 100644 index 00000000..0bb75a08 --- /dev/null +++ b/scripts/ci/coverage_diagnostics.mjs @@ -0,0 +1,69 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const coveragePath = resolve(process.argv[2] || 'coverage/coverage-final.json'); + +const locationText = (location) => { + const start = location?.start ?? {}; + const end = location?.end ?? start; + const startLine = Number.isInteger(start.line) ? start.line : '?'; + const startColumn = Number.isInteger(start.column) ? start.column + 1 : '?'; + const endLine = Number.isInteger(end.line) ? end.line : startLine; + const endColumn = Number.isInteger(end.column) ? end.column + 1 : startColumn; + return `${startLine}:${startColumn}-${endLine}:${endColumn}`; +}; + +let coverage; +try { + coverage = JSON.parse(await readFile(coveragePath, 'utf8')); +} catch (error) { + console.error(`coverage diagnostics unavailable: ${coveragePath}: ${error.message}`); + process.exitCode = 1; + process.exit(); +} + +const misses = []; +for (const [filePath, fileCoverage] of Object.entries(coverage)) { + for (const [statementId, count] of Object.entries(fileCoverage.s ?? {})) { + if (count !== 0) continue; + misses.push({ + kind: 'statement', + filePath, + id: statementId, + location: fileCoverage.statementMap?.[statementId], + }); + } + + for (const [functionId, count] of Object.entries(fileCoverage.f ?? {})) { + if (count !== 0) continue; + const definition = fileCoverage.fnMap?.[functionId]; + misses.push({ + kind: `function:${definition?.name || '(anonymous)'}`, + filePath, + id: functionId, + location: definition?.decl ?? definition?.loc, + }); + } + + for (const [branchId, counts] of Object.entries(fileCoverage.b ?? {})) { + const definition = fileCoverage.branchMap?.[branchId]; + counts.forEach((count, armIndex) => { + if (count !== 0) return; + misses.push({ + kind: `branch:${definition?.type || 'unknown'}[${armIndex}]`, + filePath, + id: branchId, + location: definition?.locations?.[armIndex] ?? definition?.loc, + }); + }); + } +} + +if (misses.length === 0) { + console.log('coverage diagnostics: no uncovered Istanbul statements, functions, or branch arms'); +} else { + console.error(`coverage diagnostics: ${misses.length} uncovered Istanbul entries`); + for (const miss of misses) { + console.error(`COVERAGE_MISS ${miss.kind} ${miss.filePath}:${locationText(miss.location)} id=${miss.id}`); + } +} diff --git a/scripts/ci/select_fuzz_budget.sh b/scripts/ci/select_fuzz_budget.sh new file mode 100755 index 00000000..e61f95b6 --- /dev/null +++ b/scripts/ci/select_fuzz_budget.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +event_name="${1:-}" +requested_runs="${2:-}" +default_runs=20000 +max_runs=200000 + +if [[ "$event_name" == "schedule" ]]; then + runs="$max_runs" +elif [[ "$requested_runs" =~ ^[1-9][0-9]{0,5}$ ]] && (( 10#$requested_runs <= max_runs )); then + runs="$((10#$requested_runs))" +else + runs="$default_runs" +fi + +printf '%s\n' "$runs" diff --git a/server/app.mjs b/server/app.mjs index c432a84f..ddbc6d7f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -14,8 +14,13 @@ import { computeEvm } from '../analytics.js'; // pure math, shared with the clie const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); -// Append-only audit trail. Never throws into the request path. -function logAudit(orgId, userId, action, targetType, targetId, meta) { +/** + * Append an audit event while preserving nullable actor/target metadata. + * + * Audit recording is deliberately best-effort: a storage failure must not fail + * the customer request that produced the event. + */ +export function logAudit(orgId, userId, action, targetType, targetId, meta) { try { db.prepare('INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)') .run(orgId, userId ?? null, action, targetType ?? null, targetId != null ? String(targetId) : null, meta ? JSON.stringify(meta) : null); @@ -130,7 +135,7 @@ function deliver(orgId, event, payload) { sendWebhook(h.id, h.url, sig, event, body, 1); } } -const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // silence during tests +const quietLogs = String(process.env.SCOPEWEAVE_DB).includes(':memory:'); // silence during tests app.use('*', async (c, next) => { const t = Date.now(); await next(); @@ -1044,18 +1049,18 @@ app.post('/api/projects/:id/attachments', requireAuth, async (c) => { const file = form?.get('file'); if (!file || typeof file === 'string') return c.json({ error: 'multipart file required' }, 400); const taskId = String(form.get('taskId') || ''); - if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); + if (/\.(hwp|hwpx)$/i.test(file.name)) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); const bytes = Buffer.from(await file.arrayBuffer()); let job; try { - job = await submitJob(p.org_id, uid, { name: file.name || 'document', mime: file.type || '', bytes }); + job = await submitJob(p.org_id, uid, { name: file.name, mime: file.type, bytes }); } catch (e) { return c.json({ error: `문서 변환 제출 실패: ${e.message}` }, 502); } const aid = rowid(db.prepare( 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' - ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); + ).run(p.id, taskId, file.name, file.type, file.size, job.jobId, job.status, uid)); logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); return c.json({ id: aid, status: job.status }); }); diff --git a/server/server.mjs b/server/server.mjs index c84c2e25..d6b9e3cd 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -1,7 +1,31 @@ import { serve } from '@hono/node-server'; import { app } from './app.mjs'; -const port = Number(process.env.PORT) || 8787; -serve({ fetch: app.fetch, port }, (info) => { +/** + * Resolve the HTTP listener port from configuration. + * + * Port `0` is intentionally accepted so tests and operators can request an + * ephemeral OS-assigned port. Missing, blank, whitespace-only, fractional, + * negative, and out-of-range values fail closed to ScopeWeave's historical + * default. + * + * @param {unknown} value - Raw `PORT` configuration value. + * @returns {number} A valid TCP port in the inclusive range 0..65535. + */ +export function resolvePort(value) { + const parsed = Number(value); + if ( + (typeof value === 'string' && value.trim() === '') + || !Number.isInteger(parsed) + || parsed < 0 + || parsed > 65535 + ) { + return 8787; + } + return parsed; +} + +const port = resolvePort(process.env.PORT); +export const server = serve({ fetch: app.fetch, port }, (info) => { console.log(`ScopeWeave API listening on http://localhost:${info.port}`); }); diff --git a/styles.css b/styles.css index 9d715f00..506ed5d3 100644 --- a/styles.css +++ b/styles.css @@ -653,6 +653,11 @@ select[data-inline-progress]:focus { box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.25); } +.modal-panel:not(.gantt-panel) { + overflow-y: auto; + overscroll-behavior: contain; +} + .modal-header { display: flex; align-items: center; diff --git a/tests/api/app-branch-coverage.mjs b/tests/api/app-branch-coverage.mjs new file mode 100644 index 00000000..fc535395 --- /dev/null +++ b/tests/api/app-branch-coverage.mjs @@ -0,0 +1,402 @@ +// Branch-oriented API coverage for production control-flow alternatives that +// are easy to miss in happy-path smoke tests. The cases use public HTTP +// boundaries wherever behavior is observable and bounded SQLite fault +// injection only for explicit "must not break the operation"/rollback paths. +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1000'; +process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; +delete process.env.ORCHESTRATOR_URL; +delete process.env.CLEARFOLIO_URL; +delete process.env.OIDC_ISSUER; + +const [{ app }, { db }, { signToken }] = await Promise.all([ + import('../../server/app.mjs'), + import('../../server/db.mjs'), + import('../../server/auth.mjs'), +]); + +const jsonBody = (value) => JSON.stringify(value); +const authHeaders = (token) => ({ authorization: `Bearer ${token}` }); +const req = (path, options = {}) => { + const headers = new Headers(options.headers || {}); + if (!(options.body instanceof FormData) && !headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + return app.request(path, { ...options, headers }); +}; +const malformedJson = (path, method, headers = {}) => + req(path, { method, headers, body: '{' }); +const status = async (expected, promise, label) => { + const response = await promise; + assert.equal(response.status, expected, label); + return response; +}; +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const waitFor = async (predicate, label, timeoutMs = 2000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await delay(5); + } + throw new Error(`timeout waiting for ${label}`); +}; + +// JSON parse failures are user-input branches, not exceptional test setup. +await status(400, malformedJson('/api/auth/signup', 'POST'), 'malformed signup'); +await status(401, malformedJson('/api/auth/login', 'POST'), 'malformed login'); + +let response = await req('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email: 'branch-owner@example.com', password: 'password123', name: 'Branch Owner' }), +}); +assert.equal(response.status, 200); +const ownerToken = (await response.json()).token; +const ownerAuth = authHeaders(ownerToken); +response = await req('/api/me', { headers: ownerAuth }); +const ownerMe = await response.json(); +const ownerId = ownerMe.user.id; +const orgId = ownerMe.orgs[0].id; +db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + +// Version-zero accounts still exercise the explicit `payload.tv || 0` branch, +// while the hardened signer/verifier contract requires token-version metadata. +const versionZeroToken = signToken({ sub: ownerId, email: ownerMe.user.email, tv: 0 }); +await status(200, req('/api/me', { headers: authHeaders(versionZeroToken) }), 'version-zero JWT'); + +response = await req('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email: 'branch-member@example.com', password: 'password123' }), +}); +const memberToken = (await response.json()).token; +const memberAuth = authHeaders(memberToken); +const memberId = (await (await req('/api/me', { headers: memberAuth })).json()).user.id; +db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(orgId, memberId, 'viewer'); + +await status(400, malformedJson('/api/orgs', 'POST', ownerAuth), 'malformed org create'); +await status(400, malformedJson('/api/projects', 'POST', ownerAuth), 'malformed project create'); +response = await req('/api/projects', { + method: 'POST', + headers: ownerAuth, + body: jsonBody({ name: 'Branch Project', orgId }), +}); +assert.equal(response.status, 200); +const projectId = (await response.json()).id; + +// Malformed/partial updates exercise persisted-value and methodology fallbacks. +await status(200, malformedJson(`/api/projects/${projectId}`, 'PUT', ownerAuth), 'malformed update falls back'); +let project = await (await req(`/api/projects/${projectId}`, { headers: ownerAuth })).json(); +response = await req(`/api/projects/${projectId}`, { + method: 'PUT', + headers: ownerAuth, + body: jsonBody({ + version: project.version, + name: 'Renamed Branch Project', + baseDate: '2026-08-18', + methodology: 'agile', + tasks: [ + { id: 'late-name', name: 'Late named', plannedEndDate: '2020-01-01', actualProgress: 40, plannedProgress: 100, weight: 2, owner: 'A' }, + { id: 'late-task', task: 'Late task fallback', plannedEndDate: '2020-01-02', actualProgress: 0 }, + { id: 'future-activity', activity: 'Activity fallback', plannedStartDate: '2999-01-01', plannedEndDate: '2999-01-02' }, + { id: 'future-phase', phase: 'Phase fallback', plannedStartDate: '2999-02-01', plannedEndDate: '2999-02-02' }, + { id: 'future-id', plannedStartDate: '2999-03-01', plannedEndDate: '2999-03-02' }, + ], + }), +}); +assert.equal(response.status, 200); + +// Revision-history persistence is deliberately best-effort. A storage fault in +// that side channel must not turn an otherwise valid save into a failed save. +db.exec("CREATE TEMP TRIGGER fail_revision_insert BEFORE INSERT ON project_revisions BEGIN SELECT RAISE(ABORT, 'forced revision failure'); END"); +project = await (await req(`/api/projects/${projectId}`, { headers: ownerAuth })).json(); +await status(200, req(`/api/projects/${projectId}`, { + method: 'PUT', headers: ownerAuth, body: jsonBody({ version: project.version, name: 'History fault tolerated' }), +}), 'revision insert failure is contained'); +db.exec('DROP TRIGGER fail_revision_insert'); + +// Comment deletion covers author, manager-of-another-author, and forbidden +// non-manager alternatives. +db.prepare("UPDATE memberships SET role = 'member' WHERE org_id = ? AND user_id = ?").run(orgId, memberId); +response = await req(`/api/projects/${projectId}/comments`, { + method: 'POST', headers: memberAuth, body: jsonBody({ taskId: 'task-1', body: 'member comment' }), +}); +const memberCommentId = (await response.json()).id; +await status(200, req(`/api/projects/${projectId}/comments/${memberCommentId}`, { method: 'DELETE', headers: ownerAuth }), 'manager deletes another comment'); +response = await req(`/api/projects/${projectId}/comments`, { + method: 'POST', headers: ownerAuth, body: jsonBody({ body: 'owner comment' }), +}); +const ownerCommentId = (await response.json()).id; +await status(403, req(`/api/projects/${projectId}/comments/${ownerCommentId}`, { method: 'DELETE', headers: memberAuth }), 'member cannot delete another comment'); +await status(400, malformedJson(`/api/projects/${projectId}/comments`, 'POST', ownerAuth), 'malformed comment'); + +// Viewer-specific write guards differ from cross-tenant 404 behavior. +db.prepare("UPDATE memberships SET role = 'viewer' WHERE org_id = ? AND user_id = ?").run(orgId, memberId); +await status(403, req(`/api/projects/${projectId}`, { method: 'PUT', headers: memberAuth, body: jsonBody({}) }), 'viewer project write'); +await status(403, req(`/api/projects/${projectId}/revisions/1/restore`, { method: 'POST', headers: memberAuth }), 'viewer restore'); + +// Calendar: invalid PAT, corrupted task JSON fallback, and JWT-header auth. +await status(401, req(`/api/projects/${projectId}/calendar.ics`, { headers: authHeaders('swk_invalid') }), 'invalid calendar PAT'); +const savedTasksJson = db.prepare('SELECT tasks_json FROM projects WHERE id = ?').get(projectId).tasks_json; +db.prepare('UPDATE projects SET tasks_json = ? WHERE id = ?').run('{bad-json', projectId); +await status(200, req(`/api/projects/${projectId}/calendar.ics`, { headers: ownerAuth }), 'calendar corrupted task storage'); +db.prepare('UPDATE projects SET tasks_json = ? WHERE id = ?').run(savedTasksJson, projectId); + +// SSE: bearer auth, missing project, existing subscriber-set path, dropped +// subscriber enqueue containment, and already-closed abort cleanup. +await status(404, req('/api/projects/999999/stream', { headers: ownerAuth }), 'SSE missing project'); +const abortController = new AbortController(); +const streamRequest = new Request(`http://localhost/api/projects/${projectId}/stream`, { + headers: ownerAuth, + signal: abortController.signal, +}); +const streamResponse = await app.request(streamRequest); +assert.equal(streamResponse.status, 200); +const secondStream = await req(`/api/projects/${projectId}/stream`, { headers: ownerAuth }); +assert.equal(secondStream.status, 200); +await streamResponse.body?.cancel(); +project = await (await req(`/api/projects/${projectId}`, { headers: ownerAuth })).json(); +await status(200, req(`/api/projects/${projectId}`, { + method: 'PUT', headers: ownerAuth, body: jsonBody({ version: project.version }), +}), 'broadcast tolerates dropped subscriber'); +abortController.abort(); +await delay(0); +await secondStream.body?.cancel(); + +// Membership/organization validation branches. +await status(404, req('/api/orgs/999999/members', { headers: ownerAuth }), 'unknown roster'); +await status(403, malformedJson(`/api/orgs/${orgId}/invites`, 'POST', memberAuth), 'viewer invite forbidden'); +await status(400, req(`/api/orgs/${orgId}/invites`, { method: 'POST', headers: ownerAuth, body: jsonBody({ email: 'x@example.com', role: 'owner' }) }), 'invalid invite role'); +response = await req(`/api/orgs/${orgId}/invites`, { + method: 'POST', headers: ownerAuth, body: jsonBody({ email: ownerMe.user.email }), +}); +const existingInvite = await response.json(); +response = await req(`/api/invites/${existingInvite.token}/accept`, { method: 'POST', headers: ownerAuth }); +assert.equal(response.status, 200); +assert.equal((await response.json()).role, 'owner'); +await status(400, malformedJson(`/api/orgs/${orgId}/members/${memberId}`, 'PATCH', ownerAuth), 'malformed role change'); +await status(404, req(`/api/orgs/${orgId}/members/999999`, { method: 'PATCH', headers: ownerAuth, body: jsonBody({ role: 'member' }) }), 'unknown role target'); +await status(404, req(`/api/orgs/${orgId}/members/999999`, { method: 'DELETE', headers: ownerAuth }), 'unknown member removal'); +await status(400, malformedJson(`/api/orgs/${orgId}/transfer`, 'POST', ownerAuth), 'missing transfer target'); +await status(400, req(`/api/orgs/${orgId}/transfer`, { method: 'POST', headers: ownerAuth, body: jsonBody({ userId: ownerId }) }), 'self transfer'); +await status(400, malformedJson(`/api/orgs/${orgId}`, 'PATCH', ownerAuth), 'malformed org rename'); +await status(403, req(`/api/orgs/${orgId}/checkout`, { method: 'POST', headers: memberAuth }), 'non-owner checkout'); + +// The dev-only activation route is evaluated at request time; production mode +// must hide it even when the app was imported for development tests. +process.env.SCOPEWEAVE_DEV = '0'; +await status(404, req(`/api/orgs/${orgId}/_dev/activate-pro`, { method: 'POST', headers: ownerAuth }), 'dev route disabled'); +process.env.SCOPEWEAVE_DEV = '1'; +await status(403, req(`/api/orgs/${orgId}/_dev/activate-pro`, { method: 'POST', headers: memberAuth }), 'dev route owner-only'); + +// Stripe webhook input optionality and JSON parse fallback. +await status(200, malformedJson('/api/stripe/webhook', 'POST'), 'malformed Stripe event'); +await status(200, req('/api/stripe/webhook', { method: 'POST', body: jsonBody({ type: 'checkout.session.completed', data: {} }) }), 'Stripe event without object'); +await status(200, req('/api/stripe/webhook', { method: 'POST', body: jsonBody({ type: 'checkout.session.completed', data: { object: {} } }) }), 'Stripe event without org id'); + +// PAT defaults plus owner/admin audit/export guards. +response = await malformedJson('/api/tokens', 'POST', ownerAuth); +assert.equal(response.status, 200); +const unnamedPat = await response.json(); +assert.equal(unnamedPat.name, 'token'); +await status(403, req(`/api/orgs/${orgId}/audit`, { headers: memberAuth }), 'member audit forbidden'); +await status(403, req(`/api/orgs/${orgId}/export`, { headers: memberAuth }), 'member export forbidden'); + +// Webhook event-subscription alternatives: wildcard delivers, unrelated events +// skip, string events are accepted, and delivery-record failures are contained. +const nativeFetch = globalThis.fetch; +let webhookFetches = 0; +let wildcardWebhook; +let stringWebhook; +globalThis.fetch = async (url) => { + webhookFetches += 1; + if (String(url).endsWith('/string')) await delay(20); + return new Response(null, { status: 204 }); +}; +try { + response = await req(`/api/orgs/${orgId}/webhooks`, { + method: 'POST', headers: ownerAuth, body: jsonBody({ url: 'https://hooks.example.test/wildcard' }), + }); + wildcardWebhook = await response.json(); + response = await req(`/api/orgs/${orgId}/webhooks`, { + method: 'POST', headers: ownerAuth, body: jsonBody({ url: 'https://hooks.example.test/string', events: 'project.update' }), + }); + stringWebhook = await response.json(); + await req(`/api/orgs/${orgId}/webhooks`, { + method: 'POST', headers: ownerAuth, body: jsonBody({ url: 'https://hooks.example.test/skip', events: ['member.join'] }), + }); + await status(400, malformedJson(`/api/orgs/${orgId}/webhooks`, 'POST', ownerAuth), 'malformed webhook'); + const stringDeliveriesBefore = db.prepare('SELECT COUNT(*) AS count FROM webhook_deliveries WHERE webhook_id = ?').get(stringWebhook.id).count; + db.exec(`CREATE TEMP TRIGGER fail_delivery_insert BEFORE INSERT ON webhook_deliveries + WHEN NEW.webhook_id = ${Number(wildcardWebhook.id)} + BEGIN SELECT RAISE(ABORT, 'forced delivery record failure'); END`); + project = await (await req(`/api/projects/${projectId}`, { headers: ownerAuth })).json(); + await status(200, req(`/api/projects/${projectId}`, { + method: 'PUT', headers: ownerAuth, body: jsonBody({ version: project.version }), + }), 'webhook record failure is contained'); + await waitFor( + () => db.prepare('SELECT COUNT(*) AS count FROM webhook_deliveries WHERE webhook_id = ?').get(stringWebhook.id).count > stringDeliveriesBefore, + 'sibling webhook delivery after injected record failure', + ); + db.exec('DROP TRIGGER fail_delivery_insert'); + + // Force only the delivery lookup boundary to disappear. The project update is + // still authoritative and must succeed because webhook delivery is best-effort. + const fetchesBeforeUnavailable = webhookFetches; + db.exec('ALTER TABLE webhooks RENAME TO webhooks_temporarily_unavailable'); + try { + project = await (await req(`/api/projects/${projectId}`, { headers: ownerAuth })).json(); + await status(200, req(`/api/projects/${projectId}`, { + method: 'PUT', headers: ownerAuth, body: jsonBody({ version: project.version }), + }), 'missing webhook table is contained'); + } finally { + db.exec('ALTER TABLE webhooks_temporarily_unavailable RENAME TO webhooks'); + } + assert.equal(webhookFetches, fetchesBeforeUnavailable, 'missing webhook table schedules no delivery'); +} finally { + globalThis.fetch = nativeFetch; +} +await status(404, req(`/api/orgs/${orgId}/webhooks/999999/deliveries`, { headers: ownerAuth }), 'unknown delivery history'); +await status(200, req(`/api/orgs/${orgId}/webhooks/${wildcardWebhook.id}`, { method: 'DELETE', headers: ownerAuth }), 'delete wildcard webhook'); +await status(200, req(`/api/orgs/${orgId}/webhooks/${stringWebhook.id}`, { method: 'DELETE', headers: ownerAuth }), 'delete string webhook'); + +// OIDC default-email and expiration branches in the self-contained provider. +let oidcStart = await req('/api/auth/oidc/start'); +let oidcAuthorizeUrl = new URL(oidcStart.headers.get('location')); +assert.equal(oidcAuthorizeUrl.searchParams.get('email'), 'sso-user@example.com'); +const expiringState = oidcAuthorizeUrl.searchParams.get('state'); +const nativeNow = Date.now; +Date.now = () => nativeNow() + (10 * 60 * 1000); +try { + await status(400, req(`/api/auth/oidc/callback?state=${expiringState}&code=anything`), 'expired OIDC state'); +} finally { + Date.now = nativeNow; +} + +// Search branch caps: five task hits per project and twenty projects per query. +const manyTasks = Array.from({ length: 6 }, (_, index) => ({ id: `needle-${index}`, name: `Needle task ${index}` })); +db.prepare('UPDATE projects SET tasks_json = ? WHERE id = ?').run(JSON.stringify(manyTasks), projectId); +response = await req('/api/search?q=Needle', { headers: ownerAuth }); +assert.equal(response.status, 200); +assert.equal((await response.json()).results.find((item) => item.projectId === projectId).tasks.length, 5); +await status(400, req('/api/search', { headers: ownerAuth }), 'missing search query'); +const insertProject = db.prepare('INSERT INTO projects(org_id,name,created_by) VALUES(?,?,?)'); +for (let index = 0; index < 21; index += 1) insertProject.run(orgId, `BulkSearch ${index}`, ownerId); +response = await req('/api/search?q=BulkSearch', { headers: ownerAuth }); +assert.equal((await response.json()).results.length, 20); + +// AI summary task-name and progress fallbacks were seeded above; restore them +// and execute the real public route so each branch contributes to one briefing. +db.prepare('UPDATE projects SET tasks_json = ? WHERE id = ?').run(savedTasksJson, projectId); +await status(200, req(`/api/projects/${projectId}/ai/brief`, { method: 'POST', headers: ownerAuth, body: jsonBody({}) }), 'AI fallback briefing'); + +// Attachment validation: malformed multipart, string field instead of File, +// empty MIME/taskId defaults, size ceiling, viewer write guard, readiness/notfound +// view paths, and uploader-vs-manager delete authorization. +await status(400, req(`/api/projects/${projectId}/attachments`, { method: 'POST', headers: ownerAuth, body: 'not-multipart' }), 'malformed attachment form'); +const stringFile = new FormData(); +stringFile.append('file', 'plain-text-field'); +await status(400, app.request(`/api/projects/${projectId}/attachments`, { method: 'POST', headers: ownerAuth, body: stringFile }), 'string attachment field'); +const emptyMime = new FormData(); +emptyMime.append('file', new Blob(['document']), 'document.bin'); +response = await app.request(`/api/projects/${projectId}/attachments`, { method: 'POST', headers: ownerAuth, body: emptyMime }); +assert.equal(response.status, 200); +const ownerAttachmentId = (await response.json()).id; +const oversized = new FormData(); +oversized.append('file', new Blob([new Uint8Array((10 * 1024 * 1024) + 1)]), 'oversized.pdf'); +await status(400, app.request(`/api/projects/${projectId}/attachments`, { method: 'POST', headers: ownerAuth, body: oversized }), 'attachment size ceiling'); +await status(403, app.request(`/api/projects/${projectId}/attachments`, { method: 'POST', headers: memberAuth, body: emptyMime }), 'viewer upload forbidden'); +await status(404, req(`/api/projects/${projectId}/attachments/999999/view`, { headers: ownerAuth }), 'missing attachment view'); +db.prepare('UPDATE attachments SET status = ? WHERE id = ?').run('PENDING', ownerAttachmentId); +await status(409, req(`/api/projects/${projectId}/attachments/${ownerAttachmentId}/view`, { headers: ownerAuth }), 'pending attachment view'); +db.prepare('UPDATE attachments SET status = ? WHERE id = ?').run('SUCCEEDED', ownerAttachmentId); + +// Member uploads a document; another member cannot delete it, while the owner can. +db.prepare("UPDATE memberships SET role = 'member' WHERE org_id = ? AND user_id = ?").run(orgId, memberId); +const memberUpload = new FormData(); +memberUpload.append('file', new Blob(['member document'], { type: 'application/pdf' }), 'member.pdf'); +response = await app.request(`/api/projects/${projectId}/attachments`, { method: 'POST', headers: memberAuth, body: memberUpload }); +const memberAttachmentId = (await response.json()).id; +response = await req('/api/auth/signup', { method: 'POST', body: jsonBody({ email: 'branch-peer@example.com', password: 'password123' }) }); +const peerAuth = authHeaders((await response.json()).token); +const peerId = (await (await req('/api/me', { headers: peerAuth })).json()).user.id; +db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(orgId, peerId, 'member'); +await status(403, req(`/api/projects/${projectId}/attachments/${memberAttachmentId}`, { method: 'DELETE', headers: peerAuth }), 'peer cannot delete attachment'); +await status(200, req(`/api/projects/${projectId}/attachments/${memberAttachmentId}`, { method: 'DELETE', headers: ownerAuth }), 'manager deletes member attachment'); +await status(404, req('/api/mock-clearfolio/not-a-job'), 'missing mock artifact'); + +// Share, seen, archive, duplicate, sprint, baseline, and project lifecycle guard +// branches that differ for viewers versus non-members. +db.prepare("UPDATE memberships SET role = 'viewer' WHERE org_id = ? AND user_id = ?").run(orgId, memberId); +await status(403, req(`/api/projects/${projectId}/shares`, { method: 'POST', headers: memberAuth }), 'viewer share create'); +await status(403, req(`/api/projects/${projectId}/shares`, { headers: memberAuth }), 'viewer share list'); +await status(404, req(`/api/projects/${projectId}/shares/999999`, { method: 'DELETE', headers: ownerAuth }), 'unknown share revoke'); +await status(404, req('/api/projects/999999/seen', { method: 'POST', headers: ownerAuth }), 'seen missing project'); +await status(403, malformedJson(`/api/projects/${projectId}/archive`, 'POST', memberAuth), 'viewer archive'); +await status(200, malformedJson(`/api/projects/${projectId}/archive`, 'POST', ownerAuth), 'archive default true'); +await status(403, malformedJson(`/api/projects/${projectId}/duplicate`, 'POST', memberAuth), 'viewer duplicate'); +await status(200, malformedJson(`/api/projects/${projectId}/duplicate`, 'POST', ownerAuth), 'duplicate default name'); +await status(403, malformedJson(`/api/projects/${projectId}/sprints`, 'POST', memberAuth), 'viewer sprint'); +await status(400, malformedJson(`/api/projects/${projectId}/sprints`, 'POST', ownerAuth), 'malformed sprint'); +response = await req(`/api/projects/${projectId}/sprints`, { + method: 'POST', headers: ownerAuth, body: jsonBody({ name: 'Invalid dates', startDate: 'not-a-date', endDate: '', goal: '' }), +}); +assert.equal(response.status, 200); +const sprintId = (await response.json()).id; +await status(404, req(`/api/projects/${projectId}/sprints/999999`, { method: 'DELETE', headers: ownerAuth }), 'unknown sprint delete'); +await status(200, req(`/api/projects/${projectId}/sprints/${sprintId}`, { method: 'DELETE', headers: ownerAuth }), 'sprint delete'); +await status(403, malformedJson(`/api/projects/${projectId}/baselines`, 'POST', memberAuth), 'viewer baseline'); +response = await malformedJson(`/api/projects/${projectId}/baselines`, 'POST', ownerAuth); +assert.equal(response.status, 200); +const baselineId = (await response.json()).id; +await status(404, req(`/api/projects/${projectId}/baselines/999999`, { headers: ownerAuth }), 'unknown baseline get'); +await status(404, req(`/api/projects/${projectId}/baselines/999999`, { method: 'DELETE', headers: ownerAuth }), 'unknown baseline delete'); +await status(200, req(`/api/projects/${projectId}/baselines/${baselineId}`, { method: 'DELETE', headers: ownerAuth }), 'baseline delete'); +await status(403, req(`/api/projects/${projectId}`, { method: 'DELETE', headers: memberAuth }), 'viewer project delete'); +await status(404, req('/api/projects/999999', { method: 'DELETE', headers: ownerAuth }), 'missing project delete'); + +// Best-effort audit writes are contained if the audit store rejects one event. +db.exec("CREATE TEMP TRIGGER fail_audit_insert BEFORE INSERT ON audit_log BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END"); +await status(200, req(`/api/projects/${projectId}/archive`, { method: 'POST', headers: ownerAuth, body: jsonBody({ archived: false }) }), 'audit failure is contained'); +db.exec('DROP TRIGGER fail_audit_insert'); + +// Transactional rollback branches: membership creation failure during signup +// and org creation, transfer update failure, and account-delete failure. +db.exec("CREATE TEMP TRIGGER fail_membership_insert BEFORE INSERT ON memberships BEGIN SELECT RAISE(ABORT, 'forced membership insert failure'); END"); +await status(500, req('/api/auth/signup', { method: 'POST', body: jsonBody({ email: 'rollback-signup@example.com', password: 'password123' }) }), 'signup rollback'); +await status(500, req('/api/orgs', { method: 'POST', headers: ownerAuth, body: jsonBody({ name: 'Rollback Org' }) }), 'org rollback'); +db.exec('DROP TRIGGER fail_membership_insert'); +assert.equal(db.prepare('SELECT id FROM users WHERE email = ?').get('rollback-signup@example.com'), undefined); + +// Prepare an ordinary member as the ownership-transfer target. +db.prepare("UPDATE memberships SET role = 'member' WHERE org_id = ? AND user_id = ?").run(orgId, memberId); +db.exec("CREATE TEMP TRIGGER fail_membership_update BEFORE UPDATE ON memberships BEGIN SELECT RAISE(ABORT, 'forced membership update failure'); END"); +await status(500, req(`/api/orgs/${orgId}/transfer`, { method: 'POST', headers: ownerAuth, body: jsonBody({ userId: memberId }) }), 'transfer rollback'); +db.exec('DROP TRIGGER fail_membership_update'); +assert.equal(db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, ownerId).role, 'owner'); + +response = await req('/api/auth/signup', { method: 'POST', body: jsonBody({ email: 'rollback-account@example.com', password: 'password123' }) }); +const rollbackAccountToken = (await response.json()).token; +const rollbackAccountId = (await (await req('/api/me', { headers: authHeaders(rollbackAccountToken) })).json()).user.id; +db.exec("CREATE TEMP TRIGGER fail_org_delete BEFORE DELETE ON orgs BEGIN SELECT RAISE(ABORT, 'forced org delete failure'); END"); +await status(500, req('/api/account', { method: 'DELETE', headers: authHeaders(rollbackAccountToken), body: jsonBody({ password: 'password123' }) }), 'account delete rollback'); +db.exec('DROP TRIGGER fail_org_delete'); +assert.ok(db.prepare('SELECT id FROM users WHERE id = ?').get(rollbackAccountId)); +await status(200, req('/api/account', { method: 'DELETE', headers: authHeaders(rollbackAccountToken), body: jsonBody({ password: 'password123' }) }), 'account delete after rollback'); + +// Restore-history catch: build a valid revision, then reject only the new +// history snapshot while allowing the project restore itself to succeed. +project = await (await req(`/api/projects/${projectId}`, { headers: ownerAuth })).json(); +await req(`/api/projects/${projectId}`, { method: 'PUT', headers: ownerAuth, body: jsonBody({ version: project.version, name: 'Restore source' }) }); +const restoreVersion = (await (await req(`/api/projects/${projectId}/revisions`, { headers: ownerAuth })).json()).revisions[0].version; +db.exec("CREATE TEMP TRIGGER fail_restore_revision BEFORE INSERT ON project_revisions BEGIN SELECT RAISE(ABORT, 'forced restore history failure'); END"); +await status(200, req(`/api/projects/${projectId}/revisions/${restoreVersion}/restore`, { method: 'POST', headers: ownerAuth }), 'restore history failure is contained'); +db.exec('DROP TRIGGER fail_restore_revision'); + +await status(400, malformedJson('/api/auth/change-password', 'POST', ownerAuth), 'malformed password change'); +await status(403, malformedJson('/api/account', 'DELETE', ownerAuth), 'malformed account delete'); +await status(404, req('/definitely-not-a-static-route'), 'unknown static route'); + +console.log('app branch coverage: ok'); \ No newline at end of file diff --git a/tests/api/app-edge-coverage.mjs b/tests/api/app-edge-coverage.mjs new file mode 100644 index 00000000..621a5558 --- /dev/null +++ b/tests/api/app-edge-coverage.mjs @@ -0,0 +1,299 @@ +// Realistic API edge-path coverage for the ScopeWeave SaaS boundary. +// This suite intentionally drives error, fallback, tenant, provider-retry, +// export, stream-cleanup, and static-asset failure paths through Hono requests. +import assert from 'node:assert/strict'; +import { rename } from 'node:fs/promises'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '2'; +process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '500'; +delete process.env.ORCHESTRATOR_URL; +delete process.env.CLEARFOLIO_URL; +delete process.env.OIDC_ISSUER; + +const [{ app }, { db }] = await Promise.all([ + import('../../server/app.mjs'), + import('../../server/db.mjs'), +]); + +let ipSequence = 0; +const body = (value) => JSON.stringify(value); +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const req = (path, options = {}) => { + const headers = new Headers(options.headers || {}); + if (!headers.has('x-forwarded-for')) { + ipSequence += 1; + headers.set('x-forwarded-for', `203.0.113.${(ipSequence % 240) + 1}`); + } + if (!(options.body instanceof FormData) && !headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + return app.request(path, { ...options, headers }); +}; + +// Rate limiting: first request creates a bucket, the third request exceeds it, +// then the elapsed fixed window replaces the bucket rather than permanently +// denying the caller. +const rateHeaders = { 'x-forwarded-for': '198.51.100.9' }; +assert.equal((await req('/api/metrics', { headers: rateHeaders })).status, 200); +assert.equal((await req('/api/metrics', { headers: rateHeaders })).status, 200); +assert.equal((await req('/api/metrics', { headers: rateHeaders })).status, 429); +await delay(510); +assert.equal((await req('/api/metrics', { headers: rateHeaders })).status, 200); + +// Owner account and its personal workspace. +let response = await req('/api/auth/signup', { + method: 'POST', + body: body({ email: 'edge-owner@example.com', password: 'password123', name: '' }), +}); +assert.equal(response.status, 200); +const ownerToken = (await response.json()).token; +const ownerAuth = { authorization: `Bearer ${ownerToken}` }; +response = await req('/api/me', { headers: ownerAuth }); +const me = await response.json(); +const ownerId = me.user.id; +const orgId = me.orgs[0].id; + +// Organization request parsing, normalization, and explicit-org project paths. +assert.equal((await req('/api/orgs', { method: 'POST', headers: ownerAuth, body: body({ name: ' ' }) })).status, 400); +response = await req('/api/orgs', { method: 'POST', headers: ownerAuth, body: body({ name: ' Edge Workspace ' }) }); +assert.equal(response.status, 200); +const secondaryOrgId = (await response.json()).id; + +response = await req('/api/projects', { method: 'POST', headers: ownerAuth, body: body({ name: 'Edge Project', orgId }) }); +assert.equal(response.status, 200); +const project = await response.json(); +const projectId = project.id; + +// Free-plan cap is enforced on duplicate just like create. A successful second +// project fills the cap; direct plan promotion afterward keeps subsequent edge +// cases focused on their own behavior. +assert.equal((await req('/api/projects', { method: 'POST', headers: ownerAuth, body: body({ name: 'Cap filler', orgId }) })).status, 200); +assert.equal((await req(`/api/projects/${projectId}/duplicate`, { method: 'POST', headers: ownerAuth, body: body({}) })).status, 402); +db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); +response = await req(`/api/projects/${projectId}/duplicate`, { method: 'POST', headers: ownerAuth, body: body({ name: '' }) }); +assert.equal(response.status, 200); +assert.match((await response.json()).name, /복사본/); + +// Missing tasks/name/base-date fields exercise the persisted-value fallbacks; +// valid methodology and task content exercise calendar/portfolio/briefing paths. +let loaded = await (await req(`/api/projects/${projectId}`, { headers: ownerAuth })).json(); +const datedTasks = [ + { + id: 'late,1', + name: 'Late, task;\\name\nnext', + plannedStartDate: '2020-01-01', + plannedEndDate: '2020-01-02', + plannedProgress: 100, + actualProgress: 20, + owner: 'Owner A', + weight: 2, + }, + { + id: 'future', + task: 'Future task', + plannedStartDate: '2999-01-01', + plannedEndDate: '2999-01-02', + plannedProgress: 0, + actualProgress: 0, + }, + { id: 'invalid-date', name: 'Invalid', plannedStartDate: 'not-a-date', plannedEndDate: '2999-01-02' }, + { id: 'fallback-name', plannedStartDate: '2999-02-01', plannedEndDate: '2999-02-01' }, +]; +response = await req(`/api/projects/${projectId}`, { + method: 'PUT', + headers: ownerAuth, + body: body({ tasks: datedTasks, version: loaded.version, methodology: 'agile' }), +}); +assert.equal(response.status, 200); + +// Comment list without taskId takes the all-comments query. Empty/oversized +// bodies and cross-tenant project access keep request validation explicit. +assert.equal((await req(`/api/projects/${projectId}/comments`, { method: 'POST', headers: ownerAuth, body: body({ body: ' ' }) })).status, 400); +assert.equal((await req(`/api/projects/${projectId}/comments`, { method: 'POST', headers: ownerAuth, body: body({ body: 'x'.repeat(2001) }) })).status, 400); +response = await req(`/api/projects/${projectId}/comments`, { method: 'POST', headers: ownerAuth, body: body({ taskId: '', body: 'Edge comment' }) }); +assert.equal(response.status, 200); +const commentId = (await response.json()).id; +response = await req(`/api/projects/${projectId}/comments`, { headers: ownerAuth }); +assert.equal(response.status, 200); +assert.ok((await response.json()).comments.some((item) => item.id === commentId)); + +// A second user cannot select an inaccessible explicit organization. +response = await req('/api/auth/signup', { method: 'POST', body: body({ email: 'edge-other@example.com', password: 'password123' }) }); +const otherToken = (await response.json()).token; +const otherAuth = { authorization: `Bearer ${otherToken}` }; +assert.equal((await req('/api/projects', { method: 'POST', headers: otherAuth, body: body({ name: 'Nope', orgId }) })).status, 400); +assert.equal((await req(`/api/projects/${projectId}/comments/${commentId}`, { method: 'DELETE', headers: otherAuth })).status, 404); + +// PAT authentication on calendar + attachment-view paths. The calendar includes +// escaped text, skips malformed dates, and uses task/id fallback names. +response = await req('/api/tokens', { method: 'POST', headers: ownerAuth, body: body({ name: '' }) }); +assert.equal(response.status, 200); +const pat = await response.json(); +const patAuth = { authorization: `Bearer ${pat.token}` }; +response = await req(`/api/projects/${projectId}/calendar.ics`, { headers: patAuth }); +assert.equal(response.status, 200); +const calendar = await response.text(); +assert.match(calendar, /BEGIN:VCALENDAR/); +assert.match(calendar, /Late\\, task\\;\\\\name\\nnext/); +assert.doesNotMatch(calendar, /invalid-date/); + +const upload = new FormData(); +upload.append('taskId', 'edge-task'); +upload.append('file', new Blob(['edge-pdf'], { type: 'application/pdf' }), 'edge.pdf'); +response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: { authorization: `Bearer ${ownerToken}`, 'x-forwarded-for': '203.0.113.250' }, + body: upload, +}); +assert.equal(response.status, 200); +const attachmentId = (await response.json()).id; +response = await req(`/api/projects/${projectId}/attachments/${attachmentId}/view`, { headers: patAuth }); +assert.equal(response.status, 302); +assert.match(response.headers.get('location') || '', /mock-clearfolio/); + +// Stripe completion accepts both documented organization-id locations. +for (const event of [ + { type: 'checkout.session.completed', data: { object: { client_reference_id: String(orgId) } } }, + { type: 'checkout.session.completed', data: { object: { metadata: { orgId: String(secondaryOrgId) } } } }, + { type: 'ignored.event', data: { object: {} } }, +]) { + assert.equal((await req('/api/stripe/webhook', { method: 'POST', body: body(event) })).status, 200); +} + +// Mock OIDC: reject a consumed state with a forged code, then complete a real +// self-contained flow for an already-existing user (upsert fast path). +async function mockCallbackPath(email) { + const start = await req(`/api/auth/oidc/start?email=${encodeURIComponent(email)}`); + assert.equal(start.status, 302); + const authorizeUrl = new URL(start.headers.get('location')); + const authorize = await req(`${authorizeUrl.pathname}${authorizeUrl.search}`); + assert.equal(authorize.status, 302); + const callbackUrl = new URL(authorize.headers.get('location')); + return callbackUrl; +} +let callbackUrl = await mockCallbackPath('edge-owner@example.com'); +const badCallback = new URL(callbackUrl); +badCallback.searchParams.set('code', 'forged-code'); +assert.equal((await req(`${badCallback.pathname}${badCallback.search}`)).status, 400); +callbackUrl = await mockCallbackPath('edge-owner@example.com'); +response = await req(`${callbackUrl.pathname}${callbackUrl.search}`); +assert.equal(response.status, 302); +assert.match(response.headers.get('location') || '', /^\/#token=/); +assert.equal((await req('/api/auth/oidc/callback?state=missing&code=missing')).status, 400); + +// Search and portfolio gracefully contain corrupted stored task JSON rather +// than leaking or crashing. Restore realistic tasks afterward for AI briefing. +db.prepare('UPDATE projects SET tasks_json = ? WHERE id = ?').run('{not-json', projectId); +assert.equal((await req('/api/search?q=x', { headers: ownerAuth })).status, 400); +response = await req('/api/search?q=Edge', { headers: ownerAuth }); +assert.equal(response.status, 200); +assert.ok((await response.json()).results.some((item) => item.projectId === projectId)); +response = await req(`/api/orgs/${orgId}/portfolio`, { headers: ownerAuth }); +assert.equal(response.status, 200); +assert.ok((await response.json()).projects.some((item) => item.id === projectId && item.tasks === 0)); +db.prepare('UPDATE projects SET tasks_json = ? WHERE id = ?').run(JSON.stringify(datedTasks), projectId); +response = await req(`/api/projects/${projectId}/ai/brief`, { method: 'POST', headers: ownerAuth, body: body({}) }); +assert.equal(response.status, 200); +assert.ok((await response.json()).analysis); +assert.equal((await req('/api/projects/999999/ai/brief', { method: 'POST', headers: ownerAuth, body: body({}) })).status, 404); + +// Each webhook delivery gets exactly one retry. Exercise HTTP and transport +// failures independently so the test matches the production retry contract. +response = await req(`/api/orgs/${orgId}/webhooks`, { + method: 'POST', + headers: ownerAuth, + body: body({ url: 'https://hooks.example.test/scopeweave', events: ['project.update'] }), +}); +assert.equal(response.status, 200); +const webhookId = (await response.json()).id; +const nativeFetch = globalThis.fetch; +let webhookAttempts = 0; +const waitForWebhookAttempts = async (expected) => { + const retryDeadline = Date.now() + 2500; + while (webhookAttempts < expected && Date.now() < retryDeadline) await delay(50); + assert.equal(webhookAttempts, expected); +}; +const triggerWebhook = async () => { + loaded = await (await req(`/api/projects/${projectId}`, { headers: ownerAuth })).json(); + response = await req(`/api/projects/${projectId}`, { + method: 'PUT', + headers: ownerAuth, + body: body({ version: loaded.version }), + }); + assert.equal(response.status, 200); +}; +globalThis.fetch = async () => { + webhookAttempts += 1; + if (webhookAttempts === 1) return new Response('retry', { status: 503 }); + if (webhookAttempts === 3) throw new Error('simulated transport reset'); + return new Response(null, { status: 204 }); +}; +try { + await triggerWebhook(); + await waitForWebhookAttempts(2); + await triggerWebhook(); + await waitForWebhookAttempts(4); + await delay(0); +} finally { + globalThis.fetch = nativeFetch; +} +response = await req(`/api/orgs/${orgId}/webhooks/${webhookId}/deliveries`, { headers: ownerAuth }); +assert.equal(response.status, 200); +const webhookDeliveries = (await response.json()).deliveries; +assert.equal(webhookDeliveries.length, 4); +assert.deepEqual(webhookDeliveries.map((item) => item.attempt), [2, 1, 2, 1]); +assert.deepEqual(webhookDeliveries.map((item) => item.ok), [1, 0, 1, 0]); +assert.deepEqual(webhookDeliveries.map((item) => item.statusCode), [204, null, 204, 503]); +assert.equal((await req(`/api/orgs/${orgId}/webhooks/999999/deliveries`, { headers: ownerAuth })).status, 404); +assert.equal((await req(`/api/orgs/${orgId}/webhooks/999999/rotate`, { method: 'POST', headers: ownerAuth })).status, 404); +assert.equal((await req(`/api/orgs/${orgId}/webhooks/999999`, { method: 'DELETE', headers: ownerAuth })).status, 404); +assert.equal((await req(`/api/orgs/${orgId}/webhooks/${webhookId}`, { method: 'DELETE', headers: ownerAuth })).status, 200); + +// Stream cleanup executes the abort listener on the request signal. +const abortController = new AbortController(); +response = await app.request(new Request(`http://localhost/api/projects/${projectId}/stream`, { + headers: { authorization: `Bearer ${ownerToken}`, 'x-forwarded-for': '203.0.113.249' }, + signal: abortController.signal, +})); +assert.equal(response.status, 200); +abortController.abort(); +await delay(0); +await response.body?.cancel().catch(() => undefined); +assert.equal((await req('/api/metrics?format=prometheus')).status, 200); + +// Audit CSV protects spreadsheet consumers against formula execution and takes +// the capped explicit-limit branch while JSON remains available. +db.prepare('INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)') + .run(orgId, ownerId, '=dangerous_formula()', 'edge,type', 'edge"id', JSON.stringify({ note: 'quoted,value' })); +const originalEmail = me.user.email; +db.prepare('UPDATE users SET email = ? WHERE id = ?').run(' =2+3', ownerId); +response = await req(`/api/orgs/${orgId}/audit?format=csv&limit=1000`, { headers: ownerAuth }); +assert.equal(response.status, 200); +const csv = await response.text(); +assert.match(csv, /'=dangerous_formula\(\)/); +assert.match(csv, /' =2\+3/); +db.prepare('UPDATE users SET email = ? WHERE id = ?').run(originalEmail, ownerId); +assert.equal((await req(`/api/orgs/${orgId}/audit?limit=0`, { headers: ownerAuth })).status, 200); + +// Token deletion not-found and owner-only workspace operations retain explicit +// fail-closed behavior. +assert.equal((await req('/api/tokens/999999', { method: 'DELETE', headers: ownerAuth })).status, 404); +assert.equal((await req(`/api/orgs/${orgId}/transfer`, { method: 'POST', headers: ownerAuth, body: body({ userId: ownerId }) })).status, 400); +assert.equal((await req(`/api/orgs/${orgId}`, { method: 'PATCH', headers: ownerAuth, body: body({ name: '' }) })).status, 400); +assert.equal((await req(`/api/orgs/${orgId}/leave`, { method: 'POST', headers: ownerAuth })).status, 403); + +// A mapped static asset that disappears at deployment time must fail closed as +// a 404. Restore the asset in finally so later jobs never inherit test damage. +const staticPath = 'robots.txt'; +const hiddenPath = 'robots.txt.coverage-edge'; +await rename(staticPath, hiddenPath); +try { + assert.equal((await req('/robots.txt')).status, 404); +} finally { + await rename(hiddenPath, staticPath); +} + +console.log('app edge coverage: ok'); \ No newline at end of file diff --git a/tests/api/app-final-branch-coverage.mjs b/tests/api/app-final-branch-coverage.mjs new file mode 100644 index 00000000..8735f4e4 --- /dev/null +++ b/tests/api/app-final-branch-coverage.mjs @@ -0,0 +1,220 @@ +// Final exact-head branch cases that remain observable through public API and +// integration boundaries after the broader residual suite. These are real +// authorization, malformed-auth, attachment-metadata, billing, and tenant- +// isolation behaviors rather than assertion-only coverage probes. +import assert from 'node:assert/strict'; +import { File } from 'node:buffer'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1000'; +process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; +delete process.env.ORCHESTRATOR_URL; +delete process.env.CLEARFOLIO_URL; +delete process.env.OIDC_ISSUER; + +const [{ app, logAudit }, { db }, { submitJob }, { signToken }, { PLANS, planOf }] = await Promise.all([ + import('../../server/app.mjs'), + import('../../server/db.mjs'), + import('../../server/clearfolio.mjs'), + import('../../server/auth.mjs'), + import('../../server/billing.mjs'), +]); + +const jsonBody = (value) => JSON.stringify(value); +const authHeaders = (token) => ({ authorization: `Bearer ${token}` }); +const req = (path, options = {}) => { + const headers = new Headers(options.headers || {}); + if (!(options.body instanceof FormData) && !headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + return app.request(path, { ...options, headers }); +}; +const status = async (expected, promise, label) => { + const response = await promise; + assert.equal(response.status, expected, label); + return response; +}; + +let response = await req('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email: 'final-owner@example.com', password: 'password123', name: 'Final Owner' }), +}); +assert.equal(response.status, 200); +const ownerToken = (await response.json()).token; +const ownerAuth = authHeaders(ownerToken); +const ownerMe = await (await req('/api/me', { headers: ownerAuth })).json(); +const ownerId = ownerMe.user.id; +const orgId = ownerMe.orgs[0].id; +db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + +// Forward-compatible plan reads must fail safely to Free rather than granting +// capabilities from an unknown persisted plan value. +assert.equal(planOf({ plan: 'future-plan' }), PLANS.free, 'unknown plans default to Free'); + +// System-originated audit entries legitimately have no actor, target metadata, +// or payload. Persist those nullable values as SQL NULL while keeping the audit +// write on the same best-effort path used by production requests. +logAudit(orgId, null, 'system.nullable-audit', undefined, null, null); +const nullableAudit = db.prepare( + `SELECT user_id AS userId, target_type AS targetType, target_id AS targetId, meta + FROM audit_log WHERE org_id = ? AND action = ? ORDER BY id DESC LIMIT 1`, +).get(orgId, 'system.nullable-audit'); +assert.deepEqual({ ...nullableAudit }, { + userId: null, + targetType: null, + targetId: null, + meta: null, +}); + +// Exercise the configured Stripe boundary through the public checkout API +// without adding a production Stripe dependency to this CI-repair branch. The +// temporary ESM fixture validates the exact non-secret checkout contract and is +// removed unconditionally before the test continues. +const stripeFixtureUrl = new URL('../../server/node_modules/stripe/', import.meta.url); +await mkdir(stripeFixtureUrl, { recursive: true }); +await writeFile(new URL('package.json', stripeFixtureUrl), JSON.stringify({ + name: 'stripe', + version: '0.0.0-scopeweave-test', + type: 'module', + exports: './index.js', +})); +await writeFile(new URL('index.js', stripeFixtureUrl), ` +export default class Stripe { + constructor(key) { + if (key !== 'sk_scopeweave_test') throw new Error('unexpected Stripe test key'); + this.checkout = { + sessions: { + create: async (options) => ({ + url: 'https://checkout.example.test/session?' + new URLSearchParams({ + mode: options.mode, + price: options.line_items[0].price, + quantity: String(options.line_items[0].quantity), + success_url: options.success_url, + cancel_url: options.cancel_url, + client_reference_id: options.client_reference_id, + metadata_org_id: options.metadata.orgId, + }), + }), + }, + }; + } +} +`); +process.env.STRIPE_SECRET_KEY = 'sk_scopeweave_test'; +process.env.STRIPE_PRICE_ID = 'price_scopeweave_pro'; +try { + response = await req(`/api/orgs/${orgId}/checkout`, { + method: 'POST', + headers: ownerAuth, + }); + assert.equal(response.status, 200); + const checkout = await response.json(); + assert.equal(checkout.live, true); + const checkoutUrl = new URL(checkout.url); + assert.equal(checkoutUrl.searchParams.get('mode'), 'subscription'); + assert.equal(checkoutUrl.searchParams.get('price'), 'price_scopeweave_pro'); + assert.equal(checkoutUrl.searchParams.get('quantity'), '1'); + assert.equal(checkoutUrl.searchParams.get('success_url'), 'http://localhost/?billing=success'); + assert.equal(checkoutUrl.searchParams.get('cancel_url'), 'http://localhost/?billing=cancel'); + assert.equal(checkoutUrl.searchParams.get('client_reference_id'), String(orgId)); + assert.equal(checkoutUrl.searchParams.get('metadata_org_id'), String(orgId)); +} finally { + delete process.env.STRIPE_SECRET_KEY; + delete process.env.STRIPE_PRICE_ID; + await rm(stripeFixtureUrl, { recursive: true, force: true }); +} + +// A cryptographically valid token for an account that no longer exists must +// fail closed. This is the realistic stale-session boundary after account +// deletion and exercises the short-circuit user lookup in authenticated routes. +const deletedAccountToken = signToken({ + sub: 999999, + email: 'deleted-account@example.com', + tv: 0, +}); +const deletedAccountAuth = authHeaders(deletedAccountToken); +await status(401, req('/api/me', { headers: deletedAccountAuth }), 'deleted account bearer token'); + +response = await req('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email: 'final-viewer@example.com', password: 'password123' }), +}); +const viewerAuth = authHeaders((await response.json()).token); +const viewerId = (await (await req('/api/me', { headers: viewerAuth })).json()).user.id; +db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(orgId, viewerId, 'viewer'); + +response = await req('/api/projects', { + method: 'POST', + headers: ownerAuth, + body: jsonBody({ name: 'Final Branch Project', orgId }), +}); +assert.equal(response.status, 200); +const projectId = (await response.json()).id; + +// Calendar clients that supply neither bearer nor query credentials fail closed. +await status(401, req(`/api/projects/${projectId}/calendar.ics`), 'calendar missing credentials'); + +// Read-only members cannot mutate roster roles or remove another member. These +// checks happen before target lookup, preserving the management boundary. +await status(403, req(`/api/orgs/${orgId}/members/${ownerId}`, { + method: 'PATCH', + headers: viewerAuth, + body: jsonBody({ role: 'member' }), +}), 'viewer cannot change member role'); +await status(403, req(`/api/orgs/${orgId}/members/${ownerId}`, { + method: 'DELETE', + headers: viewerAuth, +}), 'viewer cannot remove member'); + +// WHATWG multipart parsing treats filename="" as a regular form field rather +// than a File. The upload boundary must reject that malformed file part instead +// of pretending the unreachable File.name fallback is a browser behavior. +const emptyFilename = new FormData(); +emptyFilename.append('file', new File(['unnamed document'], '', { type: '' })); +await status(400, app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: ownerAuth, + body: emptyFilename, +}), 'empty multipart filename is not accepted as a file'); + +// A named browser file without explicit MIME metadata is normalized by the +// multipart parser and remains uploadable/viewable through a valid query JWT. +const untypedFile = new FormData(); +untypedFile.append('file', new File(['untyped document'], 'untyped.bin', { type: '' })); +response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: ownerAuth, + body: untypedFile, +}); +assert.equal(response.status, 200); +const untypedAttachmentId = (await response.json()).id; +response = await req(`/api/projects/${projectId}/attachments/${untypedAttachmentId}/view?token=${encodeURIComponent(ownerToken)}`); +assert.equal(response.status, 302); +await status( + 401, + req(`/api/projects/${projectId}/attachments/${untypedAttachmentId}/view?token=${encodeURIComponent(deletedAccountToken)}`), + 'deleted account attachment-view token', +); +await status(404, req(`/api/projects/${projectId}/attachments/999999`, { + method: 'DELETE', + headers: ownerAuth, +}), 'missing attachment delete'); + +// A mock Clearfolio artifact with no MIME metadata must still be served with a +// safe binary fallback rather than an absent or malformed Content-Type. +const rawJob = await submitJob(orgId, ownerId, { + name: 'raw.bin', + mime: '', + bytes: Buffer.from('raw artifact'), +}); +response = await req(`/api/mock-clearfolio/${rawJob.jobId}`); +assert.equal(response.status, 200); +assert.match(response.headers.get('content-type') || '', /^application\/octet-stream\b/); + +// Share-list tenant isolation returns not-found for an inaccessible project. +await status(404, req('/api/projects/999999/shares', { headers: ownerAuth }), 'share list missing project'); + +console.log('app final branch coverage: ok'); \ No newline at end of file diff --git a/tests/api/app-provider-edge-coverage.mjs b/tests/api/app-provider-edge-coverage.mjs new file mode 100644 index 00000000..83c1a4ea --- /dev/null +++ b/tests/api/app-provider-edge-coverage.mjs @@ -0,0 +1,145 @@ +// Provider-mode API coverage. This process intentionally imports the app with +// hosted OIDC, contextual-orchestrator, Clearfolio, and on-disk logging enabled +// so production-only fail-closed branches remain executable under coverage. +import assert from 'node:assert/strict'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const dbPath = join(tmpdir(), `scopeweave-provider-edge-${process.pid}.sqlite`); +await rm(dbPath, { force: true }); +process.env.SCOPEWEAVE_DB = dbPath; +process.env.SCOPEWEAVE_DEV = '0'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.OIDC_ISSUER = 'https://idp.example.test/'; +process.env.OIDC_CLIENT_ID = 'scopeweave-client'; +process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; +process.env.OIDC_REDIRECT_URI = 'https://scopeweave.example.test/api/auth/oidc/callback'; +process.env.ORCHESTRATOR_URL = 'https://orchestrator.example.test'; +process.env.ORCHESTRATOR_TOKEN = 'provider-test-token'; +process.env.CLEARFOLIO_URL = 'https://clearfolio.example.test'; + +const [{ app }, { db }] = await Promise.all([ + import('../../server/app.mjs'), + import('../../server/db.mjs'), +]); + +const nativeFetch = globalThis.fetch; +const nativeLog = console.log; +const body = (value) => JSON.stringify(value); +const req = (path, options = {}) => { + const headers = new Headers(options.headers || {}); + if (!(options.body instanceof FormData) && !headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + return app.request(path, { ...options, headers }); +}; + +function oidcPayload(claims) { + return `header.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.signature`; +} + +async function oidcState() { + const start = await req('/api/auth/oidc/start'); + assert.equal(start.status, 302); + const location = new URL(start.headers.get('location')); + assert.equal(location.origin, 'https://idp.example.test'); + assert.equal(location.pathname, '/authorize'); + assert.equal(location.searchParams.get('client_id'), 'scopeweave-client'); + assert.equal(location.searchParams.get('redirect_uri'), process.env.OIDC_REDIRECT_URI); + assert.equal(location.searchParams.get('code_challenge_method'), 'S256'); + return location.searchParams.get('state'); +} + +try { + // On-disk mode must execute structured request logging, while a logging sink + // failure remains isolated from request handling. + const logLines = []; + console.log = (line) => logLines.push(line); + let response = await req('/api/health'); + assert.equal(response.status, 200); + assert.ok(logLines.some((line) => JSON.parse(line).path === '/api/health')); + console.log = () => { throw new Error('simulated logging sink failure'); }; + assert.equal((await req('/api/health')).status, 200); + console.log = () => {}; + + // Hosted mode must disable the built-in mock IdP route. + assert.equal((await req('/api/auth/oidc/mock/authorize?state=x&email=x@example.com&redirect_uri=https://scopeweave.example.test/cb')).status, 404); + + // Each callback consumes a one-time state. Exercise transport failure, + // malformed JSON, a valid token without email, and a complete hosted login. + globalThis.fetch = async () => { throw new Error('simulated IdP outage'); }; + let state = await oidcState(); + assert.equal((await req(`/api/auth/oidc/callback?state=${state}&code=outage`)).status, 400); + + globalThis.fetch = async () => new Response('not-json', { status: 200 }); + state = await oidcState(); + assert.equal((await req(`/api/auth/oidc/callback?state=${state}&code=bad-json`)).status, 400); + + globalThis.fetch = async () => new Response(JSON.stringify({ id_token: oidcPayload({ sub: 'no-email' }) }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + state = await oidcState(); + assert.equal((await req(`/api/auth/oidc/callback?state=${state}&code=no-email`)).status, 400); + + globalThis.fetch = async () => new Response(JSON.stringify({ id_token: oidcPayload({ email: 'hosted-sso@example.com' }) }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + state = await oidcState(); + response = await req(`/api/auth/oidc/callback?state=${state}&code=success`); + assert.equal(response.status, 302); + assert.match(response.headers.get('location') || '', /^\/#token=/); + + // Create a normal tenant/project for downstream hosted-provider error paths. + response = await req('/api/auth/signup', { + method: 'POST', + body: body({ email: 'provider-owner@example.com', password: 'password123', name: 'Provider Owner' }), + }); + assert.equal(response.status, 200); + const token = (await response.json()).token; + const auth = { authorization: `Bearer ${token}` }; + response = await req('/api/me', { headers: auth }); + const me = await response.json(); + const userId = me.user.id; + response = await req('/api/projects', { method: 'POST', headers: auth, body: body({ name: 'Provider Project' }) }); + assert.equal(response.status, 200); + const projectId = (await response.json()).id; + + // contextual-orchestrator transport failures must be translated into the + // stable browser-safe API failure rather than leaking provider details. + globalThis.fetch = async () => { throw new Error('private orchestrator transport detail'); }; + response = await req(`/api/projects/${projectId}/ai/brief`, { method: 'POST', headers: auth, body: body({}) }); + assert.equal(response.status, 502); + assert.doesNotMatch((await response.json()).error, /private orchestrator transport detail/); + + // Clearfolio conversion submission has the same provider-error containment. + const upload = new FormData(); + upload.append('taskId', 'provider-task'); + upload.append('file', new Blob(['provider-pdf'], { type: 'application/pdf' }), 'provider.pdf'); + globalThis.fetch = async () => { throw new Error('private clearfolio submit detail'); }; + response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: upload, + }); + assert.equal(response.status, 502); + assert.doesNotMatch((await response.json()).error, /private clearfolio submit detail/); + + // A completed persisted job whose artifact-link provider becomes unavailable + // must also fail closed through the public API boundary. + const attachmentId = Number(db.prepare( + 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?) RETURNING id', + ).get(projectId, 'provider-task', 'persisted.pdf', 'application/pdf', 12, 'hosted-job-1', 'SUCCEEDED', userId).id); + globalThis.fetch = async () => { throw new Error('private artifact-link detail'); }; + response = await req(`/api/projects/${projectId}/attachments/${attachmentId}/view`, { headers: auth }); + assert.equal(response.status, 502); + assert.doesNotMatch((await response.json()).error, /private artifact-link detail/); +} finally { + globalThis.fetch = nativeFetch; + console.log = nativeLog; + await rm(dbPath, { force: true }); +} + +console.log('app hosted provider edge coverage: ok'); diff --git a/tests/api/app-provider-fallback-coverage.mjs b/tests/api/app-provider-fallback-coverage.mjs new file mode 100644 index 00000000..e49383b9 --- /dev/null +++ b/tests/api/app-provider-fallback-coverage.mjs @@ -0,0 +1,83 @@ +// Hosted OIDC fallback coverage for production branches that only exist when +// no explicit redirect URI is configured. The public start/callback flow proves +// origin-derived redirect binding and malformed/valid id_token handling. +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '0'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1000'; +process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; +process.env.OIDC_ISSUER = 'https://idp.example.test/'; +process.env.OIDC_CLIENT_ID = 'scopeweave-client'; +process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; +delete process.env.OIDC_REDIRECT_URI; +delete process.env.ORCHESTRATOR_URL; +delete process.env.CLEARFOLIO_URL; + +const { app } = await import('../../server/app.mjs'); +const nativeFetch = globalThis.fetch; + +const req = (path, options = {}) => { + const headers = new Headers(options.headers || {}); + if (!(options.body instanceof FormData) && !headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + return app.request(path, { ...options, headers }); +}; + +async function startHostedFlow() { + const start = await req('/api/auth/oidc/start'); + assert.equal(start.status, 302); + const authorization = new URL(start.headers.get('location')); + assert.equal(authorization.origin, 'https://idp.example.test'); + assert.equal(authorization.pathname, '/authorize'); + assert.equal(authorization.searchParams.get('redirect_uri'), 'http://localhost/api/auth/oidc/callback'); + return authorization.searchParams.get('state'); +} + +function hostedToken(email) { + const claims = Buffer.from(JSON.stringify({ email })).toString('base64url'); + return `header.${claims}.signature`; +} + +try { + // A syntactically present id_token with an empty payload exercises both the + // missing JWT-segment and decoded-empty-object fallbacks. It must fail closed + // as a stable public 400 rather than throwing a JSON parse exception. + globalThis.fetch = async () => new Response(JSON.stringify({ id_token: 'header..signature' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + let state = await startHostedFlow(); + let response = await req(`/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=empty-claims`); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'no email claim' }); + + // A valid hosted token uses the same origin-derived redirect URI during the + // token exchange and completes the browser-safe fragment redirect. + let observedTokenRequest; + globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + observedTokenRequest = request.clone(); + return new Response(JSON.stringify({ id_token: hostedToken('fallback-hosted@example.com') }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + state = await startHostedFlow(); + response = await req(`/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=valid-hosted`); + assert.equal(response.status, 302); + assert.match(response.headers.get('location') || '', /^\/#token=/); + assert.equal(observedTokenRequest.url, 'https://idp.example.test/token'); + const tokenForm = new URLSearchParams(await observedTokenRequest.text()); + assert.equal(tokenForm.get('redirect_uri'), 'http://localhost/api/auth/oidc/callback'); + assert.equal(tokenForm.get('client_id'), 'scopeweave-client'); + assert.equal(tokenForm.get('client_secret'), 'scopeweave-secret'); + assert.equal(tokenForm.get('code'), 'valid-hosted'); + assert.ok(tokenForm.get('code_verifier')); +} finally { + globalThis.fetch = nativeFetch; +} + +console.log('app hosted provider fallback coverage: ok'); diff --git a/tests/api/app-residual-branch-coverage.mjs b/tests/api/app-residual-branch-coverage.mjs new file mode 100644 index 00000000..4c4e5689 --- /dev/null +++ b/tests/api/app-residual-branch-coverage.mjs @@ -0,0 +1,258 @@ +// Residual production branch coverage through observable API behavior. +// These cases target tenant/auth guards, fallback semantics, and best-effort +// integration boundaries that remain material under exact-head coverage. +import assert from 'node:assert/strict'; +import { File } from 'node:buffer'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1000'; +process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; +delete process.env.ORCHESTRATOR_URL; +delete process.env.CLEARFOLIO_URL; +delete process.env.OIDC_ISSUER; +delete process.env.OIDC_CLIENT_ID; +delete process.env.OIDC_CLIENT_SECRET; +delete process.env.OIDC_REDIRECT_URI; + +const [{ app }, { db }, { signToken }] = await Promise.all([ + import('../../server/app.mjs'), + import('../../server/db.mjs'), + import('../../server/auth.mjs'), +]); + +const jsonBody = (value) => JSON.stringify(value); +const authHeaders = (token) => ({ authorization: `Bearer ${token}` }); +const req = (path, options = {}) => { + const headers = new Headers(options.headers || {}); + if (!(options.body instanceof FormData) && !headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + return app.request(path, { ...options, headers }); +}; +const status = async (expected, promise, label) => { + const response = await promise; + assert.equal(response.status, expected, label); + return response; +}; + +let response = await req('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email: 'residual-owner@example.com', password: 'password123', name: 'Residual Owner' }), +}); +assert.equal(response.status, 200); +const ownerToken = (await response.json()).token; +const ownerAuth = authHeaders(ownerToken); +const ownerMe = await (await req('/api/me', { headers: ownerAuth })).json(); +const ownerId = ownerMe.user.id; +const orgId = ownerMe.orgs[0].id; +db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + +// A cryptographically valid token for a deleted/nonexistent subject must still +// fail closed at the authoritative user-version lookup. +const ghostToken = signToken({ sub: 999999, email: 'ghost@example.com', tv: 0 }); +await status(401, req('/api/me', { headers: authHeaders(ghostToken) }), 'nonexistent signed user'); + +response = await req('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email: 'residual-member@example.com', password: 'password123' }), +}); +const memberToken = (await response.json()).token; +const memberAuth = authHeaders(memberToken); +const memberId = (await (await req('/api/me', { headers: memberAuth })).json()).user.id; +db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(orgId, memberId, 'viewer'); + +response = await req('/api/projects', { + method: 'POST', + headers: ownerAuth, + body: jsonBody({ name: 'Residual Project', orgId }), +}); +assert.equal(response.status, 200); +const projectId = (await response.json()).id; + +// A blank legacy methodology is schema-valid and still exercises the documented +// waterfall fallback; an invalid update must not persist an unknown mode. +db.prepare("UPDATE projects SET methodology = '' WHERE id = ?").run(projectId); +response = await req(`/api/projects/${projectId}`, { headers: ownerAuth }); +assert.equal((await response.json()).methodology, 'waterfall'); +let project = await (await req(`/api/projects/${projectId}`, { headers: ownerAuth })).json(); +await status(200, req(`/api/projects/${projectId}`, { + method: 'PUT', + headers: ownerAuth, + body: jsonBody({ version: project.version, methodology: 'unsupported-mode' }), +}), 'invalid methodology falls back'); + +// Comment and revision guards distinguish inaccessible projects, read-only +// membership, and missing snapshots from valid project history. +await status(404, req('/api/projects/999999/comments', { + method: 'POST', headers: ownerAuth, body: jsonBody({ body: 'missing project' }), +}), 'comment missing project'); +await status(403, req(`/api/projects/${projectId}/comments`, { + method: 'POST', headers: memberAuth, body: jsonBody({ body: 'viewer write' }), +}), 'viewer comment forbidden'); +await status(404, req('/api/projects/999999/revisions', { headers: ownerAuth }), 'revisions missing project'); +await status(404, req('/api/projects/999999/revisions/1', { headers: ownerAuth }), 'revision detail missing project'); +await status(404, req(`/api/projects/${projectId}/revisions/999999`, { headers: ownerAuth }), 'revision snapshot missing'); + +// Calendar query-token authentication is a real EventSource/calendar-client +// path. Missing start/end dates must be skipped independently. +project = await (await req(`/api/projects/${projectId}`, { headers: ownerAuth })).json(); +await status(200, req(`/api/projects/${projectId}`, { + method: 'PUT', + headers: ownerAuth, + body: jsonBody({ + version: project.version, + tasks: [ + { id: 'missing-start', name: 'Missing start', plannedEndDate: '2999-01-02' }, + { id: 'missing-end', name: 'Missing end', plannedStartDate: '2999-01-01' }, + ], + }), +}), 'calendar fallback task seed'); +response = await req(`/api/projects/${projectId}/calendar.ics?token=${encodeURIComponent(ownerToken)}`); +assert.equal(response.status, 200); +const calendar = await response.text(); +assert.doesNotMatch(calendar, /missing-start|missing-end/); + +// Invitation defaults and owner-protection rules must remain explicit. +await status(400, req(`/api/orgs/${orgId}/invites`, { + method: 'POST', headers: ownerAuth, body: jsonBody({}), +}), 'invite email required'); +response = await req(`/api/orgs/${orgId}/invites`, { + method: 'POST', headers: ownerAuth, body: jsonBody({ email: 'invite-default@example.com' }), +}); +assert.equal(response.status, 200); +assert.equal((await response.json()).role, 'member'); +await status(403, req(`/api/orgs/${orgId}/members/${ownerId}`, { + method: 'PATCH', headers: ownerAuth, body: jsonBody({ role: 'member' }), +}), 'owner role immutable'); +await status(403, req(`/api/orgs/${orgId}/members/${ownerId}`, { + method: 'DELETE', headers: ownerAuth, +}), 'owner cannot be removed'); +await status(404, req('/api/orgs/999999/leave', { method: 'POST', headers: ownerAuth }), 'leave unknown org'); +await status(404, req('/api/orgs/999999/billing', { headers: ownerAuth }), 'billing unknown org'); + +// Non-managers cannot inspect or mutate webhook controls. A blank event +// subscription must be treated as no subscription and never trigger delivery. +await status(403, req(`/api/orgs/${orgId}/webhooks`, { + method: 'POST', headers: memberAuth, body: jsonBody({ url: 'https://hooks.example.test/denied' }), +}), 'viewer webhook create'); +await status(403, req(`/api/orgs/${orgId}/webhooks/1/deliveries`, { headers: memberAuth }), 'viewer webhook deliveries'); +await status(403, req(`/api/orgs/${orgId}/webhooks/1/rotate`, { method: 'POST', headers: memberAuth }), 'viewer webhook rotate'); +await status(403, req(`/api/orgs/${orgId}/webhooks/1`, { method: 'DELETE', headers: memberAuth }), 'viewer webhook delete'); +response = await req(`/api/orgs/${orgId}/webhooks`, { + method: 'POST', headers: ownerAuth, body: jsonBody({ url: 'https://hooks.example.test/blank-events', events: ['project.update'] }), +}); +const blankEventsWebhook = await response.json(); +db.prepare("UPDATE webhooks SET events = '' WHERE id = ?").run(blankEventsWebhook.id); +project = await (await req(`/api/projects/${projectId}`, { headers: ownerAuth })).json(); +await status(200, req(`/api/projects/${projectId}`, { + method: 'PUT', headers: ownerAuth, body: jsonBody({ version: project.version }), +}), 'blank webhook subscriptions are skipped'); + +// The mock OIDC user-creation transaction must roll back atomically if its +// membership insert fails. +const oidcStart = await req('/api/auth/oidc/start?email=residual-sso@example.com'); +const authorizeUrl = new URL(oidcStart.headers.get('location')); +const oidcAuthorize = await req(`${authorizeUrl.pathname}${authorizeUrl.search}`); +const callbackUrl = new URL(oidcAuthorize.headers.get('location')); +db.exec("CREATE TEMP TRIGGER fail_oidc_membership_insert BEFORE INSERT ON memberships BEGIN SELECT RAISE(ABORT, 'forced oidc membership failure'); END"); +await status(500, req(`${callbackUrl.pathname}${callbackUrl.search}`), 'OIDC user creation rollback'); +db.exec('DROP TRIGGER fail_oidc_membership_insert'); +assert.equal(db.prepare('SELECT id FROM users WHERE email = ?').get('residual-sso@example.com'), undefined); + +// Search must safely inspect tasks that have no display name; AI briefing must +// tolerate corrupt legacy task JSON and still return a bounded empty summary. +db.prepare('UPDATE projects SET tasks_json = ? WHERE id = ?').run(JSON.stringify([{ id: 'unnamed-task' }]), projectId); +response = await req('/api/search?q=Residual', { headers: ownerAuth }); +assert.equal(response.status, 200); +assert.ok((await response.json()).results.some((item) => item.projectId === projectId)); +db.prepare('UPDATE projects SET tasks_json = ? WHERE id = ?').run('{corrupt-json', projectId); +await status(200, req(`/api/projects/${projectId}/ai/brief`, { + method: 'POST', headers: ownerAuth, body: jsonBody({}), +}), 'AI briefing corrupt task fallback'); +db.prepare('UPDATE projects SET tasks_json = ? WHERE id = ?').run('[]', projectId); + +// Attachment guards cover inaccessible projects, query/PAT/JWT auth, empty MIME +// metadata, uploader-vs-manager authorization, and mock artifact MIME fallback. +const missingProjectForm = new FormData(); +missingProjectForm.append('file', new Blob(['missing']), 'missing.pdf'); +await status(404, app.request('/api/projects/999999/attachments', { + method: 'POST', headers: ownerAuth, body: missingProjectForm, +}), 'attachment missing project'); + +const emptyMime = new FormData(); +emptyMime.append('file', new File(['empty metadata'], 'empty-mime.bin', { type: '' })); +response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', headers: ownerAuth, body: emptyMime, +}); +assert.equal(response.status, 200); +const emptyAttachmentId = (await response.json()).id; +const emptyAttachment = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(emptyAttachmentId); +assert.ok(emptyAttachment?.job_id); +await status(200, req(`/api/mock-clearfolio/${emptyAttachment.job_id}`), 'mock artifact empty MIME fallback'); +await status(401, req(`/api/projects/${projectId}/attachments/${emptyAttachmentId}/view`, { + headers: authHeaders('swk_invalid'), +}), 'attachment invalid PAT'); +await status(401, req(`/api/projects/${projectId}/attachments/${emptyAttachmentId}/view`, { + headers: authHeaders(ghostToken), +}), 'attachment nonexistent signed user'); +await status(404, req(`/api/projects/999999/attachments/${emptyAttachmentId}/view`, { headers: ownerAuth }), 'attachment view missing project'); +await status(404, req(`/api/projects/999999/attachments/${emptyAttachmentId}`, { method: 'DELETE', headers: ownerAuth }), 'attachment delete missing project'); + +// The uploader may delete their own attachment without management privilege. +db.prepare("UPDATE memberships SET role = 'member' WHERE org_id = ? AND user_id = ?").run(orgId, memberId); +const memberFile = new FormData(); +memberFile.append('file', new Blob(['member'], { type: 'application/pdf' }), 'member-own.pdf'); +response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', headers: memberAuth, body: memberFile, +}); +assert.equal(response.status, 200); +const memberAttachmentId = (await response.json()).id; +await status(200, req(`/api/projects/${projectId}/attachments/${memberAttachmentId}`, { + method: 'DELETE', headers: memberAuth, +}), 'attachment uploader delete'); + +// Share, sprint, and baseline routes preserve tenant and read-only guards on +// every operation, including legacy blank methodology/default metadata paths. +db.prepare("UPDATE memberships SET role = 'viewer' WHERE org_id = ? AND user_id = ?").run(orgId, memberId); +await status(404, req('/api/projects/999999/shares', { method: 'POST', headers: ownerAuth }), 'share create missing project'); +response = await req(`/api/projects/${projectId}/shares`, { method: 'POST', headers: ownerAuth }); +assert.equal(response.status, 200); +const shareToken = await response.json(); +const shareId = db.prepare('SELECT id FROM share_tokens WHERE token = ?').get(shareToken.token).id; +await status(403, req(`/api/projects/${projectId}/shares/${shareId}`, { method: 'DELETE', headers: memberAuth }), 'viewer share revoke'); +await status(404, req(`/api/projects/999999/shares/${shareId}`, { method: 'DELETE', headers: ownerAuth }), 'share revoke missing project'); + +await status(404, req('/api/projects/999999/sprints', { headers: ownerAuth }), 'sprint list missing project'); +db.prepare("UPDATE projects SET methodology = '' WHERE id = ?").run(projectId); +response = await req(`/api/projects/${projectId}/sprints`, { headers: ownerAuth }); +assert.equal(response.status, 200); +assert.equal((await response.json()).methodology, 'waterfall'); +await status(404, req('/api/projects/999999/sprints/1', { method: 'DELETE', headers: ownerAuth }), 'sprint delete missing project'); +response = await req(`/api/projects/${projectId}/sprints`, { + method: 'POST', headers: ownerAuth, body: jsonBody({ name: 'Protected sprint', startDate: '2026-08-18', endDate: '2026-08-25' }), +}); +const sprintId = (await response.json()).id; +await status(403, req(`/api/projects/${projectId}/sprints/${sprintId}`, { method: 'DELETE', headers: memberAuth }), 'viewer sprint delete'); + +await status(404, req('/api/projects/999999/baselines', { method: 'POST', headers: ownerAuth, body: jsonBody({}) }), 'baseline create missing project'); +await status(404, req('/api/projects/999999/baselines', { headers: ownerAuth }), 'baseline list missing project'); +await status(404, req('/api/projects/999999/baselines/1', { headers: ownerAuth }), 'baseline detail missing project'); +await status(404, req('/api/projects/999999/baselines/1', { method: 'DELETE', headers: ownerAuth }), 'baseline delete missing project'); +response = await req(`/api/projects/${projectId}/baselines`, { method: 'POST', headers: ownerAuth, body: jsonBody({}) }); +const baselineId = (await response.json()).id; +await status(403, req(`/api/projects/${projectId}/baselines/${baselineId}`, { method: 'DELETE', headers: memberAuth }), 'viewer baseline delete'); + +// Null metadata is legal in historical audit rows. Both JSON audit and workspace +// export must preserve that as null instead of assuming every event has JSON. +db.prepare('INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)') + .run(orgId, ownerId, 'legacy.null_meta', 'project', String(projectId), null); +response = await req(`/api/orgs/${orgId}/audit`, { headers: ownerAuth }); +assert.equal(response.status, 200); +assert.ok((await response.json()).events.some((event) => event.action === 'legacy.null_meta' && event.meta === null)); +response = await req(`/api/orgs/${orgId}/export`, { headers: ownerAuth }); +assert.equal(response.status, 200); +assert.ok((await response.json()).audit.some((event) => event.action === 'legacy.null_meta' && event.meta === null)); + +console.log('app residual branch coverage: ok'); diff --git a/tests/api/attachment-metadata.test.mjs b/tests/api/attachment-metadata.test.mjs new file mode 100644 index 00000000..71c2686b --- /dev/null +++ b/tests/api/attachment-metadata.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.CLEARFOLIO_URL = ''; + +const { app } = await import('../../server/app.mjs'); +const { db } = await import('../../server/db.mjs'); + +const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); + +test('empty multipart filenames are rejected before attachment metadata is persisted', async () => { + let response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: JSON.stringify({ + email: 'unnamed-attachment@scopeweave.test', + password: 'password123', + name: 'Unnamed Attachment', + }), + }); + assert.equal(response.status, 200); + const token = (await response.json()).token; + const auth = { authorization: `Bearer ${token}` }; + + response = await jsonRequest('/api/projects', { + method: 'POST', + headers: auth, + body: JSON.stringify({ name: 'Unnamed Attachment Project' }), + }); + assert.equal(response.status, 200); + const projectId = (await response.json()).id; + + const form = new FormData(); + form.append('file', new Blob(['unnamed'], { type: 'text/plain' }), ''); + form.set('taskId', 'unnamed-task'); + response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: auth, + body: form, + }); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'multipart file required' }); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM attachments WHERE project_id = ?').get(projectId).count, + 0, + 'a multipart field without a filename must never create attachment metadata', + ); +}); diff --git a/tests/api/session-revocation.test.mjs b/tests/api/session-revocation.test.mjs index 6164798b..8bbc3394 100644 --- a/tests/api/session-revocation.test.mjs +++ b/tests/api/session-revocation.test.mjs @@ -12,6 +12,7 @@ process.env.SCOPEWEAVE_JWT_SECRET = JWT_SECRET; const { app } = await import('../../server/app.mjs'); const { signToken } = await import('../../server/auth.mjs'); +const { db } = await import('../../server/db.mjs'); const req = (path, opts = {}) => app.request(path, { @@ -88,6 +89,60 @@ async function expectRejectedEverywhere(projectId, token, label) { await expectAttachmentViewStatus(projectId, token, 401, `attachment view rejects ${label}`); } +/** + * Fault-inject a token-version change between the verifier read and a transport's + * defense-in-depth read, modeling a concurrent logout-all from another process. + * + * Both reads are synchronous in this process, so the test interposes only the + * exact token-version query instead of weakening production verification. The + * first read remains the real durable value used by `verifyToken`; the second + * read reports the next version, as an external writer could after verification. + * All other statement methods are delegated to the real SQLite statement so + * the fault seam cannot accidentally narrow the adapter surface under test. + * + * @param {() => Promise} runRequest - Request that authenticates one JWT. + * @param {string} label - Diagnostic label for assertions. + * @returns {Promise} Resolves after the request is proven fail-closed. + */ +async function expectPostVerificationRevocationRejected(runRequest, label) { + const originalPrepare = db.prepare; + let tokenVersionReads = 0; + + db.prepare = function prepareWithRevocationFault(sql) { + const statement = originalPrepare.call(this, sql); + if (sql !== 'SELECT token_version FROM users WHERE id = ?') return statement; + + return new Proxy(statement, { + get(target, property) { + if (property === 'get') { + return (...args) => { + const row = target.get(...args); + tokenVersionReads += 1; + if (tokenVersionReads === 2 && row) { + return { ...row, token_version: row.token_version + 1 }; + } + return row; + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }; + + try { + const response = await runRequest(); + assert.equal(response.status, 401, `${label} rejects post-verification revocation`); + assert.equal( + tokenVersionReads, + 2, + `${label} performs verifier and transport token-version reads`, + ); + } finally { + db.prepare = originalPrepare; + } +} + test('session signer rejects malformed claims before minting a token', () => { assert.throws(() => signToken(null), /claims must be an object/); assert.throws(() => signToken([], 60), /claims must be an object/); @@ -181,6 +236,26 @@ test('logout-all and strict JWT validation cover every session transport', async const missingUserToken = signToken({ sub: userId + 1_000_000, tv: 0 }); await expectRejectedEverywhere(projectId, missingUserToken, 'signed token for a missing user'); + // A cryptographically valid token for a real user must still fail closed when + // its version does not equal the durable account version. Exercise this + // boundary directly rather than relying only on the later logout transition. + const mismatchedVersionToken = signToken({ sub: userId, tv: 1 }); + await expectRejectedEverywhere(projectId, mismatchedVersionToken, 'signed token with mismatched token version'); + + // Model a separate ScopeWeave process committing logout-all after this process + // completes verification but before its transport-specific second read. Both + // bearer middleware and attachment query-token access must fail closed. + await expectPostVerificationRevocationRejected( + () => req('/api/me', { headers: authA }), + 'bearer middleware', + ); + await expectPostVerificationRevocationRejected( + () => req( + `/api/projects/${projectId}/attachments/missing/view?token=${encodeURIComponent(tokenA)}`, + ), + 'attachment query-token route', + ); + await expectBearerStatus(tokenA, 200, 'bearer accepts token A before revocation'); await expectBearerStatus(tokenB, 200, 'bearer accepts token B before revocation'); await expectCalendarStatus(projectId, tokenA, 200, 'calendar accepts token A before revocation'); @@ -205,4 +280,4 @@ test('logout-all and strict JWT validation cover every session transport', async await expectCalendarStatus(projectId, freshToken, 200, 'calendar accepts replacement token'); await expectStreamStatus(projectId, freshToken, 200, 'SSE accepts replacement token'); await expectAttachmentViewStatus(projectId, freshToken, 404, 'attachment view accepts replacement token before lookup'); -}); +}); \ No newline at end of file diff --git a/tests/e2e/beforeunload.spec.js b/tests/e2e/beforeunload.spec.js index c94c44ae..db6fb85c 100644 --- a/tests/e2e/beforeunload.spec.js +++ b/tests/e2e/beforeunload.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-test.js'; test.describe('Inline editor unsaved-change guards', () => { test('Escape on dirty editor prompts before discard', async ({ page }) => { diff --git a/tests/e2e/browser-coverage-boundary.spec.js b/tests/e2e/browser-coverage-boundary.spec.js new file mode 100644 index 00000000..d3b5c5d7 --- /dev/null +++ b/tests/e2e/browser-coverage-boundary.spec.js @@ -0,0 +1,251 @@ +import { test, expect } from './coverage-test.js'; + +const STORAGE_KEY = 'scopeweave:planner-state:v1'; + +const hierarchy = [ + { id: 'root-a', parentId: null, depth: 1, expanded: true, phase: 'Root A', plannedStartDate: '2026-08-17', plannedEndDate: '2026-08-21', actualProgressStatus: '미착수(0%)' }, + { id: 'activity-a', parentId: 'root-a', depth: 2, expanded: true, phase: 'Root A', activity: 'Activity A', plannedStartDate: '2026-08-17', plannedEndDate: '2026-08-21', actualProgressStatus: '미착수(0%)' }, + { id: 'leaf-a', parentId: 'activity-a', depth: 3, expanded: true, phase: 'Root A', activity: 'Activity A', task: 'Leaf A', plannedStartDate: '2026-08-17', plannedEndDate: '2026-08-21', actualProgressStatus: '미착수(0%)' }, + { id: 'root-b', parentId: null, depth: 1, expanded: true, phase: 'Root B', plannedStartDate: '2026-08-17', plannedEndDate: '2026-08-21', actualProgressStatus: '미착수(0%)' }, +]; + +async function seedPlanner(page, tasks = hierarchy, { captureHost = false } = {}) { + await page.addInitScript(({ storageKey, seedTasks, shouldCaptureHost }) => { + localStorage.setItem(storageKey, JSON.stringify({ + projectName: 'Coverage boundary', + baseDate: '2026-08-19', + tasks: seedTasks, + })); + + if (!shouldCaptureHost) return; + let cloudApi; + Object.defineProperty(window, 'ScopeWeaveCloud', { + configurable: true, + get() { return cloudApi; }, + set(value) { + if (value && typeof value.init === 'function') { + const originalInit = value.init; + value.init = function capturePlannerHost(hostApi) { + window.__scopeweavePlannerHost = hostApi; + return originalInit.call(this, hostApi); + }; + } + cloudApi = value; + }, + }); + }, { storageKey: STORAGE_KEY, seedTasks: tasks, shouldCaptureHost: captureHost }); +} + +test.describe('browser defensive coverage boundaries', () => { + test('fails closed for stale table events while preserving valid drag behavior', async ({ page }) => { + await seedPlanner(page); + await page.goto('/'); + + const result = await page.evaluate(() => { + const dragEventPayload = () => ({ bubbles: true, cancelable: true, dataTransfer: new DataTransfer() }); + const tbody = document.querySelector('#task-table-body'); + const rootA = document.querySelector('tr[data-task-id="root-a"]'); + const activity = document.querySelector('tr[data-task-id="activity-a"]'); + const rootB = document.querySelector('tr[data-task-id="root-b"]'); + if (!tbody || !rootA || !activity || !rootB) throw new Error('expected seeded rows'); + + tbody.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + tbody.dispatchEvent(new DragEvent('dragstart', dragEventPayload())); + tbody.dispatchEvent(new DragEvent('dragover', dragEventPayload())); + tbody.dispatchEvent(new DragEvent('drop', dragEventPayload())); + tbody.dispatchEvent(new DragEvent('dragend', dragEventPayload())); + + const invalidTransfer = new DataTransfer(); + rootA.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: invalidTransfer })); + activity.dispatchEvent(new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer: invalidTransfer, + clientY: activity.getBoundingClientRect().bottom - 1, + })); + rootA.dispatchEvent(new DragEvent('dragend', { bubbles: true, cancelable: true, dataTransfer: invalidTransfer })); + + const validTransfer = new DataTransfer(); + rootA.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: validTransfer })); + rootB.dispatchEvent(new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer: validTransfer, + clientY: rootB.getBoundingClientRect().bottom - 1, + })); + const becameDropTarget = rootB.classList.contains('drop-target'); + rootB.dispatchEvent(new DragEvent('dragleave', { bubbles: true, cancelable: true, dataTransfer: validTransfer })); + const clearedDropTarget = !rootB.classList.contains('drop-target'); + rootA.dispatchEvent(new DragEvent('dragend', { bubbles: true, cancelable: true, dataTransfer: validTransfer })); + + const progress = rootA.querySelector('[data-inline-progress]'); + progress.dataset.inlineProgress = 'missing-task'; + progress.dispatchEvent(new Event('change', { bubbles: true })); + + const edit = rootA.querySelector('[data-action="edit"]'); + rootA.dataset.taskId = 'missing-task'; + edit.click(); + rootA.querySelector('td:nth-child(2)').click(); + + return { becameDropTarget, clearedDropTarget }; + }); + + expect(result).toEqual({ becameDropTarget: true, clearedDropTarget: true }); + + const leafAdd = page.locator('tr[data-task-id="leaf-a"] [data-action="add-child"]'); + await leafAdd.evaluate((button) => button.removeAttribute('aria-disabled')); + await leafAdd.click(); + await expect(page.locator('#toast')).toContainText('최대 3단계까지만 추가할 수 있습니다'); + }); + + test('keeps editor validation and stale edit races contained', async ({ page }) => { + await seedPlanner(page, hierarchy, { captureHost: true }); + await page.goto('/'); + await page.waitForFunction(() => Boolean(window.__scopeweavePlannerHost)); + + const root = page.locator('tr[data-task-id="root-a"]'); + await root.getByRole('button', { name: '편집 - Root A' }).click(); + await page.getByTestId('editor-owner').fill('Updated owner'); + await page.getByRole('button', { name: '저장', exact: true }).click(); + await expect(page.locator('tr[data-task-id="root-a"]')).toContainText('Updated owner'); + + await page.locator('tr[data-task-id="root-a"]').getByRole('button', { name: '편집 - Root A' }).click(); + await page.getByTestId('editor-phase').fill(''); + await page.locator('form[data-editor-form="true"]').evaluate((form) => { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + }); + await expect(page.locator('#editor-errors')).toContainText('최상위 작업은 단계 값을 입력해야 합니다'); + + page.once('dialog', (dialog) => dialog.accept()); + await page.keyboard.press('Escape'); + await expect(page.locator('.editor-panel')).toHaveCount(0); + + await page.evaluate(() => { + const host = window.__scopeweavePlannerHost; + host.hydrateState({ projectName: 'Concurrent replacement', baseDate: '2026-08-19', tasks: [] }); + }); + await page.locator('tr[data-task-id="root-a"] [data-action="edit"]').click(); + await expect(page.locator('.editor-panel')).toHaveCount(0); + }); + + test('recovers from local-state and seed-read failures', async ({ page }) => { + await page.addInitScript((storageKey) => { + const nativeGetItem = Storage.prototype.getItem; + Storage.prototype.getItem = function guardedGetItem(key) { + if (key === storageKey) throw new DOMException('blocked', 'SecurityError'); + return nativeGetItem.call(this, key); + }; + }, STORAGE_KEY); + await page.route('**/wbs.json', (route) => route.fulfill({ status: 503, contentType: 'application/json', body: '{}' })); + await page.goto('/'); + + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(0); + await expect(page.getByRole('button', { name: '최상위 작업 추가' }).first()).toBeVisible(); + }); + + test('treats a non-array seed document as an empty plan', async ({ page }) => { + await page.route('**/wbs.json', (route) => route.fulfill({ contentType: 'application/json', body: '{"unexpected":true}' })); + await page.goto('/'); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(0); + await expect(page.locator('.table-empty')).toContainText('등록된 작업이 없습니다'); + }); + + test('covers empty, invalid-depth, and CRLF CSV chooser boundaries', async ({ page }) => { + await page.route('**/wbs.json', (route) => route.fulfill({ contentType: 'application/json', body: '[]' })); + await page.goto('/'); + + await page.locator('#csv-file-input').evaluate((input) => input.dispatchEvent(new Event('change', { bubbles: true }))); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(0); + + const required = ['단계', 'Activity', 'Task', '대분류', '중분류', '산출물', '담당자', '지원팀', '실적진척상태', '계획시작일', '계획종료일', '실적시작일', '실적종료일', '__depth']; + const invalidDepth = ['Invalid depth', '', '', '', '', '', '', '', '미착수(0%)', '2026-08-20', '2026-08-21', '', '', '9']; + await page.locator('#csv-file-input').setInputFiles({ + name: 'invalid-depth.csv', + mimeType: 'text/csv', + buffer: Buffer.from(`${required.join(',')}\r\n${invalidDepth.join(',')}\r\n`, 'utf8'), + }); + await expect(page.locator('#toast')).toContainText('__depth 컬럼은 1, 2, 3 중 하나여야 합니다'); + + const valid = ['CRLF phase', '', '', '', '', '', '', '', '미착수(0%)', '2026-08-20', '2026-08-21', '', '', '1']; + await page.locator('#csv-file-input').setInputFiles({ + name: 'valid-crlf.csv', + mimeType: 'text/csv', + buffer: Buffer.from(`${required.join(',')}\r\n${valid.join(',')}\r\n`, 'utf8'), + }); + await expect(page.getByText('CRLF phase', { exact: true })).toHaveCount(1); + }); + + test('detects picker disappearance and a later connected-file write failure', async ({ page }) => { + await page.addInitScript(() => { + window.__scopeweaveWriteAttempt = 0; + Object.defineProperty(window, 'showSaveFilePicker', { + configurable: true, + value: async () => ({ + async createWritable() { + window.__scopeweaveWriteAttempt += 1; + const attempt = window.__scopeweaveWriteAttempt; + return { + async write() { + if (attempt > 1) throw new Error('simulated later write failure'); + }, + async close() {}, + }; + }, + }), + }); + }); + await page.route('**/wbs.json', (route) => route.fulfill({ contentType: 'application/json', body: '[]' })); + await page.goto('/'); + + await page.getByRole('button', { name: /wbs\.json 자동저장 연결/ }).click(); + await expect(page.locator('#toast')).toContainText('자동저장 연결이 완료되었습니다'); + await page.locator('#project-name').fill('Trigger connected write'); + await page.locator('#project-name').blur(); + await expect(page.locator('#toast')).toContainText('연결된 wbs.json 파일 저장에 실패했습니다'); + }); + + test('explains when an enabled picker control loses browser support before activation', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, 'showSaveFilePicker', { configurable: true, value: async () => ({}) }); + }); + await page.route('**/wbs.json', (route) => route.fulfill({ contentType: 'application/json', body: '[]' })); + await page.goto('/'); + await page.evaluate(() => { + Object.defineProperty(window, 'showSaveFilePicker', { configurable: true, value: undefined }); + }); + await page.getByRole('button', { name: /wbs\.json 자동저장 연결/ }).click(); + await expect(page.locator('#toast')).toContainText('이 브라우저는 wbs.json 직접 저장 연결을 지원하지 않습니다'); + }); + + test('keeps Gantt safe for out-of-window actual dates and an emptied focus trap', async ({ page }) => { + await seedPlanner(page, [ + { id: 'early', parentId: null, depth: 1, expanded: true, phase: 'Early actual', plannedStartDate: '2026-08-17', plannedEndDate: '2026-08-21', actualStartDate: '2026-08-10', actualEndDate: '2026-08-11', actualProgressStatus: '진행(50%)' }, + { id: 'late', parentId: null, depth: 1, expanded: true, phase: 'Late actual', plannedStartDate: '2026-08-17', plannedEndDate: '2026-08-21', actualStartDate: '2026-08-24', actualEndDate: '2026-08-25', actualProgressStatus: '진행(50%)' }, + { id: 'reversed', parentId: null, depth: 1, expanded: true, phase: 'Reversed actual', plannedStartDate: '2026-08-17', plannedEndDate: '2026-08-21', actualStartDate: '2026-08-20', actualEndDate: '2026-08-18', actualProgressStatus: '진행(50%)' }, + ]); + await page.goto('/'); + await page.getByRole('button', { name: '간트차트보기' }).click(); + + await expect(page.locator('.gantt-bar.plan')).toHaveCount(3); + await expect(page.locator('.gantt-bar.actual')).toHaveCount(0); + const dispatchResult = await page.locator('#gantt-modal').evaluate((modal) => { + modal.querySelectorAll('button').forEach((button) => button.remove()); + modal.querySelectorAll('[tabindex]').forEach((element) => element.setAttribute('tabindex', '-1')); + modal.setAttribute('tabindex', '-1'); + modal.focus(); + return modal.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })); + }); + expect(dispatchResult).toBe(false); + await expect(page.locator('#gantt-modal')).toBeFocused(); + }); + + test('expires planner toasts after their announced interval', async ({ page }) => { + await seedPlanner(page); + await page.goto('/'); + const leafAdd = page.locator('tr[data-task-id="leaf-a"] [data-action="add-child"]'); + await leafAdd.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#toast')).toHaveClass(/show/); + await expect(page.locator('#toast')).not.toHaveClass(/show/, { timeout: 3000 }); + }); +}); diff --git a/tests/e2e/browser-coverage-completion.spec.js b/tests/e2e/browser-coverage-completion.spec.js new file mode 100644 index 00000000..c9b7e7d0 --- /dev/null +++ b/tests/e2e/browser-coverage-completion.spec.js @@ -0,0 +1,212 @@ +import { spawn } from 'node:child_process'; +import { test, expect } from './coverage-test.js'; + +const PORT = 8834; +const BASE = `http://127.0.0.1:${PORT}`; +let server; +let ownerToken; +let ownerOrgId; +let projectId; + +async function api(path, { method = 'GET', body, tok = ownerToken } = {}) { + const response = await fetch(`${BASE}${path}`, { + method, + headers: { + 'content-type': 'application/json', + ...(tok ? { authorization: `Bearer ${tok}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await response.json().catch(() => ({})); + return { ok: response.ok, status: response.status, data }; +} + +async function waitForServer() { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + const response = await fetch(`${BASE}/api/health`); + if (response.ok) return; + } catch { /* server is still starting */ } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error('ScopeWeave coverage-completion server did not become ready'); +} + +async function loginAndOpen(page) { + await page.goto(`${BASE}/`); + await page.evaluate(({ authToken, project }) => { + localStorage.clear(); + localStorage.setItem('scopeweave:token', authToken); + localStorage.setItem('scopeweave:project', String(project)); + }, { authToken: ownerToken, project: projectId }); + await page.reload(); + await page.waitForSelector('#cloud-auth select'); +} + +test.beforeAll(async () => { + server = spawn(process.execPath, ['server/server.mjs'], { + env: { + ...process.env, + SCOPEWEAVE_DB: ':memory:', + SCOPEWEAVE_JWT_SECRET: '0123456789abcdef0123456789abcdef', + PORT: String(PORT), + }, + stdio: 'ignore', + }); + await waitForServer(); + + const signup = await api('/api/auth/signup', { + method: 'POST', + tok: '', + body: { + email: 'coverage-completion@scopeweave.test', + password: 'password123', + name: 'Coverage Completion Owner', + }, + }); + if (!signup.ok) throw new Error(`coverage-completion signup failed (${signup.status})`); + ownerToken = signup.data.token; + + const me = await api('/api/me'); + ownerOrgId = me.data.orgs[0].id; + const created = await api('/api/projects', { + method: 'POST', + body: { name: 'Coverage Completion Project', orgId: ownerOrgId }, + }); + if (!created.ok) throw new Error(`coverage-completion project creation failed (${created.status})`); + projectId = created.data.id; +}); + +test.afterAll(() => { server?.kill(); }); + +test('a clean page leaves beforeunload non-blocking before any editor session', async ({ page }) => { + await page.goto('/'); + + const unload = await page.evaluate(() => { + const event = new Event('beforeunload', { cancelable: true }); + const dispatched = window.dispatchEvent(event); + return { dispatched, defaultPrevented: event.defaultPrevented }; + }); + + expect(unload).toEqual({ dispatched: true, defaultPrevented: false }); +}); + +test('the first root task persists when randomUUID is unavailable but secure random values exist', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'First Root Coverage', + baseDate: '2026-08-21', + tasks: [], + })); + Object.defineProperty(window.crypto, 'randomUUID', { + configurable: true, + value: undefined, + }); + }); + await page.goto('/'); + + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(0); + expect(await page.evaluate(() => typeof crypto.randomUUID)).toBe('undefined'); + + await page.locator('#add-root-task').click(); + await page.getByTestId('editor-phase').fill('Secure fallback root'); + await page.getByTestId('editor-category-large').fill('Coverage'); + await page.getByTestId('editor-owner').fill('Coverage Owner'); + await page.getByTestId('editor-planned-start').fill('2026-08-21'); + await page.getByTestId('editor-planned-end').fill('2026-08-22'); + await page.getByRole('button', { name: '저장', exact: true }).click(); + + const row = page.locator('tbody tr[data-task-id]').filter({ hasText: 'Secure fallback root' }); + await expect(row).toHaveCount(1); + const generatedId = await row.getAttribute('data-task-id'); + expect(generatedId).toMatch(/^task-[0-9a-f]+-[0-9a-f]+$/); + await expect.poll(() => page.evaluate(() => { + const stored = JSON.parse(localStorage.getItem('scopeweave:planner-state:v1') || '{}'); + return stored.tasks?.[0]?.id || null; + })).toBe(generatedId); +}); + +test('a free-plan upgrade follows the live checkout redirect returned by the provider boundary', async ({ page }) => { + await loginAndOpen(page); + const checkoutTarget = `${BASE}/checkout-redirect-target`; + + await page.route('**/api/orgs/*/checkout', async (route) => { + expect(route.request().method()).toBe('POST'); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ mock: false, url: checkoutTarget }), + }); + }); + await page.route(checkoutTarget, (route) => route.fulfill({ + status: 200, + contentType: 'text/html; charset=utf-8', + body: 'Checkout redirect targetredirected', + })); + + await page.getByRole('button', { name: '팀', exact: true }).click(); + const upgrade = page.locator('#team-body .billing-upgrade'); + await expect(upgrade).toBeVisible(); + + await Promise.all([ + page.waitForURL(checkoutTarget), + upgrade.click(), + ]); + await expect(page).toHaveTitle('Checkout redirect target'); +}); + +test('a failed provider navigation still attempts the returned checkout URL without a false demo fallback', async ({ page }) => { + await loginAndOpen(page); + const plannerUrl = page.url(); + const checkoutTarget = `${BASE}/checkout-navigation-aborted`; + let checkoutRequests = 0; + + await page.route('**/api/orgs/*/checkout', async (route) => { + checkoutRequests += 1; + expect(route.request().method()).toBe('POST'); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ mock: false, url: checkoutTarget }), + }); + }); + await page.route(checkoutTarget, (route) => route.abort('aborted')); + + await page.getByRole('button', { name: '팀', exact: true }).click(); + const upgrade = page.locator('#team-body .billing-upgrade'); + await expect(upgrade).toBeVisible(); + + const attemptedNavigation = page.waitForRequest(checkoutTarget); + await upgrade.click(); + const navigationRequest = await attemptedNavigation; + + expect(navigationRequest.url()).toBe(checkoutTarget); + expect(checkoutRequests).toBe(1); + await expect(page.locator('#toast')).not.toContainText('데모 환경입니다.'); + await expect(page).toHaveURL(plannerUrl); +}); + +test('a demo billing checkout explains the missing provider key without navigating away', async ({ page }) => { + await loginAndOpen(page); + const plannerUrl = page.url(); + let checkoutRequests = 0; + + await page.route('**/api/orgs/*/checkout', async (route) => { + checkoutRequests += 1; + expect(route.request().method()).toBe('POST'); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ mock: true, url: null }), + }); + }); + + await page.getByRole('button', { name: '팀', exact: true }).click(); + const upgrade = page.locator('#team-body .billing-upgrade'); + await expect(upgrade).toBeVisible(); + await upgrade.click(); + + await expect(page.locator('#toast')).toContainText('결제 연동(Stripe 키)이 필요합니다 — 데모 환경입니다.'); + expect(checkoutRequests).toBe(1); + await expect(page).toHaveURL(plannerUrl); +}); diff --git a/tests/e2e/browser-crypto-compatibility.spec.js b/tests/e2e/browser-crypto-compatibility.spec.js new file mode 100644 index 00000000..41677e4b --- /dev/null +++ b/tests/e2e/browser-crypto-compatibility.spec.js @@ -0,0 +1,54 @@ +import { test, expect } from './coverage-test.js'; + +test('creates and persists a task when randomUUID is unavailable but getRandomValues exists', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window.crypto, 'randomUUID', { + configurable: true, + value: undefined, + }); + }); + await page.route('**/wbs.json', (route) => route.fulfill({ + contentType: 'application/json', + body: '[]', + })); + await page.goto('/'); + + await page.getByRole('button', { name: '최상위 작업 추가' }).first().click(); + await page.getByTestId('editor-phase').fill('Secure fallback task'); + await page.getByRole('button', { name: '저장', exact: true }).click(); + + const row = page.locator('tbody tr[data-task-id]').filter({ hasText: 'Secure fallback task' }); + await expect(row).toHaveCount(1); + const taskId = await row.getAttribute('data-task-id'); + expect(taskId).toMatch(/^task-[0-9a-f]+-[0-9a-f]+$/); + + await page.reload(); + const persisted = page.locator(`tbody tr[data-task-id="${taskId}"]`); + await expect(persisted).toContainText('Secure fallback task'); +}); + +test('refuses to create a task when the browser exposes no secure random source', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperties(window.crypto, { + randomUUID: { configurable: true, value: undefined }, + getRandomValues: { configurable: true, value: undefined }, + }); + }); + await page.route('**/wbs.json', (route) => route.fulfill({ + contentType: 'application/json', + body: '[]', + })); + await page.goto('/'); + + await page.getByRole('button', { name: '최상위 작업 추가' }).first().click(); + await page.getByTestId('editor-phase').fill('Must not receive an insecure id'); + + const pageError = page.waitForEvent('pageerror'); + await page.getByRole('button', { name: '저장', exact: true }).click(); + await expect(pageError).resolves.toMatchObject({ + message: 'Secure random number generation is not supported in this environment', + }); + + await expect(page.locator('tbody tr[data-task-id]').filter({ hasText: 'Must not receive an insecure id' })).toHaveCount(0); + await expect(page.getByTestId('editor-phase')).toHaveValue('Must not receive an insecure id'); +}); diff --git a/tests/e2e/browser-exact-coverage-regressions.spec.js b/tests/e2e/browser-exact-coverage-regressions.spec.js new file mode 100644 index 00000000..d4ab8bde --- /dev/null +++ b/tests/e2e/browser-exact-coverage-regressions.spec.js @@ -0,0 +1,360 @@ +import { test, expect } from './coverage-test.js'; + +const ANALYTICS_TASKS = [ + { + id: 'requirements', + phase: 'Requirements workshop', + activity: 'RFI clarification', + task: 'Define acceptance criteria and business case', + documentName: 'RFP requirements package', + owner: 'PM', + plannedStartDate: '2026-08-03', + plannedEndDate: '2026-08-05', + plannedProgress: 100, + actualProgress: 100, + budget: 1000, + actualCost: 900, + storyPoints: 3, + }, + { + id: 'implementation', + parentId: 'requirements', + activity: 'Implementation', + task: 'Build bidder response workflow', + documentName: 'Working feature', + owner: 'Engineer', + plannedStartDate: '2026-08-06', + plannedEndDate: '2026-08-10', + plannedProgress: 80, + actualProgress: 60, + budget: 2000, + actualCost: 2200, + storyPoints: 8, + predecessors: 'requirementsFS+1', + }, + { + id: 'validation', + task: 'Evaluation and Q&A', + owner: '', + plannedStartDate: '2026-08-10', + plannedEndDate: '2026-08-11', + plannedProgress: 50, + actualProgress: 10, + budget: 500, + actualCost: 800, + predecessors: ['implementationSS', 'requirementsFF+2', 'missingSF-1'], + }, +]; + +test.describe('exact browser analytics production coverage', () => { + test('public analytics API covers schedule, cost, CPM, workload, and PM risk boundaries', async ({ page }) => { + await page.goto('/'); + + const result = await page.evaluate((tasks) => { + const api = window.ScopeWeaveAnalytics; + if (!api) throw new Error('ScopeWeaveAnalytics is not available'); + + const calcDuration = (start, end) => { + const ms = Date.parse(end) - Date.parse(start); + if (!Number.isFinite(ms) || ms < 0) return 0; + return Math.max(1, Math.round(ms / 86400000)); + }; + const calcPlannedRatio = (date, start, end, duration) => { + if (!date || !start || !end) return 0; + if (date <= start) return 0; + if (date >= end) return 1; + const elapsed = calcDuration(start, date); + return Math.max(0, Math.min(1, elapsed / Math.max(duration, 1))); + }; + const buildTimeline = (start, end) => { + const rows = []; + const cursor = new Date(`${start}T00:00:00Z`); + const finish = new Date(`${end}T00:00:00Z`); + while (cursor <= finish) { + rows.push({ date: cursor.toISOString().slice(0, 10) }); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return rows; + }; + + const evm = [ + api.computeEvm({ pv: 0, ev: 0 }), + api.computeEvm({ pv: 0.5, ev: 0.6 }), + api.computeEvm({ pv: 0.5, ev: 0.5 }), + api.computeEvm({ pv: 0.5, ev: 0.46 }), + api.computeEvm({ pv: 0.5, ev: 0.2 }), + ]; + + const noDates = api.buildScurve({ + tasks: [{ id: 'undated' }], calcPlannedRatio, calcDuration, buildTimeline, + }); + const nullScurve = api.buildScurve({ + tasks: null, calcPlannedRatio, calcDuration, buildTimeline, + }); + const zeroDuration = api.buildScurve({ + tasks: [{ id: 'invalid', plannedStartDate: '2026-08-05', plannedEndDate: '2026-08-03' }], + calcPlannedRatio, + calcDuration, + buildTimeline, + }); + const curve = api.buildScurve({ tasks, calcPlannedRatio, calcDuration, buildTimeline }); + + const relationCpm = api.computeCpm([ + { id: 'A', duration: 2 }, + { id: 'B', duration: 3, predecessors: 'A' }, + { id: 'C', duration: 1, predecessors: 'ASS+1' }, + { id: 'D', duration: 2, predecessors: 'BFF+1' }, + { id: 'E', duration: 1, predecessors: 'CSF-1,missing' }, + { id: 'FS', duration: 1 }, + { id: 'letters', duration: 1, predecessors: 'FS' }, + { id: 'dated', plannedStartDate: '2026-08-01', plannedEndDate: '2026-08-03' }, + { id: 'invalid-date', plannedStartDate: 'bad', plannedEndDate: 'worse' }, + { id: 'reverse-date', plannedStartDate: '2026-08-05', plannedEndDate: '2026-08-03' }, + { id: 'negative-duration', duration: -1 }, + ], { calcDuration }); + const nativeDateCpm = api.computeCpm([ + { id: 'native-valid', plannedStartDate: '2026-08-01', plannedEndDate: '2026-08-03' }, + { id: 'native-invalid', plannedStartDate: 'bad', plannedEndDate: 'worse' }, + { id: 'native-reverse', plannedStartDate: '2026-08-05', plannedEndDate: '2026-08-03' }, + ]); + const cycle = api.computeCpm([ + { id: 'x', duration: 1, predecessors: 'y' }, + { id: 'y', duration: 1, predecessors: 'x' }, + ]); + const emptyCpm = api.computeCpm(null); + + const nullCost = api.computeCostEvm(null); + const costCases = [ + api.computeCostEvm([]), + api.computeCostEvm([{ budget: 100, plannedProgress: 20, actualProgress: 0, actualCost: 0 }]), + api.computeCostEvm([{ budget: 100, plannedProgress: 80, actualProgress: 100, actualCost: 80 }]), + api.computeCostEvm([{ budget: 100, plannedProgress: 80, actualProgress: 95, actualCost: 100 }]), + api.computeCostEvm([{ budget: 100, plannedProgress: 80, actualProgress: 50, actualCost: 100 }]), + ]; + + const nullWorkload = api.computeWorkload(null); + const workload = api.computeWorkload([ + { owner: 'Kim', plannedProgress: 80, actualProgress: 70 }, + { owner: 'Kim', plannedProgress: 50, actualProgress: 50 }, + { owner: '', plannedProgress: 20, actualProgress: 0 }, + ]); + const emptyPm = api.computePmAnalysis([]); + const nonArrayPm = api.computePmAnalysis(null); + const nativeDatePm = api.computePmAnalysis([ + { + id: 'native-pm', + task: 'Requirement acceptance', + plannedStartDate: '2026-08-01', + plannedEndDate: '2026-08-03', + }, + ]); + const parentCyclePm = api.computePmAnalysis([ + { id: 'parent-a', parentId: 'parent-b', task: 'A', duration: 1 }, + { id: 'parent-b', parentId: 'parent-a', task: 'B', duration: 1 }, + ]); + const strongPm = api.computePmAnalysis(tasks, { calcDuration }); + const mediumPm = api.computePmAnalysis([ + { id: '1', task: 'Build', duration: 2 }, + { id: '2', task: 'Test', duration: 2 }, + { id: '3', task: 'Ship', duration: 2 }, + { id: '4', task: 'Operate', duration: 2 }, + ]); + const cyclicPm = api.computePmAnalysis([ + { id: 'a', task: 'Requirement', predecessors: 'b' }, + { id: 'b', task: 'Review', predecessors: 'a' }, + ]); + + return { + evm: evm.map(({ status, label, spi }) => ({ status, label, spi })), + noDates, + nullScurve, + zeroDuration, + curveLength: curve.timeline.length, + relationDuration: relationCpm.projectDurationDays, + relationCycle: relationCpm.cycleDetected, + nativeDuration: nativeDateCpm.projectDurationDays, + cycleDetected: cycle.cycleDetected, + emptyDuration: emptyCpm.projectDurationDays, + nullCost, + costCases: costCases.map((entry) => entry && ({ status: entry.status, label: entry.label, cpi: entry.cpi })), + nullWorkload, + workload, + emptyPm, + nonArrayPm, + nativePmDuration: nativeDatePm.estimates.totalDurationDays, + parentCycleTotal: parentCyclePm.tasks.total, + strongRisk: strongPm.dependencies.risk, + strongReady: strongPm.procurement.ready, + mediumRisk: mediumPm.dependencies.risk, + cyclicRisk: cyclicPm.dependencies.risk, + }; + }, ANALYTICS_TASKS); + + expect(result.evm.map((entry) => entry.label)).toEqual([ + '계획 착수 전', '일정 선행', '일정 준수', '경미한 지연', '지연 위험', + ]); + expect(result.noDates).toEqual({ timeline: [], planned: [] }); + expect(result.nullScurve).toEqual({ timeline: [], planned: [] }); + expect(result.zeroDuration).toEqual({ timeline: [], planned: [] }); + expect(result.curveLength).toBeGreaterThan(2); + expect(result.relationDuration).toBeGreaterThan(0); + expect(result.relationCycle).toBe(false); + expect(result.nativeDuration).toBeGreaterThan(0); + expect(result.cycleDetected).toBe(true); + expect(result.emptyDuration).toBe(0); + expect(result.nullCost).toBeNull(); + expect(result.costCases[0]).toBeNull(); + expect(result.costCases.slice(1).map((entry) => entry.label)).toEqual([ + '실투입 전', '예산 준수', '경미한 초과', '예산 초과 위험', + ]); + expect(result.nullWorkload).toEqual([]); + expect(result.workload).toEqual(expect.arrayContaining([ + expect.objectContaining({ owner: 'Kim', count: 2, behind: 1 }), + expect.objectContaining({ owner: '미지정', count: 1, behind: 1 }), + ])); + expect(result.emptyPm.tasks.total).toBe(0); + expect(result.nonArrayPm.tasks.total).toBe(0); + expect(result.nativePmDuration).toBeGreaterThan(0); + expect(result.parentCycleTotal).toBe(2); + expect(result.strongReady).toBeGreaterThan(0); + expect(result.strongRisk).toBe('high'); + expect(result.mediumRisk).toBe('medium'); + expect(result.cyclicRisk).toBe('high'); + }); + + test('analytics renderer exposes actionable EVM, CPM, cost, workload, and PM evidence', async ({ page }) => { + await page.goto('/'); + + const rendered = await page.evaluate((tasks) => { + const api = window.ScopeWeaveAnalytics; + const calcDuration = (start, end) => { + const ms = Date.parse(end) - Date.parse(start); + if (!Number.isFinite(ms) || ms < 0) return 0; + return Math.max(1, Math.round(ms / 86400000)); + }; + const calcPlannedRatio = (date, start, end, duration) => { + if (date <= start) return 0; + if (date >= end) return 1; + return calcDuration(start, date) / Math.max(duration, 1); + }; + const buildTimeline = (start, end) => { + const rows = []; + const cursor = new Date(`${start}T00:00:00Z`); + const finish = new Date(`${end}T00:00:00Z`); + while (cursor <= finish) { + rows.push({ date: cursor.toISOString().slice(0, 10) }); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return rows; + }; + + document.getElementById('evm-panel')?.remove(); + api.render({ + pv: 0.6, + ev: 0.5, + tasks, + baseDate: '2026-08-07', + calcPlannedRatio, + calcDuration, + buildTimeline, + }); + + const panel = document.getElementById('evm-panel'); + const first = { + text: panel?.textContent || '', + hasCurve: Boolean(panel?.querySelector('.evm-scurve')), + workloadRows: panel?.querySelectorAll('.workload-table tbody tr').length || 0, + pmItems: panel?.querySelectorAll('.pm-section-list li').length || 0, + }; + + api.render({ + pv: 0.2, + ev: 0, + tasks: [{ + id: 'solo-before-cost', + duration: 1, + budget: 100, + actualCost: 0, + predecessors: '', + }], + baseDate: '2026-08-01', + calcPlannedRatio, + calcDuration, + buildTimeline, + }); + const beforeCostText = document.getElementById('evm-panel')?.textContent || ''; + + api.render({ + pv: 0.5, + ev: 0.4, + tasks: [ + { id: 'cycle-a', duration: 1, predecessors: 'cycle-b' }, + { id: 'cycle-b', duration: 1, predecessors: 'cycle-a' }, + ], + baseDate: '2026-08-01', + calcPlannedRatio, + calcDuration, + buildTimeline, + }); + const cycleText = document.getElementById('evm-panel')?.textContent || ''; + + api.render({ + pv: 0, + ev: 0, + tasks: null, + baseDate: '2026-08-01', + calcPlannedRatio, + calcDuration, + buildTimeline, + }); + const nullText = document.getElementById('evm-panel')?.textContent || ''; + + api.render({ + pv: 0, + ev: 0, + tasks: [], + baseDate: '2026-08-01', + calcPlannedRatio, + calcDuration, + buildTimeline, + }); + const emptyText = document.getElementById('evm-panel')?.textContent || ''; + + document.getElementById('evm-panel')?.remove(); + document.querySelector('.meta-grid-secondary')?.remove(); + document.querySelector('.top-panel')?.remove(); + api.render({ + pv: 0, + ev: 0, + tasks: [], + baseDate: '2026-08-01', + calcPlannedRatio, + calcDuration, + buildTimeline, + }); + + return { + ...first, + beforeCostText, + cycleText, + nullText, + emptyText, + noAnchorPanel: Boolean(document.getElementById('evm-panel')), + }; + }, ANALYTICS_TASKS); + + expect(rendered.text).toContain('PV 계획가치'); + expect(rendered.text).toContain('임계경로(CPM)'); + expect(rendered.text).toContain('BAC 총예산'); + expect(rendered.text).toContain('담당자별 워크로드'); + expect(rendered.text).toContain('PM 분석: 요구사항 · RFI/RFP · WBS 추정'); + expect(rendered.hasCurve).toBe(true); + expect(rendered.workloadRows).toBeGreaterThan(0); + expect(rendered.pmItems).toBe(6); + expect(rendered.beforeCostText).toContain('실투입 전'); + expect(rendered.cycleText).toContain('순환 의존성이 감지되어 임계경로를 계산할 수 없습니다.'); + expect(rendered.nullText).toContain('계획 착수 전'); + expect(rendered.emptyText).toContain('계획 착수 전'); + expect(rendered.noAnchorPanel).toBe(false); + }); +}); diff --git a/tests/e2e/browser-fault-boundary.spec.js b/tests/e2e/browser-fault-boundary.spec.js new file mode 100644 index 00000000..1156c291 --- /dev/null +++ b/tests/e2e/browser-fault-boundary.spec.js @@ -0,0 +1,208 @@ +import { test, expect } from './coverage-test.js'; + +const routeSeed = async (page, tasks) => { + await page.route('**/wbs.json', (route) => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(tasks), + })); +}; + +const rootTask = (overrides = {}) => ({ + __id: 'root-task', + __depth: 1, + phase: 'Coverage Phase', + plannedStartDate: '2026-08-17', + plannedEndDate: '2026-08-21', + ...overrides, +}); + +test.describe('browser fault-boundary behavior', () => { + test('closes Gantt through backdrop and Escape while trapping keyboard focus', async ({ page }) => { + await routeSeed(page, [rootTask()]); + await page.goto('/'); + + const open = page.getByRole('button', { name: '간트차트보기' }); + const modal = page.locator('#gantt-modal'); + const close = page.getByRole('button', { name: '간트 차트 닫기' }); + + await open.click(); + await expect(modal).not.toHaveClass(/hidden/); + await modal.locator('.modal-backdrop[data-close-modal="true"]').click({ position: { x: 2, y: 2 } }); + await expect(modal).toHaveClass(/hidden/); + await expect(open).toBeFocused(); + + await open.click(); + await page.keyboard.press('Escape'); + await expect(modal).toHaveClass(/hidden/); + await expect(open).toBeFocused(); + + await open.click(); + const planBar = modal.locator('.gantt-bar.plan').first(); + await expect(planBar).toBeVisible(); + await close.focus(); + await page.keyboard.press('Shift+Tab'); + await expect(planBar).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(close).toBeFocused(); + }); + + test('keeps a dirty editor open when cancellation is rejected and closes after confirmation', async ({ page }) => { + await routeSeed(page, [rootTask()]); + await page.goto('/'); + + const row = page.locator('tr[data-task-id="root-task"]'); + await row.getByRole('button', { name: '편집 - Coverage Phase' }).click(); + await page.getByTestId('editor-owner').fill('Changed Owner'); + + page.once('dialog', (dialog) => dialog.dismiss()); + await page.keyboard.press('Escape'); + await expect(page.locator('.editor-panel')).toBeVisible(); + await expect(page.getByTestId('editor-owner')).toHaveValue('Changed Owner'); + + page.once('dialog', (dialog) => dialog.accept()); + await page.keyboard.press('Escape'); + await expect(page.locator('.editor-panel')).toHaveCount(0); + }); + + test('explains the leaf-depth boundary and restores useful focus after deleting the final task', async ({ page }) => { + await routeSeed(page, [ + rootTask(), + { + __id: 'activity-task', + __parentId: 'root-task', + __depth: 2, + phase: 'Coverage Phase', + activity: 'Coverage Activity', + }, + { + __id: 'leaf-task', + __parentId: 'activity-task', + __depth: 3, + phase: 'Coverage Phase', + activity: 'Coverage Activity', + task: 'Coverage Leaf', + }, + ]); + await page.goto('/'); + + const leafAdd = page.locator('tr[data-task-id="leaf-task"]').getByRole('button', { name: '하위 추가 - Coverage Leaf' }); + await expect(leafAdd).toHaveAttribute('aria-disabled', 'true'); + await leafAdd.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#toast')).toContainText('최대 3단계까지만 추가할 수 있습니다'); + + const rootDelete = page.locator('tr[data-task-id="root-task"]').getByRole('button', { name: '삭제 - Coverage Phase' }); + page.once('dialog', (dialog) => dialog.accept()); + await rootDelete.click(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(0); + await expect(page.getByRole('button', { name: '최상위 작업 추가' }).last()).toBeFocused(); + }); + + test('connects a writable JSON file and records the exported plan', async ({ page }) => { + await page.addInitScript(() => { + window.__scopeweavePickerWrites = []; + window.__scopeweavePickerClosed = false; + Object.defineProperty(window, 'showSaveFilePicker', { + configurable: true, + value: async () => ({ + async createWritable() { + return { + async write(value) { window.__scopeweavePickerWrites.push(value); }, + async close() { window.__scopeweavePickerClosed = true; }, + }; + }, + }), + }); + }); + await routeSeed(page, [rootTask()]); + await page.goto('/'); + + await page.getByRole('button', { name: /wbs\.json 자동저장 연결/ }).click(); + await expect(page.locator('#sync-status')).toContainText('연결된 wbs.json 파일'); + await expect(page.locator('#toast')).toContainText('자동저장 연결이 완료되었습니다'); + await expect.poll(() => page.evaluate(() => window.__scopeweavePickerWrites.length)).toBeGreaterThan(0); + expect(await page.evaluate(() => window.__scopeweavePickerClosed)).toBe(true); + const exported = JSON.parse(await page.evaluate(() => window.__scopeweavePickerWrites.at(-1))); + expect(exported[0].phase).toBe('Coverage Phase'); + }); + + test('treats file-picker cancellation as cancellation rather than a product failure', async ({ page }) => { + await page.addInitScript(() => { + window.__scopeweavePickerAttempted = false; + Object.defineProperty(window, 'showSaveFilePicker', { + configurable: true, + value: async () => { + window.__scopeweavePickerAttempted = true; + throw new DOMException('cancelled', 'AbortError'); + }, + }); + }); + await routeSeed(page, [rootTask()]); + await page.goto('/'); + + await page.getByRole('button', { name: /wbs\.json 자동저장 연결/ }).click(); + await expect.poll(() => page.evaluate(() => window.__scopeweavePickerAttempted)).toBe(true); + await expect(page.locator('#toast')).not.toContainText('wbs.json 연결에 실패했습니다'); + await expect(page.locator('#sync-status')).toContainText('브라우저 로컬 자동저장'); + }); + + test('surfaces a non-cancellation file-picker failure without pretending sync succeeded', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, 'showSaveFilePicker', { + configurable: true, + value: async () => { throw new Error('forced picker failure'); }, + }); + }); + await routeSeed(page, [rootTask()]); + await page.goto('/'); + + await page.getByRole('button', { name: /wbs\.json 자동저장 연결/ }).click(); + await expect(page.locator('#toast')).toContainText('wbs.json 연결에 실패했습니다'); + await expect(page.locator('#sync-status')).toContainText('브라우저 로컬 자동저장'); + }); + + test('rejects oversized and malformed CSV imports without replacing the current plan', async ({ page }) => { + await routeSeed(page, []); + await page.goto('/'); + + const input = page.locator('#csv-file-input'); + await input.setInputFiles({ + name: 'too-large.csv', + mimeType: 'text/csv', + buffer: Buffer.alloc(5 * 1024 * 1024 + 1, 0x41), + }); + await expect(page.locator('#toast')).toContainText('5MB를 초과할 수 없습니다'); + + await input.setInputFiles({ + name: 'malformed.csv', + mimeType: 'text/csv', + buffer: Buffer.from('foo,bar\nvalue,other', 'utf8'), + }); + await expect(page.locator('#toast')).toContainText('필수 컬럼이 없습니다'); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(0); + }); + + test('honors CSV replacement cancellation and clears the chooser for a safe retry', async ({ page }) => { + await routeSeed(page, [rootTask()]); + await page.goto('/'); + + const headers = [ + '단계', 'Activity', 'Task', '대분류', '중분류', '산출물', '담당자', '지원팀', + '실적진척상태', '계획시작일', '계획종료일', '실적시작일', '실적종료일', + ]; + const replacement = [ + 'Replacement Phase', '', '', '', '', '', '', '', '미착수(0%)', + '2026-08-20', '2026-08-21', '', '', + ]; + page.once('dialog', (dialog) => dialog.dismiss()); + await page.locator('#csv-file-input').setInputFiles({ + name: 'replacement.csv', + mimeType: 'text/csv', + buffer: Buffer.from(`${headers.join(',')}\n${replacement.join(',')}`, 'utf8'), + }); + + await expect(page.locator('tr[data-task-id="root-task"]')).toBeVisible(); + await expect(page.getByText('Replacement Phase', { exact: true })).toHaveCount(0); + await expect(page.locator('#csv-file-input')).toHaveValue(''); + }); +}); diff --git a/tests/e2e/browser-invariant-boundaries.spec.js b/tests/e2e/browser-invariant-boundaries.spec.js new file mode 100644 index 00000000..d8e1b0e6 --- /dev/null +++ b/tests/e2e/browser-invariant-boundaries.spec.js @@ -0,0 +1,110 @@ +import { readFileSync } from 'node:fs'; +import { test, expect } from './coverage-test.js'; + +const APP_SOURCE = readFileSync(new URL('../../app.js', import.meta.url), 'utf8'); +const APP_BOOTSTRAP_LINE = APP_SOURCE + .split(/\r?\n/) + .findIndex((line) => line.trim() === 'bootstrap();'); + +if (APP_BOOTSTRAP_LINE < 0) { + throw new Error('app.js bootstrap call was not found'); +} + +test.describe('browser invariant boundaries', () => { + test('validates a prospective editor registration through the real input path', async ({ page }) => { + const coverageAlreadyActive = process.env.SCOPEWEAVE_BROWSER_COVERAGE === '1'; + const cdp = await page.context().newCDPSession(page); + let breakpointId; + let localCoverageActive = false; + + try { + // The coverage fixture already establishes V8's script-tracking path for + // coverage runs. The required non-coverage browser gate must establish + // the same pre-navigation runtime condition explicitly; otherwise the + // pending URL breakpoint can race app.js script registration and the + // probe is never installed even though the production page loads. + if (!coverageAlreadyActive) { + await page.coverage.startJSCoverage({ resetOnNavigation: false }); + localCoverageActive = true; + } + + await cdp.send('Debugger.enable'); + const breakpoint = await cdp.send('Debugger.setBreakpointByUrl', { + urlRegex: '/app\\.js$', + lineNumber: APP_BOOTSTRAP_LINE, + condition: String.raw`(() => { + EDITABLE_FIELDS.push('futureField'); + const probe = { + zeroDurationProgress: calculatePlannedProgressRatio( + '2026-08-20', + '2026-08-19', + '2026-08-21', + 0, + ), + missingDescendant: getLastDescendantId('missing-task-id'), + malformedEndDate: getPlannedEndDateValue(null), + escapedMarkup: escapeHtml(''), + extensionTestId: toKebab('futureField_name'), + noHandleWrite: 'pending', + }; + globalThis.__scopeweaveInvariantProbe = probe; + Promise.resolve(writeJsonSyncFile()).then( + () => { probe.noHandleWrite = 'resolved'; }, + () => { probe.noHandleWrite = 'rejected'; }, + ); + return false; + })()`, + }); + breakpointId = breakpoint.breakpointId; + + await page.goto('/'); + await expect.poll(() => page.evaluate( + () => globalThis.__scopeweaveInvariantProbe?.noHandleWrite ?? null, + )).toBe('resolved'); + + await page.getByRole('button', { name: '최상위 작업 추가' }).click(); + await page.evaluate(() => { + const grid = document.querySelector('form[data-editor-form="true"] .editor-grid'); + if (!grid) { + throw new Error('editor grid not found'); + } + const input = document.createElement('input'); + input.type = 'text'; + input.dataset.editorField = 'futureField'; + input.setAttribute('aria-label', 'Future field'); + grid.appendChild(input); + }); + + const futureField = page.getByRole('textbox', { name: 'Future field' }); + await futureField.fill(''); + await futureField.dispatchEvent('change'); + + await expect(page.locator('#editor-errors')).toContainText( + 'futureField 항목에는 HTML 태그 문자를 사용할 수 없습니다.', + ); + await expect(futureField).toHaveAttribute('aria-invalid', 'true'); + await expect(futureField).toHaveAttribute('aria-describedby', 'editor-errors'); + + const result = await page.evaluate(() => globalThis.__scopeweaveInvariantProbe); + expect(result).toEqual({ + zeroDurationProgress: 1, + missingDescendant: 'missing-task-id', + malformedEndDate: '', + escapedMarkup: '<script>"'&</script>', + extensionTestId: 'future-field-name', + noHandleWrite: 'resolved', + }); + } finally { + if (breakpointId) { + await cdp.send('Debugger.removeBreakpoint', { breakpointId }).catch(() => {}); + } + await page.evaluate(() => { + delete globalThis.__scopeweaveInvariantProbe; + }).catch(() => {}); + await cdp.detach().catch(() => {}); + if (localCoverageActive) { + await page.coverage.stopJSCoverage().catch(() => {}); + } + } + }); +}); diff --git a/tests/e2e/browser-residual-behavior.spec.js b/tests/e2e/browser-residual-behavior.spec.js new file mode 100644 index 00000000..1b173c25 --- /dev/null +++ b/tests/e2e/browser-residual-behavior.spec.js @@ -0,0 +1,204 @@ +import { test, expect } from './coverage-test.js'; + +test.describe('browser residual production behavior', () => { + test('fails safe when direct JSON file sync is unavailable', async ({ page }) => { + await page.addInitScript(() => { + // Chromium does not normally expose this API, but keep the regression + // deterministic if a future browser/runtime starts doing so. + try { delete window.showSaveFilePicker; } catch { window.showSaveFilePicker = undefined; } + }); + await page.goto('/'); + + const connect = page.getByRole('button', { name: /wbs\.json/ }); + await expect(connect).toHaveAttribute('aria-disabled', 'true'); + await expect(connect).toHaveAttribute('title', /지원하지 않습니다/); + await connect.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#toast')).toContainText('지원하지 않습니다'); + }); + + test('normalizes tampered explicit seed depths through the shipped three-level contract', async ({ page }) => { + await page.route('**/wbs.json', (route) => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify([ + { __id: 'phase-invalid-depth', __depth: '9', phase: 'Tampered Phase' }, + { __id: 'activity-invalid-depth', __depth: '0', activity: 'Tampered Activity' }, + { __id: 'task-invalid-depth', __depth: 'not-a-number', task: 'Tampered Task' }, + { __id: 'valid-explicit-depth', __depth: '2', activity: 'Explicit Activity' }, + ]), + })); + await page.goto('/'); + + await expect(page.locator('tr[data-task-id="phase-invalid-depth"]')).toHaveClass(/depth-1/); + await expect(page.locator('tr[data-task-id="activity-invalid-depth"]')).toHaveClass(/depth-2/); + await expect(page.locator('tr[data-task-id="task-invalid-depth"]')).toHaveClass(/depth-3/); + await expect(page.locator('tr[data-task-id="valid-explicit-depth"]')).toHaveClass(/depth-2/); + }); + + test('collapses nested work and restores descendant editing without losing hierarchy', async ({ page }) => { + await page.route('**/wbs.json', (route) => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify([ + { __id: 'phase-a', __depth: 1, phase: 'Phase A' }, + { __id: 'activity-a', __parentId: 'phase-a', __depth: 2, phase: 'Phase A', activity: 'Activity A' }, + { __id: 'task-a', __parentId: 'activity-a', __depth: 3, phase: 'Phase A', activity: 'Activity A', task: 'Task A' }, + ]), + })); + await page.goto('/'); + + const phaseRow = page.locator('tr[data-task-id="phase-a"]'); + const activityRow = page.locator('tr[data-task-id="activity-a"]'); + const taskRow = page.locator('tr[data-task-id="task-a"]'); + await expect(phaseRow).toBeVisible(); + await expect(activityRow).toBeVisible(); + await expect(taskRow).toBeVisible(); + + await phaseRow.getByRole('button', { name: /접기/ }).click(); + await expect(activityRow).toHaveCount(0); + await expect(taskRow).toHaveCount(0); + await expect(phaseRow.getByRole('button', { name: /펼치기/ })).toHaveAttribute('aria-expanded', 'false'); + + await phaseRow.getByRole('button', { name: /펼치기/ }).click(); + await expect(activityRow).toBeVisible(); + await expect(taskRow).toBeVisible(); + await expect(phaseRow.getByRole('button', { name: /접기/ })).toHaveAttribute('aria-expanded', 'true'); + + await taskRow.locator('td').nth(3).click(); + await expect(page.locator('.editor-panel')).toBeVisible(); + await expect(page.getByTestId('editor-task')).toHaveValue('Task A'); + await page.getByRole('button', { name: '취소', exact: true }).click(); + await expect(page.locator('.editor-panel')).toHaveCount(0); + await expect(taskRow).toBeVisible(); + }); + + test('renders warning badges through the public browser test seam', async ({ page }) => { + await page.goto('/'); + + const result = await page.evaluate(() => { + const emptyWarning = window.createTextCellContent('', '필수값 경고'); + const textWarning = window.createTextCellContent('값', '범위 경고'); + return { + emptyWarning: emptyWarning.textContent, + textWarning: textWarning.textContent, + nullValidation: window.validateDraft(null, 1), + }; + }); + + expect(result.emptyWarning).toBe('필수값 경고'); + expect(result.textWarning).toContain('값'); + expect(result.textWarning).toContain('범위 경고'); + expect(result.nullValidation).toEqual([]); + }); + + test('keeps empty-plan actions useful and bounded instead of silently doing nothing', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'Empty residual plan', + baseDate: '2026-08-19', + tasks: [], + })); + }); + await page.goto('/'); + + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(0); + const exportButton = page.getByRole('button', { name: 'CSV 내보내기' }); + await expect(exportButton).toHaveAttribute('aria-disabled', 'true'); + await expect(exportButton).toHaveAttribute('title', /내보낼 작업이 없습니다/); + await exportButton.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#toast')).toContainText('내보낼 작업이 없습니다'); + + const ganttButton = page.getByRole('button', { name: '간트차트보기' }); + await expect(ganttButton).toHaveAttribute('aria-disabled', 'true'); + await expect(ganttButton).toHaveAttribute('title', /표시할 작업이 없습니다/); + await ganttButton.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#toast')).toContainText('간트 차트로 표시할 작업이 없습니다'); + + const emptyState = page.locator('.table-empty'); + await emptyState.getByRole('button', { name: '최상위 작업 추가' }).click(); + await expect(page.locator('.editor-panel')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator('.editor-panel')).toHaveCount(0); + await expect(emptyState).toBeVisible(); + + const chooserPromise = page.waitForEvent('filechooser'); + await emptyState.getByRole('button', { name: 'CSV 가져오기' }).click(); + const chooser = await chooserPromise; + expect(chooser.isMultiple()).toBe(false); + }); + + test('normalizes oversized project metadata and an emptied base date through the visible inputs', async ({ page }) => { + await page.goto('/'); + const longName = 'P'.repeat(121); + + await page.getByTestId('project-name-input').evaluate((input, value) => { + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + }, longName); + await expect(page.getByTestId('project-name-input')).toHaveValue('P'.repeat(120)); + await expect(page).toHaveTitle(`${'P'.repeat(120)} - ScopeWeave Planner`); + + const baseDate = page.getByTestId('base-date-input'); + await baseDate.evaluate((input) => { + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + await expect(baseDate).toHaveValue(/^\d{4}-\d{2}-\d{2}$/); + }); + + test('renders tampered persisted date ranges without non-finite summary progress', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'Corrupt date recovery', + baseDate: '2026-08-21', + tasks: [{ + id: 'invalid-date-range', + depth: 1, + phase: 'Corrupt imported phase', + plannedStartDate: '2026-08-00', + plannedEndDate: '2026-08-99', + actualProgressStatus: '미착수(0%)', + }], + })); + }); + await page.goto('/'); + + await expect(page.locator('tr[data-task-id="invalid-date-range"]')).toBeVisible(); + await expect(page.getByTestId('base-date-input')).toHaveValue('2026-08-21'); + await expect(page.locator('#summary-total-days')).toHaveText('0일'); + await expect(page.locator('#summary-planned-progress')).toHaveText('0.00%'); + await expect(page.locator('#summary-actual-progress')).not.toContainText('NaN'); + }); + + test('returns focus when the Gantt dialog closes and isolates persistence failures', async ({ page }) => { + await page.route('**/wbs.json', (route) => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify([{ __id: 'gantt-task', __depth: 1, phase: 'Gantt Phase' }]), + })); + await page.goto('/'); + + const openGantt = page.getByRole('button', { name: '간트차트보기' }); + await openGantt.click(); + await expect(page.locator('#gantt-modal')).not.toHaveClass(/hidden/); + await page.getByRole('button', { name: '간트 차트 닫기' }).click(); + await expect(page.locator('#gantt-modal')).toHaveClass(/hidden/); + await expect(openGantt).toBeFocused(); + + await page.evaluate(() => { + window.__scopeweaveOriginalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = () => { throw new Error('forced quota'); }; + }); + try { + const projectName = page.getByTestId('project-name-input'); + await projectName.fill('Persistence failure regression'); + await projectName.blur(); + await expect(page.locator('#toast')).toContainText('저장하지 못했습니다'); + } finally { + await page.evaluate(() => { + Storage.prototype.setItem = window.__scopeweaveOriginalSetItem; + delete window.__scopeweaveOriginalSetItem; + }); + } + }); +}); \ No newline at end of file diff --git a/tests/e2e/cloud-fault-boundary.spec.js b/tests/e2e/cloud-fault-boundary.spec.js new file mode 100644 index 00000000..9c44ae95 --- /dev/null +++ b/tests/e2e/cloud-fault-boundary.spec.js @@ -0,0 +1,292 @@ +// Buyer-visible SaaS failure handling on an isolated in-memory API server. +import { test, expect } from './coverage-test.js'; +import { spawn } from 'node:child_process'; + +const PORT = 8833; +const BASE = `http://127.0.0.1:${PORT}`; +let server; +let ownerToken; +let ownerOrgId; +let projectId; + +async function api(path, { method = 'GET', body, tok = ownerToken } = {}) { + const res = await fetch(`${BASE}${path}`, { + method, + headers: { + 'content-type': 'application/json', + ...(tok ? { authorization: `Bearer ${tok}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + return { status: res.status, ok: res.ok, data }; +} + +async function waitForServer() { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + const response = await fetch(`${BASE}/api/health`); + if (response.ok) return; + } catch { /* server is still starting */ } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error('ScopeWeave cloud fault-boundary server did not become ready'); +} + +async function loginAndOpen(page) { + await page.goto(`${BASE}/`); + await page.evaluate(({ token, id }) => { + localStorage.clear(); + localStorage.setItem('scopeweave:token', token); + localStorage.setItem('scopeweave:project', String(id)); + }, { token: ownerToken, id: projectId }); + await page.reload(); + await page.waitForSelector('#cloud-auth select'); + await page.waitForSelector('#task-table-body tr[data-task-id]'); +} + +async function replaceProject(patch = {}) { + const current = await api(`/api/projects/${projectId}`); + if (!current.ok) throw new Error(`project read failed (${current.status})`); + const updated = await api(`/api/projects/${projectId}`, { + method: 'PUT', + body: { + name: current.data.name, + baseDate: current.data.baseDate, + tasks: current.data.tasks, + version: current.data.version, + ...patch, + }, + }); + if (!updated.ok) throw new Error(`project update failed (${updated.status})`); + return updated.data; +} + +test.beforeAll(async () => { + server = spawn(process.execPath, ['server/server.mjs'], { + env: { + ...process.env, + SCOPEWEAVE_DB: ':memory:', + SCOPEWEAVE_JWT_SECRET: '0123456789abcdef0123456789abcdef', + PORT: String(PORT), + }, + stdio: 'ignore', + }); + await waitForServer(); + + const signup = await api('/api/auth/signup', { + method: 'POST', + tok: '', + body: { email: 'fault-owner@scopeweave.test', password: 'password123', name: 'Fault Owner' }, + }); + if (!signup.ok) throw new Error(`owner signup failed (${signup.status})`); + ownerToken = signup.data.token; + + const me = await api('/api/me'); + ownerOrgId = me.data.orgs[0].id; + const created = await api('/api/projects', { + method: 'POST', + body: { name: 'Fault Boundary Project', orgId: ownerOrgId }, + }); + if (!created.ok) throw new Error(`project create failed (${created.status})`); + projectId = created.data.id; + await replaceProject({ + baseDate: '2026-08-19', + tasks: [{ + id: 'fault-task', + parentId: null, + depth: 1, + expanded: true, + phase: 'Fault Phase', + task: 'Fault deliverable', + owner: 'Fault Owner', + plannedStartDate: '2026-08-18', + plannedEndDate: '2026-08-20', + actualProgressStatus: '진행중(50%)', + }], + }); +}); + +test.afterAll(() => { server?.kill(); }); + +test('expired share links fail closed and fall back to the signed-out planner', async ({ page }) => { + await page.goto(`${BASE}/?share=missingShareToken1`); + await expect(page.locator('#toast')).toContainText('공유 링크가 만료되었거나 철회되었습니다'); + await expect(page.locator('#cloud-auth button')).toContainText('클라우드 로그인'); +}); + +test('stale credentials are cleared instead of leaving a misleading authenticated shell', async ({ page }) => { + await page.goto(`${BASE}/`); + await page.evaluate((id) => { + localStorage.clear(); + localStorage.setItem('scopeweave:token', 'stale-credential-value'); + localStorage.setItem('scopeweave:project', String(id)); + }, projectId); + await page.reload(); + + await expect(page.locator('#cloud-auth button')).toContainText('클라우드 로그인'); + await expect.poll(() => page.evaluate(() => localStorage.getItem('scopeweave:token'))).toBeNull(); + await expect(page.evaluate(() => localStorage.getItem('scopeweave:project'))).resolves.toBeNull(); +}); + +test('project-list and notification outages keep authenticated onboarding usable', async ({ page }) => { + await page.goto(`${BASE}/`); + await page.evaluate((token) => { + localStorage.clear(); + localStorage.setItem('scopeweave:token', token); + }, ownerToken); + await page.route(`${BASE}/api/projects`, (route) => route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'projects unavailable' }), + })); + await page.route(`${BASE}/api/notifications`, (route) => route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'notifications unavailable' }), + })); + await page.reload(); + + await expect(page.getByRole('button', { name: '✨ 샘플로 시작' })).toBeVisible(); + await expect(page.locator('#cloud-auth select')).toContainText('프로젝트 없음'); +}); + +test('optimistic-concurrency conflict reloads the server winner when realtime delivery is unavailable', async ({ page }) => { + await page.route(`**/api/projects/${projectId}/stream**`, (route) => route.abort()); + await loginAndOpen(page); + await replaceProject({ name: 'Server Winner' }); + + await page.locator('#project-name').fill('Stale Client Edit'); + await expect(page.locator('#toast')).toContainText('다른 사용자가 먼저 저장하여 최신본을 불러왔습니다', { timeout: 5000 }); + await expect(page.locator('#project-name')).toHaveValue('Server Winner'); + await expect.poll(async () => (await api(`/api/projects/${projectId}`)).data.name).toBe('Server Winner'); +}); + +test('cloud write failure preserves the local edit and tells the buyer what happened', async ({ page }) => { + await loginAndOpen(page); + await page.route(`${BASE}/api/projects/${projectId}`, async (route) => { + if (route.request().method() === 'PUT') { + await route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'forced cloud write failure' }), + }); + return; + } + await route.continue(); + }); + + await page.locator('#project-name').fill('Locally Preserved Edit'); + await expect(page.locator('#toast')).toContainText('클라우드 저장 실패 — 로컬에는 저장되었습니다', { timeout: 5000 }); + await expect(page.locator('#project-name')).toHaveValue('Locally Preserved Edit'); +}); + +test('duplicate cancellation and logout are safe no-op and session-teardown paths', async ({ page }) => { + await loginAndOpen(page); + const before = await api('/api/projects'); + page.once('dialog', (dialog) => dialog.dismiss()); + await page.getByRole('button', { name: '복제', exact: true }).click(); + const after = await api('/api/projects'); + expect(after.data.projects.length).toBe(before.data.projects.length); + + await page.getByRole('button', { name: '로그아웃', exact: true }).click(); + await expect(page.locator('#cloud-auth button')).toContainText('클라우드 로그인'); + expect(await page.evaluate(() => ({ + token: localStorage.getItem('scopeweave:token'), + project: localStorage.getItem('scopeweave:project'), + }))).toEqual({ token: null, project: null }); +}); + +test('share UI falls back when clipboard access is unavailable and can revoke the link', async ({ page }) => { + await loginAndOpen(page); + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: async () => { throw new Error('clipboard denied'); } }, + }); + }); + + await page.getByRole('button', { name: '공유', exact: true }).click(); + const panel = page.locator('#share-panel'); + await expect(panel).toContainText('활성 공유 링크가 없습니다'); + + page.once('dialog', (dialog) => dialog.accept()); + await panel.getByRole('button', { name: '공유 링크 만들기' }).click(); + await expect(panel.getByRole('button', { name: '철회', exact: true })).toHaveCount(1); + + page.once('dialog', (dialog) => dialog.accept()); + await panel.getByRole('button', { name: '복사', exact: true }).click(); + await panel.getByRole('button', { name: '철회', exact: true }).click(); + await expect(panel).toContainText('활성 공유 링크가 없습니다'); +}); + +test('weekly report exposes clipboard and AI failures while restoring the action state', async ({ page }) => { + await loginAndOpen(page); + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: async () => { throw new Error('clipboard denied'); } }, + }); + }); + await page.route(`${BASE}/api/projects/${projectId}/ai/brief`, (route) => route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'forced AI failure' }), + })); + + await page.getByRole('button', { name: '주간보고', exact: true }).click(); + const panel = page.locator('#report-panel'); + await panel.getByRole('button', { name: '마크다운 복사' }).click(); + await expect(page.locator('#toast')).toContainText('복사에 실패했습니다'); + + const ai = panel.getByRole('button', { name: 'AI 요약' }); + await ai.click(); + await expect(page.locator('#toast')).toContainText('forced AI failure'); + await expect(ai).toBeEnabled(); + await expect(ai).toHaveText('AI 요약'); + await panel.getByRole('button', { name: '주간보고 닫기' }).click(); + await expect(panel).toBeHidden(); +}); + +test('MS Project import rejects empty XML and honors replacement cancellation', async ({ page }) => { + await loginAndOpen(page); + await page.getByRole('button', { name: 'MSP 가져오기', exact: true }).click(); + await page.setInputFiles('#msp-file-input', { + name: 'empty.xml', + mimeType: 'text/xml', + buffer: Buffer.from('', 'utf8'), + }); + await expect(page.locator('#toast')).toContainText('가져올 작업이 없습니다'); + + const valid = '' + + '1Cancelled replacement1' + + '2026-08-20T08:00:002026-08-21T17:00:00' + + ''; + page.once('dialog', (dialog) => dialog.dismiss()); + await page.setInputFiles('#msp-file-input', { + name: 'cancelled.xml', + mimeType: 'text/xml', + buffer: Buffer.from(valid, 'utf8'), + }); + await expect(page.locator('tr[data-task-id="fault-task"]')).toBeVisible(); + await expect(page.getByText('Cancelled replacement', { exact: true })).toHaveCount(0); +}); + +test('dashboard guard explains missing workspace context for a first-time account', async ({ page }) => { + const signup = await api('/api/auth/signup', { + method: 'POST', + tok: '', + body: { email: 'fresh-dashboard@scopeweave.test', password: 'password123', name: 'Fresh User' }, + }); + expect(signup.ok).toBe(true); + await page.goto(`${BASE}/`); + await page.evaluate((token) => { + localStorage.clear(); + localStorage.setItem('scopeweave:token', token); + }, signup.data.token); + await page.reload(); + await page.waitForSelector('#cloud-auth button:has-text("대시보드")'); + + await page.getByRole('button', { name: '대시보드', exact: true }).click(); + await expect(page.locator('#toast')).toContainText('워크스페이스를 먼저 선택하세요'); +}); diff --git a/tests/e2e/cloud-residual-behavior.spec.js b/tests/e2e/cloud-residual-behavior.spec.js new file mode 100644 index 00000000..f87aa837 --- /dev/null +++ b/tests/e2e/cloud-residual-behavior.spec.js @@ -0,0 +1,259 @@ +// Residual SaaS UI behavior coverage. This suite owns its API server so it can +// exercise buyer-visible cloud workflows without sharing mutable state with the +// primary cloud.spec.js fixture. +import { test, expect } from './coverage-test.js'; +import { spawn } from 'node:child_process'; + +const PORT = 8832; +const BASE = `http://127.0.0.1:${PORT}`; +let server; +let ownerToken; +let ownerOrgId; +let projectId; + +async function api(path, { method = 'GET', body, tok = ownerToken } = {}) { + const res = await fetch(`${BASE}${path}`, { + method, + headers: { + 'content-type': 'application/json', + ...(tok ? { authorization: `Bearer ${tok}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + return { status: res.status, ok: res.ok, data }; +} + +async function waitForServer() { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + const response = await fetch(`${BASE}/api/health`); + if (response.ok) return; + } catch { /* server is still starting */ } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error('ScopeWeave residual cloud test server did not become ready'); +} + +async function loginAndOpen(page, token = ownerToken, id = projectId) { + await page.goto(`${BASE}/`); + await page.evaluate(({ authToken, project }) => { + localStorage.clear(); + localStorage.setItem('scopeweave:token', authToken); + localStorage.setItem('scopeweave:project', String(project)); + }, { authToken: token, project: id }); + await page.reload(); + await page.waitForSelector('#cloud-auth select'); + await page.waitForSelector('#task-table-body tr[data-task-id]'); +} + +async function closeCloudModal(page, panelSelector, accessibleName) { + const panel = page.locator(panelSelector); + const close = panel.getByRole('button', { name: accessibleName }); + await expect(close).toHaveCount(1); + await close.click(); + await expect(panel).toBeHidden(); +} + +test.beforeAll(async () => { + server = spawn(process.execPath, ['server/server.mjs'], { + env: { + ...process.env, + SCOPEWEAVE_DB: ':memory:', + SCOPEWEAVE_JWT_SECRET: '0123456789abcdef0123456789abcdef', + PORT: String(PORT), + }, + stdio: 'ignore', + }); + await waitForServer(); + + const signup = await api('/api/auth/signup', { + method: 'POST', + tok: '', + body: { email: 'residual-owner@scopeweave.test', password: 'password123', name: 'Residual Owner' }, + }); + if (!signup.ok) throw new Error(`owner signup failed (${signup.status})`); + ownerToken = signup.data.token; + + const me = await api('/api/me'); + ownerOrgId = me.data.orgs[0].id; + const created = await api('/api/projects', { + method: 'POST', + body: { name: 'Residual Coverage Project', orgId: ownerOrgId }, + }); + projectId = created.data.id; + const seeded = await api(`/api/projects/${projectId}`, { + method: 'PUT', + body: { + name: 'Residual Coverage Project', + baseDate: '2026-08-19', + version: created.data.version, + tasks: [{ + id: 'residual-task', + parentId: null, + depth: 1, + expanded: true, + phase: 'Residual Phase', + task: 'Residual deliverable', + owner: 'Residual Owner', + plannedStartDate: '2026-08-18', + plannedEndDate: '2026-08-20', + actualProgressStatus: '진행중(50%)', + }], + }, + }); + if (!seeded.ok) throw new Error(`project seed failed (${seeded.status})`); +}); + +test.afterAll(() => { server?.kill(); }); + +test('task-bound attachments and comments preserve visible project context through CRUD', async ({ page }) => { + await loginAndOpen(page); + + await page.click('#cloud-auth button:has-text("산출물")'); + const attachments = page.locator('#attachments-panel'); + await attachments.locator('select.cloud-select').selectOption({ label: 'Residual deliverable' }); + await page.setInputFiles('#attachment-file-input', { + name: 'residual-evidence.txt', + mimeType: 'text/plain', + buffer: Buffer.from('ScopeWeave residual evidence', 'utf8'), + }); + await attachments.getByRole('button', { name: '업로드', exact: true }).click(); + await expect(attachments.locator('.team-list')).toContainText('residual-evidence.txt'); + await expect(attachments.locator('.team-list')).toContainText('[Residual deliverable]'); + await expect(attachments.getByRole('button', { name: '보기', exact: true })).toHaveCount(1); + await attachments.getByRole('button', { name: '삭제', exact: true }).click(); + await expect(attachments).toContainText('첨부된 산출물이 없습니다.'); + + await closeCloudModal(page, '#attachments-panel', '산출물 닫기'); + await page.click('#cloud-auth button:has-text("코멘트")'); + const comments = page.locator('#comments-panel'); + await comments.locator('select.cloud-select').selectOption({ label: 'Residual deliverable' }); + await comments.locator('input[type="text"]').fill('Task-bound residual comment'); + await comments.getByRole('button', { name: '등록', exact: true }).click(); + await expect(comments.locator('.team-list')).toContainText('[Residual deliverable]'); + await expect(comments.locator('.team-list')).toContainText('Task-bound residual comment'); + await comments.getByRole('button', { name: '삭제', exact: true }).click(); + await expect(comments).toContainText('코멘트가 없습니다.'); +}); + +test('baseline comparison renders moved, added, and deleted buyer-visible schedule evidence', async ({ page }) => { + await loginAndOpen(page); + page.once('dialog', (dialog) => dialog.accept('Residual baseline')); + await page.click('#cloud-auth button:has-text("기준선")'); + const baselinePanel = page.locator('#baseline-panel'); + await baselinePanel.getByRole('button', { name: '현재 계획을 기준선으로 저장' }).click(); + await expect(baselinePanel).toContainText('Residual baseline'); + + const current = await api(`/api/projects/${projectId}`); + const changed = await api(`/api/projects/${projectId}`, { + method: 'PUT', + body: { + name: current.data.name, + baseDate: current.data.baseDate, + version: current.data.version, + tasks: [{ + ...current.data.tasks[0], + plannedEndDate: '2026-08-25', + }, { + id: 'residual-added', + parentId: null, + depth: 1, + expanded: true, + phase: 'Added Phase', + task: 'Added buyer task', + plannedStartDate: '2026-08-21', + plannedEndDate: '2026-08-22', + }], + }, + }); + expect(changed.ok).toBe(true); + + await page.reload(); + await page.waitForSelector('#cloud-auth select'); + await page.click('#cloud-auth button:has-text("기준선")'); + const refreshedPanel = page.locator('#baseline-panel'); + const baselineRow = refreshedPanel.locator('.team-list li').filter({ hasText: 'Residual baseline' }); + await baselineRow.getByRole('button', { name: '비교', exact: true }).click(); + await expect(page.locator('#baseline-result')).toContainText('변경'); + await expect(page.locator('#baseline-result')).toContainText('+5일'); + await expect(page.locator('#baseline-result')).toContainText('신규'); + + await baselineRow.getByRole('button', { name: '삭제', exact: true }).click(); + await expect(page.locator('#baseline-panel')).toContainText('저장된 기준선이 없습니다.'); +}); + +test('team governance actions exercise revocation, webhook rotation, and audit export paths', async ({ page }) => { + await loginAndOpen(page); + await page.click('#cloud-auth button:has-text("팀")'); + await page.waitForSelector('#team-body'); + + let tokenSection = page.locator('#team-body .token-section').filter({ hasText: 'API 토큰' }); + await tokenSection.locator('input[type="text"]').fill('Residual PAT'); + await tokenSection.getByRole('button', { name: '토큰 생성', exact: true }).click(); + await expect(tokenSection.locator('.token-secret')).toContainText('한 번만 표시됩니다'); + + const webhookSection = page.locator('#team-body .token-section').filter({ hasText: '웹훅' }); + await webhookSection.locator('input[type="url"]').fill('https://example.com/scopeweave-residual'); + await webhookSection.getByRole('button', { name: '웹훅 추가', exact: true }).click(); + await expect(webhookSection).toContainText('https://example.com/scopeweave-residual'); + + await closeCloudModal(page, '#team-modal', '닫기'); + await page.click('#cloud-auth button:has-text("팀")'); + await page.waitForSelector('#team-body'); + + tokenSection = page.locator('#team-body .token-section').filter({ hasText: 'API 토큰' }); + const tokenRow = tokenSection.locator('.team-list li').filter({ hasText: 'Residual PAT' }); + await tokenRow.getByRole('button', { name: '폐기', exact: true }).click(); + await expect(tokenSection.locator('.team-list')).not.toContainText('Residual PAT'); + + const refreshedWebhook = page.locator('#team-body .token-section').filter({ hasText: '웹훅' }); + const webhookRow = refreshedWebhook.locator('.team-list li').filter({ hasText: 'scopeweave-residual' }); + page.on('dialog', async (dialog) => { + if (dialog.type() === 'confirm') await dialog.accept(); + else if (dialog.type() === 'prompt') await dialog.accept('acknowledged'); + }); + await webhookRow.getByRole('button', { name: '키 교체', exact: true }).click(); + await webhookRow.getByRole('button', { name: '삭제', exact: true }).click(); + await expect(page.locator('#team-body .token-section').filter({ hasText: '웹훅' })).not.toContainText('scopeweave-residual'); + + const audit = page.locator('#team-body .token-section').filter({ hasText: '감사 로그' }); + await expect(audit).toBeVisible(); + const downloadPromise = page.waitForEvent('download'); + await audit.getByRole('button', { name: 'CSV 다운로드', exact: true }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toContain('scopeweave-audit-'); +}); + +test('SSO fragment cleanup and invite auto-accept keep credentials out of the visible URL', async ({ page, context }) => { + const ssoPage = await context.newPage(); + await ssoPage.goto(`${BASE}/#token=${encodeURIComponent(ownerToken)}`); + await expect.poll(() => ssoPage.evaluate(() => localStorage.getItem('scopeweave:token'))).toBe(ownerToken); + await expect.poll(() => ssoPage.evaluate(() => location.hash)).toBe(''); + await ssoPage.close(); + + const invite = await api(`/api/orgs/${ownerOrgId}/invites`, { + method: 'POST', + body: { email: 'residual-invitee@scopeweave.test', role: 'viewer' }, + }); + expect(invite.ok).toBe(true); + const inviteeSignup = await api('/api/auth/signup', { + method: 'POST', + tok: '', + body: { email: 'residual-invitee@scopeweave.test', password: 'password123', name: 'Residual Invitee' }, + }); + expect(inviteeSignup.ok).toBe(true); + const inviteeToken = inviteeSignup.data.token; + + const invitePage = await context.newPage(); + await invitePage.addInitScript((authToken) => { + localStorage.setItem('scopeweave:token', authToken); + }, inviteeToken); + await invitePage.goto(`${BASE}/?invite=${invite.data.token}`); + await expect(invitePage.locator('#toast')).toContainText('초대를 수락했습니다.'); + await expect.poll(async () => { + const me = await api('/api/me', { tok: inviteeToken }); + return me.data.orgs.some((org) => String(org.id) === String(ownerOrgId)); + }).toBe(true); + await invitePage.close(); +}); diff --git a/tests/e2e/cloud-team-boundary.spec.js b/tests/e2e/cloud-team-boundary.spec.js new file mode 100644 index 00000000..4e0b8184 --- /dev/null +++ b/tests/e2e/cloud-team-boundary.spec.js @@ -0,0 +1,208 @@ +import { test, expect } from './coverage-test.js'; +import { spawn } from 'node:child_process'; + +const PORT = 8835; +const BASE = `http://127.0.0.1:${PORT}`; +let server; +let ownerToken; +let ownerOrgId; +let projectId; + +async function api(path, { method = 'GET', body, tok = ownerToken } = {}) { + const response = await fetch(`${BASE}${path}`, { + method, + headers: { + 'content-type': 'application/json', + ...(tok ? { authorization: `Bearer ${tok}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await response.json().catch(() => ({})); + return { status: response.status, ok: response.ok, data }; +} + +async function waitForServer() { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + const response = await fetch(`${BASE}/api/health`); + if (response.ok) return; + } catch { /* server is still starting */ } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error('ScopeWeave team-boundary server did not become ready'); +} + +async function loginAndOpen(page, token = ownerToken, id = projectId) { + await page.goto(`${BASE}/`); + await page.evaluate(({ authToken, project }) => { + localStorage.clear(); + localStorage.setItem('scopeweave:token', authToken); + localStorage.setItem('scopeweave:project', String(project)); + }, { authToken: token, project: id }); + await page.reload(); + await page.waitForSelector('#cloud-auth select'); +} + +async function openTeam(page) { + await page.click('#cloud-auth button:has-text("팀")'); + await page.waitForSelector('#team-body'); + return page.locator('#team-body'); +} + +test.beforeAll(async () => { + server = spawn(process.execPath, ['server/server.mjs'], { + env: { + ...process.env, + SCOPEWEAVE_DB: ':memory:', + SCOPEWEAVE_JWT_SECRET: 'abcdef0123456789abcdef0123456789', + PORT: String(PORT), + }, + stdio: 'ignore', + }); + await waitForServer(); + + const signup = await api('/api/auth/signup', { + method: 'POST', + tok: '', + body: { email: 'team-owner@scopeweave.test', password: 'password123', name: 'Team Owner' }, + }); + if (!signup.ok) throw new Error(`team owner signup failed (${signup.status})`); + ownerToken = signup.data.token; + const me = await api('/api/me'); + ownerOrgId = me.data.orgs[0].id; + const created = await api('/api/projects', { + method: 'POST', + body: { name: 'Team Boundary Project', orgId: ownerOrgId }, + }); + if (!created.ok) throw new Error(`team project creation failed (${created.status})`); + projectId = created.data.id; +}); + +test.afterAll(() => { server?.kill(); }); + +test('owner can rename the workspace, exercise upgrade guidance, and revoke a pending invite', async ({ page }) => { + await loginAndOpen(page); + const team = await openTeam(page); + + page.once('dialog', (dialog) => dialog.accept('Acquisition Ready Workspace')); + await team.getByRole('button', { name: '워크스페이스 이름 변경' }).click(); + await expect(page.locator('#toast')).toContainText('이름을 변경했습니다.'); + await expect.poll(async () => { + const me = await api('/api/me'); + return me.data.orgs.find((org) => String(org.id) === String(ownerOrgId))?.name; + }).toBe('Acquisition Ready Workspace'); + + await team.getByRole('button', { name: 'Pro 업그레이드' }).click(); + await expect(page.locator('#toast')).toContainText('결제 연동(Stripe 키)이 필요합니다'); + + await page.locator('#team-email').fill('pending-viewer@scopeweave.test'); + await page.locator('#team-role').selectOption('viewer'); + await page.locator('#team-invite').getByRole('button', { name: '초대', exact: true }).click(); + await expect(page.locator('#team-msg')).toContainText('초대 링크:'); + const pendingRow = page.locator('#team-body .team-list li').filter({ hasText: 'pending-viewer@scopeweave.test' }); + await expect(pendingRow).toHaveCount(1); + await pendingRow.getByRole('button', { name: '초대 취소' }).click(); + await expect(page.locator('#toast')).toContainText('초대를 취소했습니다.'); + await expect(page.locator('#team-body')).not.toContainText('pending-viewer@scopeweave.test'); +}); + +test('owner can change a member role and remove the member through the team surface', async ({ page }) => { + const invite = await api(`/api/orgs/${ownerOrgId}/invites`, { + method: 'POST', + body: { email: 'managed-member@scopeweave.test', role: 'member' }, + }); + expect(invite.ok).toBe(true); + const signup = await api('/api/auth/signup', { + method: 'POST', + tok: '', + body: { email: 'managed-member@scopeweave.test', password: 'password123', name: 'Managed Member' }, + }); + expect(signup.ok).toBe(true); + const accepted = await api(`/api/invites/${invite.data.token}/accept`, { + method: 'POST', + tok: signup.data.token, + }); + expect(accepted.ok).toBe(true); + + await loginAndOpen(page); + await openTeam(page); + const memberRow = page.locator('#team-body .team-list li').filter({ hasText: 'managed-member@scopeweave.test' }); + await expect(memberRow).toHaveCount(1); + + await memberRow.locator('select.cloud-select').selectOption('viewer'); + await expect(page.locator('#toast')).toContainText('managed-member@scopeweave.test → 뷰어'); + await memberRow.getByRole('button', { name: '제거', exact: true }).click(); + await expect(page.locator('#toast')).toContainText('managed-member@scopeweave.test 제거됨'); + await expect(memberRow).toHaveCount(0); +}); + +test('account controls change a password and preserve the current device across global logout', async ({ page }) => { + await loginAndOpen(page); + const team = await openTeam(page); + const account = team.locator('.token-section').filter({ hasText: '계정' }); + + await account.locator('input[autocomplete="current-password"]').fill('password123'); + await account.locator('input[autocomplete="new-password"]').fill('password456'); + await account.getByRole('button', { name: '비밀번호 변경' }).click(); + await expect(page.locator('#toast')).toContainText('비밀번호를 변경했습니다.'); + + const login = await api('/api/auth/login', { + method: 'POST', + tok: '', + body: { email: 'team-owner@scopeweave.test', password: 'password456' }, + }); + expect(login.ok).toBe(true); + ownerToken = login.data.token; + await page.evaluate((token) => localStorage.setItem('scopeweave:token', token), ownerToken); + + page.once('dialog', (dialog) => dialog.accept()); + await account.getByRole('button', { name: '다른 모든 기기에서 로그아웃' }).click(); + await expect(page.locator('#toast')).toContainText('다른 모든 기기에서 로그아웃했습니다.'); + ownerToken = await page.evaluate(() => localStorage.getItem('scopeweave:token')); + expect(ownerToken).toBeTruthy(); + + const restored = await api('/api/auth/change-password', { + method: 'POST', + body: { oldPassword: 'password456', newPassword: 'password123' }, + }); + expect(restored.ok).toBe(true); + const relogin = await api('/api/auth/login', { + method: 'POST', + tok: '', + body: { email: 'team-owner@scopeweave.test', password: 'password123' }, + }); + expect(relogin.ok).toBe(true); + ownerToken = relogin.data.token; +}); + +test('a throwaway owner can delete the account from the buyer-visible account controls', async ({ page }) => { + const signup = await api('/api/auth/signup', { + method: 'POST', + tok: '', + body: { email: 'delete-me@scopeweave.test', password: 'password123', name: 'Delete Me' }, + }); + expect(signup.ok).toBe(true); + const me = await api('/api/me', { tok: signup.data.token }); + const orgId = me.data.orgs[0].id; + const project = await api('/api/projects', { + method: 'POST', + tok: signup.data.token, + body: { name: 'Disposable Project', orgId }, + }); + expect(project.ok).toBe(true); + + await loginAndOpen(page, signup.data.token, project.data.id); + const team = await openTeam(page); + const account = team.locator('.token-section').filter({ hasText: '계정' }); + page.once('dialog', (dialog) => dialog.accept('password123')); + await account.getByRole('button', { name: '계정 삭제' }).click(); + await expect(page.locator('#toast')).toContainText('계정을 삭제했습니다.'); + await expect(page.locator('#cloud-auth')).toContainText('로그인'); + + const login = await api('/api/auth/login', { + method: 'POST', + tok: '', + body: { email: 'delete-me@scopeweave.test', password: 'password123' }, + }); + expect(login.status).toBe(401); +}); diff --git a/tests/e2e/cloud.spec.js b/tests/e2e/cloud.spec.js index fa18cc2e..03450d8b 100644 --- a/tests/e2e/cloud.spec.js +++ b/tests/e2e/cloud.spec.js @@ -1,7 +1,7 @@ // Cloud (SaaS) UI e2e — self-contained: spawns the Node API server itself, so // the static python webServer from playwright.config is untouched. // Run: npx playwright test tests/e2e/cloud.spec.js -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-test.js'; import { spawn } from 'node:child_process'; const PORT = 8830; @@ -147,8 +147,8 @@ test('MSP import: XML file populates the tree and saves to the cloud', async ({ await page.waitForFunction(() => document.querySelector('#task-table-body')?.textContent.includes('MSP단계')); // wait for the debounced cloud push, then confirm server state await page.waitForTimeout(1200); - const server = await api('/api/projects/1', { tok: token }); - expect(server.tasks.some((t) => t.id === 'msp-1' && t.depth === 1)).toBeTruthy(); + const serverState = await api('/api/projects/1', { tok: token }); + expect(serverState.tasks.some((t) => t.id === 'msp-1' && t.depth === 1)).toBeTruthy(); }); test('archive: project moves under the 보관됨 optgroup and restores', async ({ page }) => { @@ -160,3 +160,141 @@ test('archive: project moves under the 보관됨 optgroup and restores', async ( await page.click('#cloud-auth button:has-text("보관 해제")'); await page.waitForFunction(() => !document.querySelector('#cloud-auth select optgroup[label="보관됨"]')); }); + +test('login modal surfaces failed credentials, toggles signup mode, and authenticates', async ({ page }) => { + await page.goto(`${BASE}/`); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + await page.click('#cloud-auth button:has-text("클라우드 로그인")'); + await page.click('#cloud-toggle'); + await expect(page.locator('#cloud-modal-title')).toHaveText('계정 만들기'); + await page.click('#cloud-toggle'); + await expect(page.locator('#cloud-modal-title')).toHaveText('클라우드 로그인'); + + await page.fill('#cloud-email', 'e2e@cloud.com'); + await page.fill('#cloud-password', 'wrong-password'); + await page.click('#cloud-submit'); + await expect(page.locator('#cloud-error')).not.toHaveText(''); + + await page.fill('#cloud-password', 'password123'); + await page.click('#cloud-submit'); + await page.waitForSelector('#cloud-auth select'); + expect(await page.evaluate(() => Boolean(localStorage.getItem('scopeweave:token')))).toBeTruthy(); +}); + +test('first-time cloud user can seed the buyer-visible sample project', async ({ page }) => { + const sampleToken = (await api('/api/auth/signup', { + method: 'POST', + body: { email: 'sample@cloud.com', password: 'password123', name: 'Sample User' }, + })).token; + await page.goto(`${BASE}/`); + await page.evaluate(([t]) => { + localStorage.clear(); + localStorage.setItem('scopeweave:token', t); + }, [sampleToken]); + await page.reload(); + await page.waitForSelector('#cloud-auth button:has-text("샘플로 시작")'); + await page.click('#cloud-auth button:has-text("샘플로 시작")'); + await page.waitForFunction(() => [...document.querySelectorAll('#cloud-auth select option')] + .some((option) => option.textContent?.includes('샘플 프로젝트'))); + expect(await page.evaluate(() => Boolean(localStorage.getItem('scopeweave:project')))).toBeTruthy(); +}); + +test('new-project and search flows operate through the shipped cloud UI', async ({ page }) => { + await loginAndOpen(page); + page.once('dialog', (dialog) => dialog.accept('검색 가능한 프로젝트')); + await page.click('#cloud-auth button:has-text("+ 새 프로젝트")'); + await page.waitForFunction(() => [...document.querySelectorAll('#cloud-auth select option')] + .some((option) => option.textContent?.includes('검색 가능한 프로젝트'))); + + await page.click('#cloud-auth button:has-text("검색")'); + await page.fill('#search-panel input[type="search"]', '검색 가능한'); + await page.click('#search-panel button:has-text("검색")'); + await expect(page.locator('#search-panel')).toContainText('검색 가능한 프로젝트'); + await page.click('#search-panel button:has-text("열기")'); + await expect(page.locator('#toast')).toContainText('프로젝트를 열었습니다'); +}); + +test('team administration renders governance controls and creates bounded credentials', async ({ page }) => { + await loginAndOpen(page); + await page.click('#cloud-auth button:has-text("팀")'); + await page.waitForSelector('#team-body'); + await expect(page.locator('#team-body')).toContainText('API 토큰'); + await expect(page.locator('#team-body')).toContainText('웹훅'); + await expect(page.locator('#team-body')).toContainText('계정'); + + const tokenSection = page.locator('#team-body .token-section').filter({ hasText: 'API 토큰' }); + await tokenSection.locator('input[type="text"]').fill('E2E CI'); + await tokenSection.locator('button:has-text("토큰 생성")').click(); + await expect(tokenSection.locator('.token-secret')).toContainText('한 번만 표시됩니다'); + + await page.fill('#team-email', 'invitee@example.com'); + await page.selectOption('#team-role', 'viewer'); + await page.click('#team-invite button:has-text("초대")'); + await expect(page.locator('#team-msg')).toContainText('초대 링크:'); + + const downloadPromise = page.waitForEvent('download'); + await page.click('#team-body button:has-text("데이터 내보내기")'); + const download = await downloadPromise; + expect(download.suggestedFilename()).toContain('scopeweave-org-'); +}); + +test('sprint workflow persists methodology and renders a real burndown', async ({ page }) => { + const project = await api('/api/projects/1', { tok: token }); + await api('/api/projects/1', { + method: 'PUT', + tok: token, + body: { + tasks: [ + { + id: 's1', name: 'Sprint done', sprint: 'Sprint E2E', storyPoints: 5, + actualProgress: 100, actualEndDate: '2026-08-02', plannedStartDate: '2026-08-01', plannedEndDate: '2026-08-03', + }, + { + id: 's2', name: 'Sprint open', sprint: 'Sprint E2E', storyPoints: 8, + actualProgress: 40, plannedStartDate: '2026-08-01', plannedEndDate: '2026-08-08', + }, + ], + baseDate: project.baseDate, + version: project.version, + }, + }); + + await loginAndOpen(page); + await page.click('#cloud-auth button:has-text("스프린트")'); + const form = page.locator('#sprint-panel form.cloud-form'); + await form.locator('input[type="text"]').fill('Sprint E2E'); + await form.locator('input[type="date"]').nth(0).fill('2026-08-01'); + await form.locator('input[type="date"]').nth(1).fill('2026-08-08'); + await form.locator('button:has-text("추가")').click(); + await expect(page.locator('#sprint-panel .team-list')).toContainText('Sprint E2E'); + await expect(page.locator('#sprint-panel .cpm-summary')).toContainText('벨로시티'); + + await page.selectOption('#methodology-select', 'hybrid'); + await expect(page.locator('#methodology-select')).toHaveValue('hybrid'); + await expect.poll(async () => { + const saved = await api('/api/projects/1', { tok: token }); + return saved.methodology; + }).toBe('hybrid'); + await page.click('#sprint-panel button:has-text("번다운")'); + await expect(page.locator('#burndown-holder')).toContainText('커밋 13pt'); + await expect(page.locator('#burndown-holder svg')).toHaveCount(1); +}); + +test('share and attachment modals expose actionable empty states without hidden transport details', async ({ page }) => { + await loginAndOpen(page); + await page.click('#cloud-auth button:has-text("공유")'); + await expect(page.locator('#share-panel')).toContainText('활성 공유 링크가 없습니다.'); + + page.once('dialog', (dialog) => dialog.accept()); + await page.click('#share-panel button:has-text("공유 링크 만들기")'); + await page.waitForFunction(() => document.querySelectorAll('#share-panel .team-list li').length > 0); + await expect(page.locator('#share-panel .team-list')).toContainText('복사'); + await page.click('#share-panel button:has-text("철회")'); + await expect(page.locator('#share-panel')).toContainText('활성 공유 링크가 없습니다.'); + + await page.click('#share-panel button[aria-label="공유 닫기"]'); + await expect(page.locator('#share-modal')).toHaveClass(/hidden/); + await page.click('#cloud-auth button:has-text("산출물")'); + await expect(page.locator('#attachments-panel')).toContainText('첨부된 산출물이 없습니다.'); +}); diff --git a/tests/e2e/commercial-coverage-boundaries.spec.js b/tests/e2e/commercial-coverage-boundaries.spec.js new file mode 100644 index 00000000..7ddcb065 --- /dev/null +++ b/tests/e2e/commercial-coverage-boundaries.spec.js @@ -0,0 +1,323 @@ +import { test, expect } from './coverage-test.js'; + +const STATIC_BASE = 'http://127.0.0.1:4173'; +const TOKEN = 'coverage-token'; +const SHARE_TOKEN = 'abcdefghijklmnop'; +const INVITE_TOKEN = 'ponmlkjihgfedcba'; + +function project(id = 1, name = 'Coverage Project') { + return { + id, + name, + baseDate: '2026-08-20', + version: 1, + orgId: 7, + archived: false, + methodology: 'waterfall', + tasks: [{ + id: 'task-1', + name: 'Coverage task', + phase: 'Coverage task', + depth: 1, + plannedStartDate: '2026-08-20', + plannedEndDate: '2026-08-22', + plannedProgress: 50, + actualProgress: 20, + sprint: 'Coverage Sprint', + storyPoints: 5, + }], + }; +} + +async function primeAuth(page, { projectId = '1', token = TOKEN } = {}) { + await page.addInitScript(({ authToken, selectedProject }) => { + localStorage.clear(); + localStorage.setItem('scopeweave:token', authToken); + if (selectedProject) localStorage.setItem('scopeweave:project', selectedProject); + }, { authToken: token, selectedProject: projectId }); +} + +async function installApiMock(page, options = {}) { + const state = { + projects: options.projects ?? [project()], + sprintRows: options.sprintRows ?? [{ + id: 31, + name: 'Coverage Sprint', + startDate: '2026-08-20', + endDate: '2026-08-22', + goal: 'Exercise delete boundary', + }], + attachments: options.attachments ?? [{ + id: 41, + name: 'buyer-proof.pdf', + taskId: 'task-1', + status: 'SUCCEEDED', + }], + portfolioProjects: options.portfolioProjects ?? [], + revisions: options.revisions ?? [{ version: 1, savedAt: '2026-08-20T01:02:03Z', savedBy: 'owner@example.com' }], + createProjectFail: Boolean(options.createProjectFail), + exportMode: 'ok', + log: [], + }; + + const json = (route, body, status = 200) => route.fulfill({ + status, + contentType: 'application/json; charset=utf-8', + body: JSON.stringify(body), + }); + + await page.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + const method = request.method(); + state.log.push(`${method} ${path}${url.search}`); + + if (path.endsWith('/stream')) return route.fulfill({ status: 204, body: '' }); + if (path === '/api/projects' && method === 'GET') { + return json(route, { projects: state.projects.map(({ tasks, ...meta }) => meta) }); + } + if (path === '/api/projects' && method === 'POST') { + if (state.createProjectFail) return json(route, { error: 'project creation denied' }, 503); + const created = project(9, 'Created Project'); + state.projects.push(created); + return json(route, { id: created.id, name: created.name, version: created.version }); + } + if (path === '/api/notifications') return json(route, { notifications: [] }); + if (/^\/api\/projects\/\d+$/.test(path) && method === 'GET') { + const id = Number(path.split('/').at(-1)); + return json(route, state.projects.find((item) => Number(item.id) === id) || project(id, `Project ${id}`)); + } + if (/^\/api\/projects\/\d+$/.test(path) && method === 'PUT') return json(route, { version: 2 }); + if (/^\/api\/projects\/\d+\/seen$/.test(path)) return json(route, { ok: true }); + if (/^\/api\/projects\/\d+\/duplicate$/.test(path) && method === 'POST') { + const created = project(2, 'Duplicated buyer plan'); + state.projects.push(created); + return json(route, { id: created.id, name: created.name, version: created.version }); + } + if (/^\/api\/projects\/\d+\/ai\/brief$/.test(path)) return json(route, { analysis: 'Buyer-ready bounded analysis' }); + if (/^\/api\/projects\/\d+\/sprints$/.test(path) && method === 'GET') { + return json(route, { sprints: state.sprintRows, methodology: 'waterfall' }); + } + if (/^\/api\/projects\/\d+\/sprints\/\d+$/.test(path) && method === 'DELETE') { + state.sprintRows = []; + return json(route, { ok: true }); + } + if (/^\/api\/projects\/\d+\/attachments$/.test(path) && method === 'GET') return json(route, { attachments: state.attachments }); + if (/^\/api\/projects\/\d+\/calendar\.ics$/.test(path)) { + return route.fulfill({ status: 200, contentType: 'text/calendar', body: 'BEGIN:VCALENDAR\nEND:VCALENDAR\n' }); + } + if (/^\/api\/projects\/\d+\/revisions$/.test(path)) return json(route, { revisions: state.revisions }); + if (/^\/api\/projects\/\d+\/revisions\/\d+\/restore$/.test(path) && method === 'POST') return json(route, { version: 2 }); + if (/^\/api\/projects\/\d+\/revisions\/\d+$/.test(path)) { + return json(route, { tasks: [{ ...project().tasks[0], plannedEndDate: '2026-08-25' }] }); + } + if (/^\/api\/projects\/\d+\/baselines$/.test(path)) return json(route, { baselines: [] }); + if (path === '/api/orgs/7/portfolio') return json(route, { projects: state.portfolioProjects }); + if (path === '/api/orgs/7/members') { + return json(route, { + members: [ + { id: 70, email: 'owner@example.com', role: 'owner' }, + { id: 71, email: 'member@example.com', role: 'member' }, + ], + invites: [], + }); + } + if (path === '/api/me') return json(route, { orgs: [{ id: 7, role: 'owner' }] }); + if (path === '/api/orgs/7/billing') { + return json(route, { + plan: 'free', + planName: 'Free', + usage: { projects: state.projects.length, members: 2 }, + limits: { projects: 3, members: 5 }, + }); + } + if (path === '/api/tokens' && method === 'GET') return json(route, { tokens: [] }); + if (path === '/api/orgs/7/webhooks' && method === 'GET') return json(route, { webhooks: [] }); + if (path === '/api/orgs/7/audit') return json(route, { events: [] }); + if (path === '/api/orgs/7/invites' && method === 'POST') return json(route, { token: INVITE_TOKEN }); + if (path === '/api/orgs/7' && method === 'PATCH') return json(route, { ok: true }); + if (path === '/api/orgs/7/transfer' && method === 'POST') return json(route, { ok: true }); + if (path === '/api/orgs/7/checkout' && method === 'POST') return json(route, { mock: false, url: '/checkout-target' }); + if (path === '/api/orgs/7/export') { + if (state.exportMode === 'abort') return route.abort('failed'); + if (state.exportMode === 'forbidden') return json(route, { error: 'owner only' }, 403); + if (state.exportMode === 'error') return json(route, { error: 'temporary export failure' }, 500); + return json(route, { projects: [] }); + } + if (/^\/api\/invites\/[A-Za-z0-9_-]+\/accept$/.test(path) && method === 'POST') return json(route, { orgId: 7 }); + return json(route, { error: `unhandled mock route: ${method} ${path}` }, 404); + }); + return state; +} + +test('offline bootstrap remains functional when the optional cloud bridge is unavailable', async ({ page }) => { + await page.addInitScript(() => { + localStorage.clear(); + Object.defineProperty(window, 'ScopeWeaveCloud', { + configurable: true, + get: () => undefined, + set: () => {}, + }); + }); + await page.goto(`${STATIC_BASE}/`); + await expect(page.locator('#task-table-body tr').first()).toBeVisible(); + await expect(page.locator('#cloud-auth')).toHaveCount(0); +}); + +test('cloud API path validation fails closed before a tampered project id can escape /api', async ({ page }) => { + await primeAuth(page, { projectId: '../../outside-api' }); + const state = await installApiMock(page); + await page.goto(`${STATIC_BASE}/`); + await expect(page.locator('#task-table-body tr').first()).toBeVisible(); + expect(state.log.some((entry) => entry.includes('/outside-api'))).toBeFalsy(); +}); + +test('public share boot hydrates the planner and exposes an explicit read-only state', async ({ page }) => { + await page.route(`**/api/shared/${SHARE_TOKEN}`, (route) => route.fulfill({ + status: 200, + contentType: 'application/json; charset=utf-8', + body: JSON.stringify(project(1, 'Shared acquisition plan')), + })); + await page.goto(`${STATIC_BASE}/?share=${SHARE_TOKEN}`); + await expect(page.locator('#cloud-auth .team-role-tag')).toHaveText('읽기 전용 공유 보기'); + await expect(page.locator('#project-name')).toHaveValue('Shared acquisition plan'); + await expect(page.locator('#cloud-auth button')).toHaveCount(0); +}); + +test('commercial cloud controls execute success, denial, recovery, and empty-state boundaries', async ({ page }) => { + await primeAuth(page); + await page.addInitScript(() => { + window.__scopeweaveOpened = null; + window.open = (...args) => { window.__scopeweaveOpened = args; return null; }; + }); + const state = await installApiMock(page); + await page.goto(`${STATIC_BASE}/`); + await page.waitForSelector('#cloud-auth select'); + + await page.click('#cloud-auth button:has-text("주간보고")'); + await page.click('#report-panel button:has-text("AI 요약")'); + await expect(page.locator('#report-ai')).toContainText('Buyer-ready bounded analysis'); + await page.click('#report-panel button[aria-label="주간보고 닫기"]'); + + await page.click('#cloud-auth button:has-text("스프린트")'); + await expect(page.locator('#sprint-panel .team-list')).toContainText('Coverage Sprint'); + await page.click('#sprint-panel button:has-text("삭제")'); + await expect(page.locator('#sprint-panel .team-list')).toContainText('스프린트가 없습니다.'); + await page.click('#sprint-panel button[aria-label="스프린트 닫기"]'); + + await page.click('#cloud-auth button:has-text("산출물")'); + await page.click('#attachments-panel button:has-text("보기")'); + await expect.poll(() => page.evaluate(() => window.__scopeweaveOpened?.[0] || '')).toContain('/attachments/41/view?token='); + await page.click('#attachments-panel button[aria-label="산출물 닫기"]'); + + await page.click('#cloud-auth button:has-text("대시보드")'); + await expect(page.locator('#portfolio-panel')).toContainText('프로젝트가 없습니다.'); + await page.click('#portfolio-panel button[aria-label="대시보드 닫기"]'); + state.portfolioProjects = [{ + id: 1, + name: 'Coverage Project', + tasks: 1, + planned: 50, + actual: 20, + spi: 0.4, + status: 'delay', + label: '지연', + overdue: 1, + archived: false, + }]; + await page.click('#cloud-auth button:has-text("대시보드")'); + await page.click('#portfolio-panel button:has-text("열기")'); + await expect(page.locator('#toast')).toContainText('프로젝트를 열었습니다'); + + await page.click('#cloud-auth button:has-text("기준선")'); + await expect(page.locator('#baseline-panel')).toContainText('v1'); + const revisionItem = page.locator('#baseline-panel .team-list li').filter({ hasText: 'v1' }).first(); + await revisionItem.getByRole('button', { name: '비교' }).click(); + await expect(page.locator('#baseline-result')).not.toBeEmpty(); + page.once('dialog', (dialog) => dialog.accept()); + await revisionItem.getByRole('button', { name: '복원' }).click(); + await expect(page.locator('#toast')).toContainText('복원했습니다'); + + state.revisions = []; + await page.click('#cloud-auth button:has-text("기준선")'); + await expect(page.locator('#baseline-panel')).toContainText('저장 이력이 없습니다.'); + const downloadPromise = page.waitForEvent('download'); + await page.click('#baseline-panel button:has-text("캘린더 내보내기")'); + const calendarDownload = await downloadPromise; + expect(calendarDownload.suggestedFilename()).toBe('scopeweave-1.ics'); + await page.click('#baseline-panel button[aria-label="기준선 닫기"]'); + + await page.click('#cloud-auth button:has-text("팀")'); + await page.fill('#team-email', 'new-member@example.com'); + await page.selectOption('#team-role', 'viewer'); + await page.click('#team-invite button:has-text("초대")'); + await expect(page.locator('#team-msg')).toContainText(`?invite=${INVITE_TOKEN}`); + + page.once('dialog', (dialog) => dialog.accept('Renamed Workspace')); + await page.click('#team-body button:has-text("워크스페이스 이름 변경")'); + await expect(page.locator('#toast')).toContainText('이름을 변경했습니다'); + await expect(page.locator('#team-body li').filter({ hasText: 'member@example.com' })).toHaveCount(1); + + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('#team-body li').filter({ hasText: 'member@example.com' }).getByRole('button', { name: '소유권 이전' }).click(); + await expect(page.locator('#toast')).toContainText('소유권을 이전했습니다'); + + state.exportMode = 'forbidden'; + await page.click('#team-body button:has-text("데이터 내보내기")'); + await expect(page.locator('#toast')).toContainText('소유자만 데이터를 내보낼 수 있습니다'); + state.exportMode = 'error'; + await page.click('#team-body button:has-text("데이터 내보내기")'); + await expect(page.locator('#toast')).toContainText('내보내기에 실패했습니다'); + state.exportMode = 'abort'; + await page.click('#team-body button:has-text("데이터 내보내기")'); + await expect(page.locator('#toast')).toContainText('내보내기에 실패했습니다'); + + await page.locator('#team-modal button[data-team-close="true"]').click(); + state.createProjectFail = true; + page.once('dialog', (dialog) => dialog.accept('Rejected Project')); + await page.click('#cloud-auth button:has-text("+ 새 프로젝트")'); + await expect(page.locator('#toast')).toContainText('project creation denied'); + state.createProjectFail = false; + + page.once('dialog', (dialog) => dialog.accept('Duplicated buyer plan')); + await page.click('#cloud-auth button:has-text("복제")'); + await expect(page.locator('#project-name')).toHaveValue('Duplicated buyer plan'); +}); + +test('first-project onboarding surfaces a failed sample creation without corrupting local planning', async ({ page }) => { + await primeAuth(page, { projectId: '' }); + await installApiMock(page, { projects: [], createProjectFail: true }); + await page.goto(`${STATIC_BASE}/`); + await page.waitForSelector('#cloud-auth button:has-text("샘플로 시작")'); + await page.click('#cloud-auth button:has-text("샘플로 시작")'); + await expect(page.locator('#toast')).toContainText('project creation denied'); + await expect(page.locator('#task-table-body tr').first()).toBeVisible(); +}); + +test('parser rejects malformed tag candidates without losing later valid MSP tasks', async ({ page }) => { + await page.goto(`${STATIC_BASE}/`); + const parsed = await page.evaluate(async () => { + const { parseMsProjectXml } = await import('/cloud-sync.js'); + return parseMsProjectXml(` + + 999 + 998 + 7A & B1 + 2026-08-20T09:00:002026-08-21T18:00:00 + 6 + + `); + }); + expect(parsed).toHaveLength(1); + expect(parsed[0]).toMatchObject({ id: 'msp-7', name: 'A & B', predecessors: 'msp-6' }); +}); + +test('SSO fragment cleanup and invite acceptance execute on the instrumented primary page', async ({ page }) => { + await installApiMock(page, { projects: [] }); + await page.goto(`${STATIC_BASE}/?invite=${INVITE_TOKEN}#token=${encodeURIComponent(TOKEN)}`); + await expect.poll(() => page.evaluate(() => location.hash)).toBe(''); + await expect.poll(() => page.evaluate(() => localStorage.getItem('scopeweave:token'))).toBe(TOKEN); + await expect.poll(() => page.evaluate(() => localStorage.getItem('scopeweave:project'))).toBe(null); + await expect(page.locator('#toast')).toContainText('초대를 수락했습니다'); +}); diff --git a/tests/e2e/coverage-test.js b/tests/e2e/coverage-test.js new file mode 100644 index 00000000..b4edf104 --- /dev/null +++ b/tests/e2e/coverage-test.js @@ -0,0 +1,113 @@ +import { createHash } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { test as base, expect } from '@playwright/test'; + +const expectedBrowserSources = new Set(['/analytics.js', '/app.js', '/cloud-sync.js']); + +const requiredSourcePath = (url) => { + try { + const pathname = decodeURIComponent(new URL(url).pathname); + return expectedBrowserSources.has(pathname) ? pathname : null; + } catch { + return null; + } +}; + +const isRequiredSource = (url) => requiredSourcePath(url) !== null; + +const test = base.extend({ + context: async ({ context }, use, testInfo) => { + const coverageEnabled = process.env.SCOPEWEAVE_BROWSER_COVERAGE === '1'; + if (!coverageEnabled) { + await use(context); + return; + } + + const coverageDirectory = process.env.SCOPEWEAVE_BROWSER_COVERAGE_DIR; + if (!coverageDirectory) { + throw new Error('SCOPEWEAVE_BROWSER_COVERAGE_DIR is required when browser coverage is enabled.'); + } + + const entries = []; + const servedSourceSha256 = Object.create(null); + const responseEvidence = []; + const activePages = new Set(); + const originalCloseMethods = new Map(); + + const responseListener = (response) => { + const sourcePath = requiredSourcePath(response.url()); + if (!sourcePath || response.status() !== 200) return; + responseEvidence.push((async () => { + const body = await response.body(); + const sourceDigest = createHash('sha256').update(body).digest('hex'); + const previousDigest = servedSourceSha256[sourcePath]; + if (previousDigest && previousDigest !== sourceDigest) { + throw new Error(`Browser received inconsistent bytes for ${sourcePath}.`); + } + servedSourceSha256[sourcePath] = sourceDigest; + })()); + }; + + const stopPageCoverage = async (page) => { + if (!activePages.delete(page)) return; + const originalCloseMethod = originalCloseMethods.get(page); + if (originalCloseMethod) { + page.close = originalCloseMethod; + originalCloseMethods.delete(page); + } + const pageEntries = await page.coverage.stopJSCoverage(); + entries.push(...pageEntries.filter((entry) => isRequiredSource(entry.url))); + }; + + const startPageCoverage = async (page) => { + if (activePages.has(page)) return; + await page.coverage.startJSCoverage({ resetOnNavigation: false }); + activePages.add(page); + const originalCloseMethod = page.close; + const closePage = page.close.bind(page); + originalCloseMethods.set(page, originalCloseMethod); + page.close = async (...args) => { + await stopPageCoverage(page); + return closePage(...args); + }; + }; + + context.on('response', responseListener); + const originalNewPageMethod = context.newPage; + const originalNewPage = context.newPage.bind(context); + context.newPage = async (...args) => { + const newPage = await originalNewPage(...args); + await startPageCoverage(newPage); + return newPage; + }; + + try { + for (const existingPage of context.pages()) { + await startPageCoverage(existingPage); + } + await use(context); + } finally { + context.newPage = originalNewPageMethod; + for (const page of [...activePages]) { + if (page.isClosed()) { + throw new Error('Browser page closed before coverage evidence could be collected.'); + } + await stopPageCoverage(page); + } + context.off('response', responseListener); + await Promise.all(responseEvidence); + } + + await mkdir(coverageDirectory, { recursive: true }); + const identity = [testInfo.testId, testInfo.retry, testInfo.workerIndex].join(':'); + const digest = createHash('sha256').update(identity).digest('hex'); + await writeFile( + path.join(coverageDirectory, `${digest}.json`), + `${JSON.stringify({ entries, servedSourceSha256 })}\n`, + 'utf8', + ); + }, +}); + +export { test, expect }; diff --git a/tests/e2e/csv_formula_fuzz.spec.js b/tests/e2e/csv_formula_fuzz.spec.js index 5ff20a48..e57c923c 100644 --- a/tests/e2e/csv_formula_fuzz.spec.js +++ b/tests/e2e/csv_formula_fuzz.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-test.js'; import fc from 'fast-check'; test.describe('CSV formula fuzzing', () => { diff --git a/tests/e2e/exact-browser-coverage-repair.spec.js b/tests/e2e/exact-browser-coverage-repair.spec.js new file mode 100644 index 00000000..f96da36c --- /dev/null +++ b/tests/e2e/exact-browser-coverage-repair.spec.js @@ -0,0 +1,261 @@ +import { test, expect } from './coverage-test.js'; + +function json(route, body, status = 200) { + return route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify(body), + }); +} + +async function installCloudApi(page, { role = 'member', checkoutUrl = null } = {}) { + await page.addInitScript(() => { + localStorage.setItem('scopeweave:token', 'coverage-token'); + localStorage.removeItem('scopeweave:project'); + }); + + await page.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = `${url.pathname}${url.search}`; + const method = request.method(); + + if (method === 'GET' && path === '/api/projects') return json(route, { projects: [] }); + if (method === 'GET' && path === '/api/notifications') return json(route, { notifications: [] }); + if (method === 'GET' && path === '/api/me') { + return json(route, { orgs: [{ id: 7, role }] }); + } + if (method === 'GET' && path === '/api/orgs/7/members') { + return json(route, { members: [{ id: 11, email: 'member@example.com', role }], invites: [] }); + } + if (method === 'GET' && path === '/api/orgs/7/billing') { + return json(route, { + plan: 'free', + planName: 'Free', + usage: { projects: 0, members: 1 }, + limits: { projects: 1, members: 3 }, + }); + } + if (method === 'GET' && path === '/api/tokens') return json(route, { tokens: [] }); + if (method === 'GET' && path === '/api/orgs/7/webhooks') return json(route, { webhooks: [] }); + if (method === 'GET' && path.startsWith('/api/orgs/7/audit')) return json(route, { events: [] }); + if (method === 'POST' && path === '/api/orgs/7/invites') { + return json(route, { error: '이미 멤버이거나 초대된 사용자입니다.' }, 409); + } + if (method === 'POST' && path === '/api/orgs/7/leave') return json(route, { ok: true }); + if (method === 'POST' && path === '/api/orgs/7/checkout' && checkoutUrl) { + return json(route, { mock: false, url: checkoutUrl }); + } + return json(route, {}); + }); +} + +test('failed file-picker writes never retain false auto-save authority', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, 'showSaveFilePicker', { + configurable: true, + value: async () => ({ + createWritable: async () => { + throw new DOMException('write denied', 'NotAllowedError'); + }, + }), + }); + }); + + await page.goto('/'); + const connect = page.getByRole('button', { name: 'wbs.json 자동저장 연결' }); + await expect(connect).not.toHaveAttribute('aria-disabled', 'true'); + await connect.click(); + + await expect(page.locator('#toast')).toContainText('wbs.json 연결에 실패했습니다.'); + await expect(page.locator('#sync-status')).toHaveText('브라우저 로컬 자동저장 사용 중'); + + // Force a later normal render. A failed candidate must not linger in state and + // become false connected authority after the original error path completes. + const projectName = page.locator('#project-name'); + await projectName.fill('Picker failure regression'); + await projectName.blur(); + await expect(page.locator('#sync-status')).toHaveText('브라우저 로컬 자동저장 사용 중'); +}); + +test('invalid file-picker return shapes fail closed without claiming sync authority', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, 'showSaveFilePicker', { + configurable: true, + value: async () => null, + }); + }); + + await page.goto('/'); + const connect = page.getByRole('button', { name: 'wbs.json 자동저장 연결' }); + await connect.click(); + await expect(page.locator('#toast')).toContainText('wbs.json 연결에 실패했습니다.'); + await expect(page.locator('#sync-status')).toHaveText('브라우저 로컬 자동저장 사용 중'); + + await page.evaluate(() => { + Object.defineProperty(window, 'showSaveFilePicker', { + configurable: true, + value: async () => ({}), + }); + }); + await connect.click(); + await expect(page.locator('#toast')).toContainText('wbs.json 연결에 실패했습니다.'); + await expect(page.locator('#sync-status')).toHaveText('브라우저 로컬 자동저장 사용 중'); +}); + +test('inline progress keeps keyboard focus after the row is re-rendered', async ({ page }) => { + await page.goto('/'); + const progress = page.locator('select[data-inline-progress]').first(); + await expect(progress).toBeVisible(); + await progress.focus(); + await progress.selectOption('진행(30%)'); + + const taskId = await progress.getAttribute('data-inline-progress'); + const replacement = page.locator(`select[data-inline-progress="${taskId}"]`); + await expect(replacement).toHaveValue('진행(30%)'); + await expect(replacement).toBeFocused(); +}); + +test('root creation survives a stale insertion anchor after accepted remote hydration', async ({ page }) => { + await page.addInitScript(() => { + window.__scopeweaveCapturedHost = null; + Object.defineProperty(window, 'ScopeWeaveCloud', { + configurable: true, + set(value) { + const originalInit = typeof value?.init === 'function' ? value.init.bind(value) : null; + if (originalInit) { + value.init = (host) => { + window.__scopeweaveCapturedHost = host; + return originalInit(host); + }; + } + Object.defineProperty(window, 'ScopeWeaveCloud', { + configurable: true, + writable: true, + value, + }); + }, + }); + }); + await page.route('**/wbs.json', (route) => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify([{ __id: 'remote-anchor', __depth: 1, phase: 'Remote anchor' }]), + })); + + await page.goto('/'); + await page.getByRole('button', { name: '최상위 작업 추가' }).last().click(); + await page.getByTestId('editor-phase').fill('Recovered stale insertion'); + + await page.evaluate(() => { + const host = window.__scopeweaveCapturedHost; + if (!host) throw new Error('ScopeWeave host API was not captured'); + host.hydrateState({ + projectName: 'Remote plan', + baseDate: '2026-08-20', + tasks: [], + }); + }); + + await page.getByRole('button', { name: '저장', exact: true }).click(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(1); + await expect(page.getByText('Recovered stale insertion', { exact: true })).toBeVisible(); + await expect(page.locator('#toast')).toContainText('변경 내용을 저장했습니다.'); +}); + +test('cloud bootstrap remains usable when an optional host-init hook is absent', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, 'ScopeWeaveCloud', { + configurable: true, + set(value) { + delete value.init; + Object.defineProperty(window, 'ScopeWeaveCloud', { + configurable: true, + writable: true, + value, + }); + }, + }); + }); + + await page.goto('/'); + await expect(page.getByRole('button', { name: '최상위 작업 추가' })).toBeVisible(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); +}); + +test('editor validation tolerates a future field label without losing the form', async ({ page }) => { + await page.goto('/'); + const edit = page.locator('button[data-action="edit"]').first(); + await expect(edit).toBeVisible(); + await edit.click(); + + await page.evaluate(() => { + const form = document.querySelector('form[data-editor-form="true"]'); + const input = document.createElement('input'); + input.dataset.editorField = 'futureField'; + input.value = 'future value'; + input.id = 'future-editor-field'; + form.appendChild(input); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + + const future = page.locator('#future-editor-field'); + await expect(future).toBeVisible(); + await expect(future).not.toHaveAttribute('aria-invalid', 'true'); +}); + +test('editor validation ignores a malformed unlabeled extension field while reporting real errors', async ({ page }) => { + await page.goto('/'); + const edit = page.locator('button[data-action="edit"]').first(); + await edit.click(); + + await page.locator('[data-testid="editor-planned-start"]').fill('2026-12-31'); + await page.locator('[data-testid="editor-planned-end"]').fill('2026-01-01'); + await page.evaluate(() => { + const form = document.querySelector('form[data-editor-form="true"]'); + const input = document.createElement('input'); + input.setAttribute('data-editor-field', ''); + input.id = 'unlabeled-extension-field'; + form.appendChild(input); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + + await expect(page.locator('#editor-errors')).toContainText('계획종료일은 계획시작일보다 빠를 수 없습니다.'); + await expect(page.locator('#unlabeled-extension-field')).not.toHaveAttribute('aria-invalid', 'true'); +}); + +test('team recovery resolves tenant authority, reports invite rejection, and lets a member leave', async ({ page }) => { + await installCloudApi(page, { role: 'member' }); + await page.goto('/'); + + await page.getByRole('button', { name: '팀' }).click(); + const team = page.locator('#team-modal'); + await expect(team).not.toHaveClass(/hidden/); + + await team.locator('#team-email').fill('member@example.com'); + await team.getByRole('button', { name: '초대' }).click(); + await expect(team.locator('#team-msg')).toHaveText('이미 멤버이거나 초대된 사용자입니다.'); + + page.once('dialog', (dialog) => dialog.accept()); + await team.getByRole('button', { name: '워크스페이스 나가기' }).click(); + await expect(team).toHaveClass(/hidden/); + await expect(page.locator('#toast')).toContainText('워크스페이스에서 나왔습니다.'); +}); + +test('paid checkout redirects through the server-provided hosted destination', async ({ page }) => { + await installCloudApi(page, { role: 'owner', checkoutUrl: '/checkout-target' }); + await page.route('**/checkout-target', (route) => route.fulfill({ + status: 200, + contentType: 'text/html', + body: 'Checkout target', + })); + await page.goto('/'); + + await page.getByRole('button', { name: '팀' }).click(); + await expect(page.locator('#team-modal')).not.toHaveClass(/hidden/); + + await Promise.all([ + page.waitForURL('**/checkout-target'), + page.getByRole('button', { name: 'Pro 업그레이드' }).click(), + ]); + await expect(page).toHaveURL(/\/checkout-target$/); +}); diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index dc0cda8d..91d95a95 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-test.js'; import fs from 'node:fs'; diff --git a/tests/e2e/test_getTaskSubtreeRange.spec.js b/tests/e2e/test_getTaskSubtreeRange.spec.js index bc1eb38a..3f43eff5 100644 --- a/tests/e2e/test_getTaskSubtreeRange.spec.js +++ b/tests/e2e/test_getTaskSubtreeRange.spec.js @@ -1,42 +1,159 @@ -import { test, expect } from '@playwright/test'; - -test.describe('getTaskSubtreeRange function tests', () => { - test('should return correct range for root task, sub task and non-existent task', async ({ page }) => { - // Intercept app.js to inject window assignment at the end - await page.route('**/app.js', async (route) => { - const response = await route.fetch(); - let body = await response.text(); - body += `\nwindow.getTaskSubtreeRange = getTaskSubtreeRange;\nwindow.testState = state;`; - await route.fulfill({ - response, - body, - headers: { ...response.headers(), 'content-type': 'application/javascript' } - }); +import { test, expect } from './coverage-test.js'; + +const seededHierarchy = [ + { id: '1', parentId: null, depth: 1, expanded: true, phase: 'Root A' }, + { id: '2', parentId: '1', depth: 2, expanded: true, activity: 'Activity A' }, + { id: '3', parentId: '2', depth: 3, expanded: true, task: 'Leaf A' }, + { id: '7', parentId: '2', depth: 3, expanded: true, task: 'Leaf B' }, + { id: '4', parentId: '1', depth: 2, expanded: true, activity: 'Activity B' }, + { id: '5', parentId: null, depth: 1, expanded: true, phase: 'Root B' }, + { id: '6', parentId: '5', depth: 2, expanded: true, activity: 'Activity C' }, +]; + +async function seedPlanner(page, { captureCloudHost = false } = {}) { + await page.addInitScript(({ tasks, captureCloudHost: captureHost }) => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'Subtree range regression', + baseDate: '2026-08-19', + tasks, + })); + + if (!captureHost) return; + let cloudApi; + Object.defineProperty(window, 'ScopeWeaveCloud', { + configurable: true, + get() { + return cloudApi; + }, + set(value) { + if (value && typeof value.init === 'function') { + const originalInit = value.init; + value.init = function capturePlannerHost(hostApi) { + window.__scopeweavePlannerHost = hostApi; + return originalInit.call(this, hostApi); + }; + } + cloudApi = value; + }, }); + }, { tasks: seededHierarchy, captureCloudHost }); +} + +async function dragTaskAfter(page, draggedId, targetId) { + await page.evaluate(({ draggedId: sourceId, targetId: destinationId }) => { + const source = document.querySelector(`tr[data-task-id="${sourceId}"]`); + const target = document.querySelector(`tr[data-task-id="${destinationId}"]`); + if (!source || !target) throw new Error('expected drag source and target rows'); + + const transfer = new DataTransfer(); + source.dispatchEvent(new DragEvent('dragstart', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + })); + const targetRect = target.getBoundingClientRect(); + const clientY = targetRect.bottom - 1; + target.dispatchEvent(new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + clientY, + })); + target.dispatchEvent(new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + clientY, + })); + source.dispatchEvent(new DragEvent('dragend', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + })); + }, { draggedId, targetId }); +} + +function persistedTaskIds(page) { + return expect.poll(() => page.evaluate(() => { + const raw = localStorage.getItem('scopeweave:planner-state:v1'); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed?.tasks) ? parsed.tasks.map((task) => task.id) : []; + })); +} + +test.describe('task subtree range behavior', () => { + test('moves a root together with every descendant', async ({ page }) => { + await seedPlanner(page); + await page.goto('/'); + + await dragTaskAfter(page, '1', '5'); + + await persistedTaskIds(page).toEqual(['5', '6', '1', '2', '3', '7', '4']); + }); + + test('moves a middle-level task together with its nested leaves', async ({ page }) => { + await seedPlanner(page); + await page.goto('/'); + + await dragTaskAfter(page, '2', '4'); + + await persistedTaskIds(page).toEqual(['1', '4', '2', '3', '7', '5', '6']); + }); + test('moves a leaf without consuming its sibling', async ({ page }) => { + await seedPlanner(page); await page.goto('/'); - const result = await page.evaluate(() => { - window.testState.tasks = [ - { id: '1', depth: 1 }, - { id: '2', depth: 2 }, - { id: '3', depth: 3 }, - { id: '4', depth: 2 }, - { id: '5', depth: 1 }, - { id: '6', depth: 2 } - ]; - - return { - rootNodeRange: window.getTaskSubtreeRange('1'), - leafNodeRange: window.getTaskSubtreeRange('3'), - middleNodeRange: window.getTaskSubtreeRange('2'), - nonExistentNodeRange: window.getTaskSubtreeRange('99') - }; + await dragTaskAfter(page, '3', '7'); + + await persistedTaskIds(page).toEqual(['1', '2', '7', '3', '4', '5', '6']); + }); + + test('fails closed when cloud hydration removes a dragged subtree before drop', async ({ page }) => { + await seedPlanner(page, { captureCloudHost: true }); + await page.goto('/'); + await page.waitForFunction(() => Boolean(window.__scopeweavePlannerHost)); + + await page.evaluate(() => { + const source = document.querySelector('tr[data-task-id="2"]'); + const target = document.querySelector('tr[data-task-id="4"]'); + if (!source || !target) throw new Error('expected stale-drag source and target rows'); + + const transfer = new DataTransfer(); + source.dispatchEvent(new DragEvent('dragstart', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + })); + + window.__scopeweavePlannerHost.hydrateState({ + projectName: 'Concurrent cloud replacement', + baseDate: '2026-08-19', + tasks: [{ id: '5', parentId: null, depth: 1, expanded: true, phase: 'Replacement root' }], + }); + + const targetRect = target.getBoundingClientRect(); + const clientY = targetRect.bottom - 1; + target.dispatchEvent(new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + clientY, + })); + target.dispatchEvent(new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + clientY, + })); + source.dispatchEvent(new DragEvent('dragend', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + })); }); - expect(result.rootNodeRange).toEqual({ startIndex: 0, endIndex: 3 }); - expect(result.leafNodeRange).toEqual({ startIndex: 2, endIndex: 2 }); - expect(result.middleNodeRange).toEqual({ startIndex: 1, endIndex: 2 }); - expect(result.nonExistentNodeRange).toBeNull(); + await persistedTaskIds(page).toEqual(['5']); }); }); diff --git a/tests/e2e/toast-accessibility.spec.js b/tests/e2e/toast-accessibility.spec.js index 5e45cb79..b43b0ab5 100644 --- a/tests/e2e/toast-accessibility.spec.js +++ b/tests/e2e/toast-accessibility.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-test.js'; test('cloud status feedback is visibly rendered as a non-focus-taking live status', async ({ page }) => { await page.goto('/?share=ABCDEFGHIJKLMNOP'); diff --git a/tests/unit/auth-password.test.mjs b/tests/unit/auth-password.test.mjs index 5df3e344..ee147169 100644 --- a/tests/unit/auth-password.test.mjs +++ b/tests/unit/auth-password.test.mjs @@ -22,6 +22,15 @@ for (const bad of [{}, [], null, undefined, 12, true]) { assert.equal(verifyPassword(bad, stored), false, 'non-string never verifies a real password'); } +// Malformed or truncated persisted representations fail closed before any +// timing-safe equality result can be mistaken for a valid credential. +for (const malformed of [null, undefined, '', 'salt-only', 'salt:', ':hash']) { + assert.equal(verifyPassword('correct-horse', malformed), false, 'missing salt/hash never verifies'); +} +const [salt] = stored.split(':'); +assert.equal(verifyPassword('correct-horse', salt + ':00'), false, 'wrong digest length fails closed'); +assert.equal(verifyPassword('correct-horse', salt + ':not-hex'), false, 'invalid hex digest fails closed'); + // Empty string is a distinct string path; non-strings must not verify against it. const empty = hashPassword(''); assert.equal(verifyPassword('', empty), true); diff --git a/tests/unit/browser-coverage-failure.test.mjs b/tests/unit/browser-coverage-failure.test.mjs new file mode 100644 index 00000000..62aaf38f --- /dev/null +++ b/tests/unit/browser-coverage-failure.test.mjs @@ -0,0 +1,39 @@ +// Regression coverage for the browser-coverage failure boundary. +// A failed Playwright run remains the primary failure even if the collector +// encounters a second coverage-processing error while preserving diagnostics. +import assert from 'node:assert/strict'; + +import { reportCoverageProcessingFailure } from '../../scripts/ci/browser_coverage_failure.mjs'; + +const secondaryFailure = new Error('coverage evidence is incomplete'); +const messages = []; +const preservedStatus = reportCoverageProcessingFailure( + 7, + secondaryFailure, + (...parts) => messages.push(parts.map(String).join(' ')), +); + +assert.equal(preservedStatus, 7, 'the original Playwright exit status remains authoritative'); +assert.equal(messages.length, 2, 'both the primary and secondary failure must be explicit'); +assert.match(messages[0], /Browser tests failed with exit status 7/); +assert.match(messages[1], /Browser coverage processing also failed: coverage evidence is incomplete/); + +const signalledMessages = []; +assert.equal( + reportCoverageProcessingFailure( + null, + secondaryFailure, + (...parts) => signalledMessages.push(parts.map(String).join(' ')), + ), + 1, + 'a signal-terminated Playwright run remains non-passing when no numeric status exists', +); +assert.match(signalledMessages[0], /exit status 1/); + +assert.throws( + () => reportCoverageProcessingFailure(0, secondaryFailure, () => {}), + (error) => error === secondaryFailure, + 'coverage-processing failures remain fail-closed when Playwright itself passed', +); + +console.log('✓ browser coverage failure precedence tests passed'); diff --git a/tests/unit/changelog-release-notes.test.mjs b/tests/unit/changelog-release-notes.test.mjs index d886f3e1..1c451085 100644 --- a/tests/unit/changelog-release-notes.test.mjs +++ b/tests/unit/changelog-release-notes.test.mjs @@ -7,6 +7,10 @@ const changelog = readFileSync(new URL('../../CHANGELOG.md', import.meta.url), ' test('released changelog versions keep their published notes', () => { assert.match(changelog, /## \[1\.0\.0\] - 2026-04-20/); assert.match(changelog, /Initial ScopeWeave Planner release with tree-table editing/); + assert.match(changelog, /GitHub Pages deployment workflow and operator documentation\./); assert.match(changelog, /## \[1\.0\.1\] - 2026-06-25/); - assert.match(changelog, /O\(1\) 해시맵\(Map\) 기반의 캐싱 조회 로직/); + assert.match( + changelog, + /드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O\(N\) 작업 리스트 검색 성능 병목 문제를, O\(1\) 해시맵\(Map\) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다\./, + ); }); diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index cf3ad02c..665d5932 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -160,6 +160,15 @@ test('submitJob rejects transport details and malformed successful responses', a jobId: 'job-3', status: 'RUNNING', }); + + setResponse({ json: async () => ({ jobId: 'job-4', status: 'SUCCEEDED' }) }); + assert.deepEqual( + await submitJob(7, 9, { ...document, mime: '' }), + { jobId: 'job-4', status: 'SUCCEEDED' }, + ); + const uploaded = observedOptions.body.get('file'); + assert.ok(uploaded instanceof Blob, 'upload remains a multipart Blob/File payload'); + assert.equal(uploaded.type, 'application/octet-stream', 'empty MIME defaults safely'); }); test('artifactUrl validates links and never exposes transport or response text', async () => { diff --git a/tests/unit/codeql-stacked-pr-trigger-contract.test.mjs b/tests/unit/codeql-stacked-pr-trigger-contract.test.mjs new file mode 100644 index 00000000..36ce5211 --- /dev/null +++ b/tests/unit/codeql-stacked-pr-trigger-contract.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const workflow = readFileSync( + new URL('../../.github/workflows/codeql-required.yml', import.meta.url), + 'utf8', +); + +assert.match( + workflow, + /^ pull_request:\r?\n push:/m, + 'CodeQL Required must run on stacked pull requests regardless of their base branch', +); +assert.doesNotMatch( + workflow, + /\bpull_request_target\s*:/, + 'CodeQL Required must retain the unprivileged pull_request trust boundary', +); + +console.log('✓ required CodeQL covers develop-bound and stacked pull requests'); diff --git a/tests/unit/codeql-workflow-supply-chain.test.mjs b/tests/unit/codeql-workflow-supply-chain.test.mjs new file mode 100644 index 00000000..713c42e3 --- /dev/null +++ b/tests/unit/codeql-workflow-supply-chain.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; + +const advancedWorkflowUrl = new URL('../../.github/workflows/codeql.yml', import.meta.url); +const requiredWorkflow = readFileSync( + new URL('../../.github/workflows/codeql-required.yml', import.meta.url), + 'utf8', +); + +const exactHeadRef = 'ref: ${{ github.event.pull_request.head.sha || github.sha }}'; +const expectedShaEnv = 'EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}'; +const currentCodeqlSha = 'db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28'; +const supersededCodeqlSha = '8aad20d150bbac5944a9f9d289da16a4b0d87c1e'; +const protectedAnalyzeName = 'name: Analyze (${{ matrix.language }})'; + +assert.equal( + existsSync(advancedWorkflowUrl), + false, + 'default-setup SARIF authority must not coexist with a repository advanced CodeQL publisher workflow', +); +assert.equal( + requiredWorkflow.split(exactHeadRef).length - 1, + 1, + 'required CodeQL PR analysis must explicitly checkout the contributor head instead of the synthetic merge commit', +); +assert.equal( + requiredWorkflow.split(expectedShaEnv).length - 1, + 1, + 'required CodeQL must bind runtime attestation to the same expected exact-head SHA', +); +assert.equal( + requiredWorkflow.split('git rev-parse HEAD').length - 1, + 1, + 'required CodeQL must attest the commit it actually analyzes', +); +assert.equal( + requiredWorkflow.split('test "$actual_sha" = "$EXPECTED_CHECKOUT_SHA"').length - 1, + 1, + 'required CodeQL runtime attestation must fail closed when checkout does not match the expected exact head', +); +assert.equal( + requiredWorkflow.split('persist-credentials: false').length - 1, + 1, + 'required CodeQL checkout must retain least-privilege credential handling', +); +assert.equal( + requiredWorkflow.split(`github/codeql-action/init@${currentCodeqlSha} # v4.37.8`).length - 1, + 1, + 'required CodeQL initialization must use the reviewed immutable v4.37.8 action revision', +); +assert.equal( + requiredWorkflow.split(`github/codeql-action/analyze@${currentCodeqlSha} # v4.37.8`).length - 1, + 1, + 'required CodeQL analysis must use the reviewed immutable v4.37.8 action revision', +); +assert.equal( + requiredWorkflow.includes(supersededCodeqlSha), + false, + 'required CodeQL must not regress to the superseded v4.36.2 action revision', +); +assert.doesNotMatch( + requiredWorkflow, + /\bpull_request_target\s*:/, + 'required CodeQL must remain on the unprivileged pull_request trust boundary', +); +assert.equal( + requiredWorkflow.split(protectedAnalyzeName).length - 1, + 1, + 'required CodeQL must remain the sole repository workflow provider of the protected Analyze check names', +); +assert.match( + requiredWorkflow, + /\bupload:\s*never\b/, + 'required CodeQL must not publish SARIF while GitHub default setup owns publication', +); +assert.match( + requiredWorkflow, + /\bupload-database:\s*false\b/, + 'required CodeQL must not publish CodeQL databases from default-branch required-context runs', +); +assert.doesNotMatch( + requiredWorkflow, + /\bsecurity-events:\s*write\b/, + 'non-publishing required CodeQL must not retain code-scanning write authority', +); + +console.log('✓ CodeQL exact-head, default-setup authority, and supply-chain contract passed'); diff --git a/tests/unit/coverage-diagnostics-workflow-contract.test.mjs b/tests/unit/coverage-diagnostics-workflow-contract.test.mjs new file mode 100644 index 00000000..c3f6f61d --- /dev/null +++ b/tests/unit/coverage-diagnostics-workflow-contract.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const serverTestsWorkflow = readFileSync( + new URL('../../.github/workflows/server-tests.yml', import.meta.url), + 'utf8', +); + +const diagnosticsBlock = serverTestsWorkflow.match( + /- name: Coverage failure diagnostics[\s\S]*?- name: Preserve exact coverage failure evidence/, +)?.[0] ?? ''; + +assert.notEqual( + diagnosticsBlock, + '', + 'Server Tests must retain the coverage-failure diagnostics step before preserving coverage artifacts', +); +assert.match( + diagnosticsBlock, + /for report in coverage\/coverage-final\.json coverage\/browser-coverage-final\.json/, + 'coverage diagnostics must attempt both server and browser Istanbul reports', +); +assert.match( + diagnosticsBlock, + /if ! node scripts\/ci\/coverage_diagnostics\.mjs "\$report"; then[\s\S]*?::warning::coverage diagnostics could not inspect \$report[\s\S]*?fi/, + 'one unreadable coverage report must not abort diagnostics before the other report is inspected', +); +assert.doesNotMatch( + diagnosticsBlock, + /\n\s+node scripts\/ci\/coverage_diagnostics\.mjs "\$report"\s*\n/, + 'coverage diagnostics must not invoke the helper as an unguarded bash -e command', +); + +console.log('✓ coverage-failure diagnostics remain complete when one Istanbul report is unreadable'); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..d684cc25 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -1,43 +1,137 @@ -// This contract prevents a subtle CI regression: the central review gate may -// invoke `test:coverage` directly, so that script itself must create Istanbul -// JSON rather than merely execute tests without instrumentation. +// This contract prevents coverage evidence from silently omitting either runtime. +// ScopeWeave owns browser code and Node/server code; each runtime must enforce +// exact 100% Istanbul statement/branch/function/line coverage on the same PR head. import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; const packageJson = JSON.parse( readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), ); +const packageLock = JSON.parse( + readFileSync(new URL('../../package-lock.json', import.meta.url), 'utf8'), +); const scripts = packageJson.scripts; +for (const [dependencyName, dependencyVersion] of [ + ['istanbul-lib-coverage', '3.2.2'], + ['v8-to-istanbul', '9.3.0'], +]) { + assert.equal( + packageJson.devDependencies?.[dependencyName], + dependencyVersion, + `browser coverage must directly declare ${dependencyName}@${dependencyVersion}`, + ); + assert.equal( + packageLock.packages?.['']?.devDependencies?.[dependencyName], + dependencyVersion, + `the lockfile root must preserve ${dependencyName} as a direct development dependency`, + ); + assert.equal( + packageLock.packages?.[`node_modules/${dependencyName}`]?.version, + dependencyVersion, + `the lockfile must resolve ${dependencyName} to the reviewed coverage-tooling version`, + ); +} + +function declaredStringList(source, declarationStart, declarationEnd) { + const startIndex = source.indexOf(declarationStart); + assert.notEqual(startIndex, -1, `missing coverage ownership declaration: ${declarationStart}`); + const valueStart = startIndex + declarationStart.length; + const endIndex = source.indexOf(declarationEnd, valueStart); + assert.notEqual(endIndex, -1, `unterminated coverage ownership declaration: ${declarationStart}`); + return [...source.slice(valueStart, endIndex).matchAll(/['"]([^'"]+)['"]/g)] + .map((match) => match[1]) + .sort(); +} + +function productionBrowserModules(indexHtml) { + return [...indexHtml.matchAll(/]*>/gi)] + .map((match) => match[0]) + .flatMap((tag) => { + const type = tag.match(/\btype\s*=\s*['"]([^'"]+)['"]/i)?.[1]; + const src = tag.match(/\bsrc\s*=\s*['"]([^'"]+)['"]/i)?.[1]; + if (type !== 'module' || !src || /^(?:[a-z]+:|\/\/)/i.test(src)) return []; + return [src.replace(/^\.\//, '')]; + }) + .sort(); +} + assert.equal( scripts.coverage, 'npm run test:coverage', - 'the public coverage command delegates to the canonical coverage producer', + 'the public coverage command delegates to the canonical complete coverage producer', ); -assert.match( +assert.equal( scripts['test:coverage'], + 'npm run test:coverage:server && npm run test:coverage:browser', + 'canonical coverage must prove both Node/server and real-browser production code', +); +assert.match( + scripts['test:coverage:server'], /\bc8\b.*--reporter=json(?![-\w]).*npm run test:coverage:cases/, - 'test:coverage creates Istanbul JSON before executing coverage cases', + 'server coverage creates Istanbul JSON before executing deterministic cases', ); assert.match( - scripts['test:coverage'], + scripts['test:coverage:server'], /--reporter=json-summary\b/, - 'test:coverage also creates the Istanbul JSON summary', + 'server coverage also creates the Istanbul JSON summary', ); -assert.match( - scripts['test:coverage'], - /--include=server\/attachment_status\.mjs/, - 'the bounded refresh module is instrumented', +for (const requiredCoverageOption of [ + '--all', + '--check-coverage', + '--per-file', + '--lines 100', + '--functions 100', + '--branches 100', + '--statements 100', +]) { + assert.equal( + scripts['test:coverage:server'].includes(requiredCoverageOption), + true, + `server coverage must enforce ${requiredCoverageOption}`, + ); +} +assert.equal( + scripts['test:coverage:server'].includes('--include=scripts/ci/static_coverage_evidence.mjs'), + true, + 'the repository-owned static evidence producer remains covered by the server lane', +); +const serverProductionModules = readdirSync(new URL('../../server/', import.meta.url)) + .filter((name) => name.endsWith('.mjs')) + .map((name) => `server/${name}`) + .sort(); +const coveredServerModules = [...scripts['test:coverage:server'].matchAll(/--include=(server\/[^\s]+)/g)] + .map((match) => match[1]) + .sort(); +assert.deepEqual( + coveredServerModules, + serverProductionModules, + 'server coverage ownership must include every production server module rather than a curated subset', +); +assert.doesNotMatch( + scripts['test:coverage:server'], + /--include=(?:app|cloud-sync|analytics)\.js\b/, + 'browser production must not be scored from a Node VM that cannot observe real browser execution', +); +assert.equal( + scripts['test:coverage:browser'], + 'node scripts/ci/browser_coverage.mjs', + 'browser coverage must use the repository-owned Chromium/Istanbul collector', +); +assert.equal( + scripts['test:coverage:cases'], + 'npm run test:unit && npm run test:api', + 'server coverage instruments the complete deterministic unit and API suites instead of a stale curated subset', ); assert.match( - scripts['test:coverage'], - /--include=server\/clearfolio\.mjs/, - 'the abortable Clearfolio adapter is instrumented', + scripts['test:api'], + /tests\/api\/app-branch-coverage\.mjs/, + 'the complete API suite must retain branch-oriented production coverage cases', ); assert.match( - scripts['test:coverage:cases'], + scripts['test:unit'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, - 'the Clearfolio signal and HTTP failure regression executes under c8', + 'the complete unit suite retains the Clearfolio signal and HTTP failure regression', ); assert.doesNotMatch( scripts['test:coverage:cases'], @@ -45,4 +139,131 @@ assert.doesNotMatch( 'coverage cases never recursively invoke a coverage wrapper', ); +const e2eDirectory = new URL('../e2e/', import.meta.url); +const e2eSpecs = readdirSync(e2eDirectory) + .filter((name) => name.endsWith('.spec.js')) + .sort(); +assert.ok(e2eSpecs.length > 0, 'real-browser coverage requires executable Playwright specs'); +for (const specName of e2eSpecs) { + const specSource = readFileSync(new URL(specName, e2eDirectory), 'utf8'); + assert.match( + specSource, + /import\s*\{\s*test\s*,\s*expect\s*\}\s*from\s*['"]\.\/coverage-test\.js['"];/, + `${specName} must use the coverage-aware Playwright fixture so browser coverage cannot run without raw evidence`, + ); + assert.doesNotMatch( + specSource, + /from\s*['"]@playwright\/test['"]/, + `${specName} must not bypass the coverage-aware fixture with a direct Playwright test import`, + ); + assert.doesNotMatch( + specSource, + /page\.route\(\s*['"`][^'"`]*(?:app|cloud-sync|analytics)\.js[^'"`]*['"`]/, + `${specName} must not replace exact production script bytes while those bytes are coverage provenance evidence`, + ); +} + +const browserFixtureSource = readFileSync(new URL('../e2e/coverage-test.js', import.meta.url), 'utf8'); +const browserCollectorSource = readFileSync( + new URL('../../scripts/ci/browser_coverage.mjs', import.meta.url), + 'utf8', +); +const indexHtml = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); +const browserProductionModules = productionBrowserModules(indexHtml); +const fixtureOwnedModules = declaredStringList( + browserFixtureSource, + 'const expectedBrowserSources = new Set([', + ']);', +).map((source) => source.replace(/^\//, '')).sort(); +const collectorOwnedModules = declaredStringList( + browserCollectorSource, + 'const expectedBrowserSources = [', + '];', +).sort(); +assert.deepEqual( + fixtureOwnedModules, + browserProductionModules, + 'the Playwright coverage fixture must capture every local production module loaded by index.html', +); +assert.deepEqual( + collectorOwnedModules, + browserProductionModules, + 'the browser coverage collector must score every local production module loaded by index.html', +); +assert.match( + browserFixtureSource, + /context:\s*async\s*\(\{\s*context\s*\},\s*use,\s*testInfo\)\s*=>/, + 'browser coverage must own the per-test browser context so every page in that context can produce evidence', +); +assert.match( + browserFixtureSource, + /const originalNewPage = context\.newPage\.bind\(context\)/, + 'browser coverage must preserve the real BrowserContext.newPage implementation before wrapping it', +); +assert.match( + browserFixtureSource, + /context\.newPage = async \(\.\.\.args\) => \{[\s\S]*?await startPageCoverage\(newPage\)/, + 'browser coverage must start instrumentation before any context.newPage caller can navigate a secondary page', +); +assert.match( + browserFixtureSource, + /page\.close = async \(\.\.\.args\) => \{[\s\S]*?await stopPageCoverage\(page\)/, + 'browser coverage must collect secondary-page evidence before an explicitly closed page becomes unavailable', +); +assert.match( + browserFixtureSource, + /context\.on\(['"]response['"],\s*responseListener\)/, + 'browser coverage must observe production responses from every page in the test context', +); +assert.match( + browserFixtureSource, + /response\.body\(\)/, + 'browser coverage must hash served response bytes rather than trusting CDP source-text normalization', +); +assert.match( + browserFixtureSource, + /createHash\(['"]sha256['"]\)/, + 'browser coverage must bind served production source evidence with SHA-256', +); +assert.match( + browserFixtureSource, + /servedSourceSha256/, + 'raw browser evidence must carry served-source digests alongside V8 coverage ranges', +); +assert.match( + browserCollectorSource, + /servedSourceSha256/, + 'the collector must consume the served-source digest evidence', +); +assert.match( + browserCollectorSource, + /createHash\(['"]sha256['"]\)/, + 'the collector must independently hash checked-out production source bytes', +); +assert.doesNotMatch( + browserCollectorSource, + /entry\.source\s*!=\s*null\s*&&\s*entry\.source\s*!==\s*localSource/, + 'the collector must not reject browser-equivalent source solely because CDP normalized source text', +); +assert.doesNotMatch( + browserCollectorSource, + /if \(testRun\.status !== 0\) \{[\s\S]*?\}\s*else\s*\{\s*const rawFiles/, + 'a failing Playwright suite must not skip conversion of already-emitted raw browser coverage evidence', +); +assert.match( + browserCollectorSource, + /if \(testRun\.status !== 0\) \{[\s\S]*?process\.exitCode = testRun\.status \?\? 1;[\s\S]*?\}\s*try\s*\{\s*const rawFiles =/, + 'the collector must preserve a non-passing Playwright result while continuing into guarded raw coverage processing', +); +assert.match( + browserCollectorSource, + /catch \(coverageError\) \{\s*process\.exitCode = reportCoverageProcessingFailure\(testRun\.status, coverageError\);\s*\}/, + 'secondary coverage-processing failures must preserve the primary Playwright failure instead of replacing it', +); +assert.match( + browserCollectorSource, + /if \(rawFiles\.length === 0\) \{[\s\S]*?if \(testRun\.status !== 0\)/, + 'when tests fail before emitting raw evidence the collector must preserve that test failure rather than inventing coverage evidence', +); + console.log('✓ coverage script contract tests passed'); diff --git a/tests/unit/dependency-review-exact-head-contract.test.mjs b/tests/unit/dependency-review-exact-head-contract.test.mjs new file mode 100644 index 00000000..a385dcad --- /dev/null +++ b/tests/unit/dependency-review-exact-head-contract.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const workflow = readFileSync( + new URL('../../.github/workflows/dependency-review.yml', import.meta.url), + 'utf8', +); + +assert.match( + workflow, + /ref: \$\{\{ github\.event\.pull_request\.head\.sha \|\| github\.sha \}\}/, + 'Dependency Review must check out the exact contributor head on pull requests', +); +assert.match( + workflow, + /EXPECTED_CHECKOUT_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \|\| github\.sha \}\}/, + 'Dependency Review must bind runtime checkout verification to the expected exact head', +); +assert.match( + workflow, + /git rev-parse HEAD[\s\S]*?test "\$actual_sha" = "\$EXPECTED_CHECKOUT_SHA"/, + 'Dependency Review must fail closed when the actual checkout differs from the expected head', +); +assert.match( + workflow, + /BASE_REF: \$\{\{ github\.event\.pull_request\.base\.ref \}\}/, + 'Dependency Review must resolve the current named protected base rather than trust a PR base snapshot', +); +assert.doesNotMatch( + workflow, + /github\.event\.pull_request\.base\.sha/, + 'Dependency Review must not treat pull_request.base.sha as the live protected base tip', +); +assert.match( + workflow, + /git ls-remote --exit-code origin "refs\/heads\/\$BASE_REF"/, + 'Dependency Review must independently resolve the live base branch tip', +); +assert.match( + workflow, + /mapfile -t live_base_matches <<<"\$result"/, + 'Dependency Review must materialize every live-base ls-remote match before parsing one', +); +assert.match( + workflow, + /test "\$\{#live_base_matches\[@\]\}" -eq 1/, + 'Dependency Review must fail closed unless live-base resolution yields exactly one ref', +); +assert.match( + workflow, + /read -r live_base_sha live_base_ref extra <<<"\$\{live_base_matches\[0\]\}"/, + 'Dependency Review must parse the sole validated live-base result', +); +assert.doesNotMatch( + workflow, + /read -r live_base_sha live_base_ref extra <<<"\$result"/, + 'Dependency Review must not inspect only the first line of an unchecked multi-line result', +); + +const ancestryCompareEndpoint = '/repos/${REPOSITORY}/compare/${BASE_SHA}...${HEAD_SHA}'; +const dependencyGraphCompareEndpoint = '/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}'; +const ancestryCheckIndex = workflow.indexOf(ancestryCompareEndpoint); +const dependencyGraphCheckIndex = workflow.indexOf(dependencyGraphCompareEndpoint); +assert.notEqual( + ancestryCheckIndex, + -1, + 'Dependency Review must verify the exact head relationship to the independently resolved live base', +); +assert.notEqual( + dependencyGraphCheckIndex, + -1, + 'Dependency Review must retain an explicit dependency-graph support check', +); +assert.ok( + ancestryCheckIndex < dependencyGraphCheckIndex, + 'Dependency Review must reject a stale/diverged head before interpreting dependency-graph differences', +); +assert.match( + workflow, + /comparison_status="\$\(jq -er '\.status' "\$relationship_file"\)"/, + 'Dependency Review must parse the authenticated compare-commits relationship fail closed', +); +assert.match( + workflow, + /if \[ "\$comparison_status" != "ahead" \] && \[ "\$comparison_status" != "identical" \]; then[\s\S]*?exit 1/, + 'Dependency Review must fail when the exact contributor head does not contain the live protected base', +); +assert.match( + workflow, + /base-ref: \$\{\{ steps\.resolve_live_base\.outputs\.base_sha \}\}/, + 'Dependency Review action must compare from the independently resolved live base SHA', +); +assert.match( + workflow, + /head-ref: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/, + 'Dependency Review action must compare through the exact contributor-head SHA', +); +assert.doesNotMatch( + workflow, + /status" = "403"[\s\S]*?supported=false|status" = "404"[\s\S]*?supported=false/, + 'Dependency Review must not turn unavailable comparison evidence into a passing skip', +); +assert.match( + workflow, + /persist-credentials: false/, + 'Dependency Review checkout must not persist repository credentials', +); +assert.doesNotMatch( + workflow, + /\bpull_request_target\s*:/, + 'Dependency Review must remain on the unprivileged pull_request trust boundary', +); + +console.log('✓ Dependency Review exact-head/live-base contract passed'); diff --git a/tests/unit/editor-unsaved.test.mjs b/tests/unit/editor-unsaved.test.mjs index 2fb5bd16..322ea003 100644 --- a/tests/unit/editor-unsaved.test.mjs +++ b/tests/unit/editor-unsaved.test.mjs @@ -18,6 +18,10 @@ function loadApp() { editorHasUnsavedChanges, bindGlobalEvents, closeEditor, + calculatePlannedProgressRatio, + getLastDescendantId, + getPlannedEndDateValue, + writeJsonSyncFile, state, DEFAULT_EDITOR_STATE, }; @@ -123,6 +127,10 @@ const { editorHasUnsavedChanges, bindGlobalEvents, closeEditor, + calculatePlannedProgressRatio, + getLastDescendantId, + getPlannedEndDateValue, + writeJsonSyncFile, state, DEFAULT_EDITOR_STATE, windowListeners, @@ -228,4 +236,29 @@ setConfirm(() => { closeEditor(true); assert.equal(state.editor.mode, DEFAULT_EDITOR_STATE.mode, 'force close skips confirm'); -console.log('✓ editor unsaved / beforeunload coverage tests passed'); +// --- defensive product contracts --- +// These are intentionally exercised without the optimized caller assumptions used by +// compute/render paths. CI/coverage work must not make public helpers less defensive. +assert.equal( + calculatePlannedProgressRatio('2026-01-02', '2026-01-01', '2026-01-03'), + 0.5, + 'planned progress calculates duration when an optional precomputed duration is absent', +); + +state.tasks = []; +assert.equal( + getLastDescendantId('missing-task'), + 'missing-task', + 'missing task lookup remains non-throwing and returns the requested id', +); +assert.equal( + getPlannedEndDateValue(null), + '', + 'planned-end accessor remains non-throwing for a missing task record', +); +await assert.doesNotReject( + async () => writeJsonSyncFile(), + 'JSON sync remains a no-op when the user has not connected a file handle', +); + +console.log('✓ editor unsaved / beforeunload / defensive guard coverage tests passed'); diff --git a/tests/unit/fuzz-exact-head-contract.test.mjs b/tests/unit/fuzz-exact-head-contract.test.mjs new file mode 100644 index 00000000..65235f17 --- /dev/null +++ b/tests/unit/fuzz-exact-head-contract.test.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const fuzzWorkflow = readFileSync( + new URL('../../.github/workflows/fuzz.yml', import.meta.url), + 'utf8', +); + +const exactHeadRef = 'ref: ${{ github.event.pull_request.head.sha || github.sha }}'; +const expectedShaEnv = 'EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}'; +const immutableCheckout = + 'actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0'; +const setupNodeV7 = + 'actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0'; +const deprecatedSetupNodeV4 = + 'actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af'; + +assert.equal( + fuzzWorkflow.split(immutableCheckout).length - 1, + 1, + 'the protected property-fuzz context must use the reviewed immutable checkout action', +); +assert.equal( + fuzzWorkflow.split(exactHeadRef).length - 1, + 1, + 'property fuzz must select the contributor head on pull requests and github.sha on develop pushes', +); +assert.equal( + fuzzWorkflow.split(expectedShaEnv).length - 1, + 1, + 'property fuzz must bind runtime checkout verification to the same expected revision', +); +assert.equal( + fuzzWorkflow.split('git rev-parse HEAD').length - 1, + 1, + 'property fuzz must inspect the revision that the runner actually checked out', +); +assert.equal( + fuzzWorkflow.split('test "$actual_sha" = "$EXPECTED_CHECKOUT_SHA"').length - 1, + 1, + 'property fuzz must fail closed when GitHub checks out a synthetic or otherwise unexpected revision', +); +assert.equal( + fuzzWorkflow.split('persist-credentials: false').length - 1, + 1, + 'property fuzz must not persist repository credentials after exact-head checkout', +); +assert.equal( + fuzzWorkflow.split(setupNodeV7).length - 1, + 1, + 'property fuzz must use the reviewed immutable setup-node v7 action runtime', +); +assert.equal( + fuzzWorkflow.includes(deprecatedSetupNodeV4), + false, + 'property fuzz must not regress to the deprecated setup-node v4 action runtime', +); +assert.doesNotMatch( + fuzzWorkflow, + /\bpull_request_target\s*:/, + 'exact-head fuzzing must remain on the unprivileged pull_request trust boundary', +); +assert.match( + fuzzWorkflow, + /scripts\/ci\/select_fuzz_budget\.sh/, + 'property fuzz must delegate workflow_dispatch input to the bounded selector', +); +assert.doesNotMatch( + fuzzWorkflow, + /echo\s+["']?runs=\$\{\{ github\.event\.inputs\.fuzz_runs \}\}/, + 'property fuzz must never write raw workflow_dispatch input to GITHUB_OUTPUT', +); + +const fuzzBudgetScript = fileURLToPath( + new URL('../../scripts/ci/select_fuzz_budget.sh', import.meta.url), +); +const budgetCases = [ + ['schedule', 'not-a-number', '200000'], + ['workflow_dispatch', '1', '1'], + ['workflow_dispatch', '20000', '20000'], + ['workflow_dispatch', '200000', '200000'], + ['workflow_dispatch', '', '20000'], + ['workflow_dispatch', '0', '20000'], + ['workflow_dispatch', '-1', '20000'], + ['workflow_dispatch', 'abc', '20000'], + ['workflow_dispatch', '200001', '20000'], + ['workflow_dispatch', '1\n2', '20000'], + ['workflow_dispatch', ' 10 ', '20000'], +]; +for (const [eventName, requestedRuns, expectedRuns] of budgetCases) { + const result = spawnSync( + 'bash', + [fuzzBudgetScript, eventName, requestedRuns], + { encoding: 'utf8' }, + ); + assert.equal( + result.status, + 0, + `${eventName}/${JSON.stringify(requestedRuns)} exits successfully`, + ); + assert.equal( + result.stderr, + '', + `${eventName}/${JSON.stringify(requestedRuns)} produces no stderr`, + ); + assert.equal( + result.stdout, + `${expectedRuns}\n`, + `${eventName}/${JSON.stringify(requestedRuns)} selects a bounded run count`, + ); +} + +console.log('✓ protected property fuzz exact-head, action-runtime, and dispatch-budget contracts passed'); diff --git a/tests/unit/osv-configuration-identity.test.mjs b/tests/unit/osv-configuration-identity.test.mjs new file mode 100644 index 00000000..dc7218b6 --- /dev/null +++ b/tests/unit/osv-configuration-identity.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const osvWorkflow = readFileSync( + new URL('../../.github/workflows/osvscanner.yml', import.meta.url), + 'utf8', +); + +assert.match( + osvWorkflow, + /^\s{2}osv-scan:\s*$/m, + 'OSV must preserve the protected-base osv-scan job identity so GitHub can compare the same analysis configuration across base and contributor heads', +); +assert.doesNotMatch( + osvWorkflow, + /^\s{2}scan:\s*$/m, + 'OSV must not rename the protected-base analysis job because GitHub treats that as a missing code-scanning configuration and returns neutral evidence', +); + +console.log('✓ OSV analysis configuration identity remains stable across protected base and contributor heads'); diff --git a/tests/unit/osv-fail-closed-contract.test.mjs b/tests/unit/osv-fail-closed-contract.test.mjs new file mode 100644 index 00000000..9880e933 --- /dev/null +++ b/tests/unit/osv-fail-closed-contract.test.mjs @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const osvWorkflow = readFileSync( + new URL('../../.github/workflows/osvscanner.yml', import.meta.url), + 'utf8', +); + +assert.match( + osvWorkflow, + /google\/osv-scanner-action\/osv-reporter-action@/, + 'OSV dependency comparison must retain the upstream reporter action', +); +assert.match( + osvWorkflow, + /--fail-on-vuln=true\b/, + 'OSV must fail when the contributor head introduces a vulnerability', +); +assert.doesNotMatch( + osvWorkflow, + /--fail-on-vuln=false\b/, + 'OSV must not convert newly introduced vulnerabilities into a passing gate', +); + +const osvEvidenceArtifactPin = + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1'; +assert.doesNotMatch( + osvWorkflow, + /github\/codeql-action\/upload-sarif@/, + 'OSV must not publish a second code-scanning analysis because organization code scanning is intentionally CodeQL-only', +); +assert.doesNotMatch( + osvWorkflow, + /^\s+security-events:\s+write\s*$/m, + 'OSV must remain read-only with respect to code scanning and retain SARIF as workflow evidence instead', +); +assert.doesNotMatch( + osvWorkflow, + /- name: Publish exact-head OSV SARIF to code scanning/, + 'OSV must not add a repository-local code-scanning publication path that competes with the CodeQL analysis identity', +); +assert.equal( + osvWorkflow.split(osvEvidenceArtifactPin).length - 1, + 1, + 'OSV SARIF evidence must use the reviewed immutable upload-artifact revision', +); +assert.match( + osvWorkflow, + /- name: Preserve exact-head OSV SARIF\r?\n\s+if: \$\{\{ !cancelled\(\) \}\}\r?\n\s+uses: actions\/upload-artifact@[\s\S]*?name: scopeweave-osv-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}[\s\S]*?path: results\.sarif[\s\S]*?if-no-files-found: error[\s\S]*?retention-days: 3/, + 'OSV must retain exact-head SARIF as bounded workflow evidence even when introduced vulnerabilities fail the reporter', +); + +const isolatedCheckoutPath = 'path: osv-scan-source'; +assert.equal( + osvWorkflow.split(isolatedCheckoutPath).length - 1, + 2, + 'both OSV source checkouts must be isolated below the workspace evidence files', +); +assert.equal( + osvWorkflow.split('-r\n ./osv-scan-source').length - 1, + 2, + 'base and contributor scans must inspect the same isolated source path', +); +assert.match( + osvWorkflow, + /- name: Sanitize exact contributor scan tree\r?\n\s+run: \|\r?\n\s+set -euo pipefail\r?\n\s+git -C osv-scan-source clean -ffdx\r?\n\s+test -z "\$\(git -C osv-scan-source status --porcelain\)"/, + 'OSV must remove base-only untracked artifacts before scanning the exact contributor tree', +); +for (const resultFile of ['old-results.json', 'new-results.json', 'results.sarif']) { + assert.match( + osvWorkflow, + new RegExp(`--(?:output|old|new)=${resultFile.replace('.', '\\.')}`), + `${resultFile} must remain a workspace-root evidence file outside the untrusted checkout`, + ); + assert.doesNotMatch( + osvWorkflow, + new RegExp(`--(?:output|old|new)=\\.?/?osv-scan-source/${resultFile.replace('.', '\\.')}`), + `${resultFile} must not be written inside the untrusted checkout`, + ); +} + +assert.equal( + osvWorkflow.split('continue-on-error: true').length - 1, + 2, + 'both OSV scans must continue only far enough to distinguish findings from scanner failure', +); +for (const [stepId, resultFile] of [ + ['scan-base', 'old-results.json'], + ['scan-head', 'new-results.json'], +]) { + assert.equal( + osvWorkflow.split(`id: ${stepId}`).length - 1, + 1, + `${stepId} must expose the scanner step outcome before continue-on-error rewrites its conclusion`, + ); + assert.equal( + osvWorkflow.split(`if: \${{ steps.${stepId}.outcome == 'failure' }}`).length - 1, + 1, + `${stepId} must run a completion guard whenever the scanner reports failure`, + ); + assert.equal( + osvWorkflow.split(`RESULT_FILE: ${resultFile}`).length - 1, + 1, + `${stepId} must bind its completion guard to ${resultFile}`, + ); +} + +const completionGuardPattern = /node --input-type=module <<'NODE'\n([\s\S]*?)\n\s+NODE/g; +const completionGuards = [...osvWorkflow.matchAll(completionGuardPattern)].map((match) => match[1]); +assert.equal( + completionGuards.length, + 2, + 'base and contributor failure paths must each execute a structured OSV result validator', +); + +function runCompletionGuard(guardSource, resultFile, payload) { + const workdir = mkdtempSync(join(tmpdir(), 'scopeweave-osv-guard-')); + try { + writeFileSync(join(workdir, resultFile), payload, 'utf8'); + return spawnSync( + process.execPath, + ['--input-type=module', '--eval', guardSource], + { + cwd: workdir, + env: { ...process.env, RESULT_FILE: resultFile }, + encoding: 'utf8', + }, + ); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +} + +const vulnerabilityEvidence = JSON.stringify({ + results: [{ + packages: [{ + package: { ecosystem: 'npm', name: 'example-package', version: '1.0.0' }, + vulnerabilities: [{ id: 'OSV-TEST-1' }], + }], + }], +}); +const ambiguousFailureEvidence = [ + ['malformed JSON', '{'], + ['missing results array', JSON.stringify({})], + ['empty results array', JSON.stringify({ results: [] })], + ['finding-free results', JSON.stringify({ results: [{ packages: [] }] })], +]; + +for (const [index, guardSource] of completionGuards.entries()) { + const resultFile = index === 0 ? 'old-results.json' : 'new-results.json'; + const accepted = runCompletionGuard(guardSource, resultFile, vulnerabilityEvidence); + assert.equal( + accepted.status, + 0, + `${resultFile} failure guard must allow structured vulnerability evidence to reach differential reporting: ${accepted.stderr}`, + ); + + for (const [label, payload] of ambiguousFailureEvidence) { + const rejected = runCompletionGuard(guardSource, resultFile, payload); + assert.notEqual( + rejected.status, + 0, + `${resultFile} failure guard must reject ${label} instead of treating a scanner failure as completed evidence`, + ); + } +} + +console.log('✓ OSV introduced-vulnerability, CodeQL-only code-scanning ownership, checkout-isolation, evidence-ownership, and structured scan-completion gates fail closed'); diff --git a/tests/unit/package-lock-integrity.test.mjs b/tests/unit/package-lock-integrity.test.mjs new file mode 100644 index 00000000..6a716ca7 --- /dev/null +++ b/tests/unit/package-lock-integrity.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const packageJson = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +); +const packageLock = JSON.parse( + readFileSync(new URL('../../package-lock.json', import.meta.url), 'utf8'), +); + +const packageName = '@playwright/test'; +const declaredVersion = packageJson.devDependencies?.[packageName]; +assert.equal( + typeof declaredVersion, + 'string', + `${packageName} must remain a direct devDependency`, +); + +const lockEntry = packageLock.packages?.[`node_modules/${packageName}`]; +assert.ok(lockEntry, `${packageName} must be represented in package-lock.json`); +assert.equal( + lockEntry.version, + declaredVersion, + 'Playwright lock version must match package.json', +); +assert.equal( + lockEntry.resolved, + `https://registry.npmjs.org/${packageName}/-/test-${declaredVersion}.tgz`, + 'Playwright lock metadata must retain the canonical npm registry tarball URL', +); +assert.match( + lockEntry.integrity ?? '', + /^sha512-.+/, + 'Playwright lock entry must retain sha512 integrity metadata', +); + +const yargsParserVersion = '22.0.0'; +const yargsParserLockEntry = + packageLock.packages?.['node_modules/yargs/node_modules/yargs-parser']; +assert.ok( + yargsParserLockEntry, + 'nested yargs-parser must remain represented in package-lock.json', +); +assert.equal( + yargsParserLockEntry.version, + yargsParserVersion, + 'nested yargs-parser lock version must remain pinned to the installed version', +); +assert.equal( + yargsParserLockEntry.resolved, + `https://registry.npmjs.org/yargs-parser/-/yargs-parser-${yargsParserVersion}.tgz`, + 'nested yargs-parser lock metadata must retain the canonical npm registry tarball URL', +); +assert.match( + yargsParserLockEntry.integrity ?? '', + /^sha512-.+/, + 'nested yargs-parser lock entry must retain sha512 integrity metadata', +); +assert.equal( + yargsParserLockEntry.license, + 'ISC', + 'yargs-parser 22.0.0 lock metadata must retain its published ISC license', +); + +console.log('✓ package lock preserves canonical direct-dependency metadata'); diff --git a/tests/unit/playwright-install-timeout-contract.test.mjs b/tests/unit/playwright-install-timeout-contract.test.mjs new file mode 100644 index 00000000..6df081f2 --- /dev/null +++ b/tests/unit/playwright-install-timeout-contract.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const serverTestsWorkflow = readFileSync( + new URL('../../.github/workflows/server-tests.yml', import.meta.url), + 'utf8', +); +const packageJson = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +); +const cloudE2eScript = packageJson.scripts?.['test:e2e:cloud'] ?? ''; + +assert.match( + serverTestsWorkflow, + /- name: Install Playwright \(chromium for coverage\)\r?\n\s+timeout-minutes: 10\r?\n\s+run: npx playwright install chromium(?:\r?\n|$)/, + 'the browser-coverage runtime install must be bounded and avoid apt-backed --with-deps network work in the required lane', +); +assert.match( + serverTestsWorkflow, + /- name: Install Playwright \(chromium\)\r?\n\s+timeout-minutes: 10\r?\n\s+run: npx playwright install chromium(?:\r?\n|$)/, + 'the cloud-e2e runtime install must be bounded and avoid apt-backed --with-deps network work in the required lane', +); +assert.doesNotMatch( + serverTestsWorkflow, + /npx playwright install[^\r\n]*--with-deps/, + 'required Server Tests must not re-enter the Ubuntu package-manager path that can stall on runner mirror availability', +); +assert.equal( + cloudE2eScript, + 'playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js', + 'the targeted cloud script must remain available for focused local regression work without performing its own browser install', +); +assert.match( + serverTestsWorkflow, + /- name: Cloud UI e2e\r?\n\s+run: npm run test:e2e(?:\r?\n|$)/, + 'the required cloud-e2e job must execute the complete Playwright suite so newly added regressions cannot be silently omitted', +); +assert.doesNotMatch( + serverTestsWorkflow, + /- name: Cloud UI e2e\r?\n\s+run: npm run test:e2e:cloud(?:\r?\n|$)/, + 'the required cloud-e2e job must not use the historical subset-only script', +); + +console.log('✓ Playwright installation reliability contract passed'); diff --git a/tests/unit/server-entrypoint.test.mjs b/tests/unit/server-entrypoint.test.mjs new file mode 100644 index 00000000..71c8fef7 --- /dev/null +++ b/tests/unit/server-entrypoint.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; + +const originalPort = process.env.PORT; +const originalDatabase = process.env.SCOPEWEAVE_DB; +const originalJwtSecret = process.env.SCOPEWEAVE_JWT_SECRET; +const originalOrchestratorUrl = process.env.ORCHESTRATOR_URL; +const originalConsoleLog = console.log; + +process.env.PORT = '0'; +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.ORCHESTRATOR_URL; + +const logs = []; +console.log = (...parts) => logs.push(parts.join(' ')); +let liveServer; + +try { + const entrypoint = await import('../../server/server.mjs'); + + assert.equal( + typeof entrypoint.resolvePort, + 'function', + 'the production entrypoint exposes deterministic port validation for direct regression coverage', + ); + assert.equal(entrypoint.resolvePort('0'), 0, 'port 0 remains valid for an ephemeral test listener'); + assert.equal(entrypoint.resolvePort('65535'), 65535, 'the highest TCP port remains valid'); + assert.equal(entrypoint.resolvePort(''), 8787, 'blank configuration falls back to the default port'); + assert.equal(entrypoint.resolvePort(' '), 8787, 'whitespace-only configuration falls back to the default port'); + assert.equal(entrypoint.resolvePort(undefined), 8787, 'missing configuration falls back to the default port'); + assert.equal(entrypoint.resolvePort('3.5'), 8787, 'fractional ports fail closed to the default'); + assert.equal(entrypoint.resolvePort('-1'), 8787, 'negative ports fail closed to the default'); + assert.equal(entrypoint.resolvePort('65536'), 8787, 'out-of-range ports fail closed to the default'); + + liveServer = entrypoint.server; + assert.equal( + typeof liveServer?.close, + 'function', + 'the production entrypoint exposes its listener so lifecycle tests and operators can close it cleanly', + ); + if (!liveServer.listening) await once(liveServer, 'listening'); + + const address = liveServer.address(); + assert.ok(address && typeof address === 'object', 'the production listener reports its bound address'); + assert.ok(address.port > 0, 'port 0 resolves to a real ephemeral listener port'); + + const response = await fetch(`http://127.0.0.1:${address.port}/api/health`); + assert.equal(response.status, 200, 'the real entrypoint serves the health endpoint'); + assert.deepEqual(await response.json(), { ok: true }, 'the live health response keeps its public contract'); + assert.match( + logs.join('\n'), + new RegExp(`ScopeWeave API listening on http://localhost:${address.port}`), + 'the startup callback reports the actual bound listener port', + ); +} finally { + if (liveServer?.listening) { + await new Promise((resolve, reject) => { + liveServer.close((error) => (error ? reject(error) : resolve())); + }); + } + console.log = originalConsoleLog; + if (originalPort === undefined) delete process.env.PORT; + else process.env.PORT = originalPort; + if (originalDatabase === undefined) delete process.env.SCOPEWEAVE_DB; + else process.env.SCOPEWEAVE_DB = originalDatabase; + if (originalJwtSecret === undefined) delete process.env.SCOPEWEAVE_JWT_SECRET; + else process.env.SCOPEWEAVE_JWT_SECRET = originalJwtSecret; + if (originalOrchestratorUrl === undefined) delete process.env.ORCHESTRATOR_URL; + else process.env.ORCHESTRATOR_URL = originalOrchestratorUrl; +} + +console.log('✓ server entrypoint lifecycle and coverage regression passed'); diff --git a/tests/unit/static-coverage-evidence.test.mjs b/tests/unit/static-coverage-evidence.test.mjs index 268517ab..d32c7a97 100644 --- a/tests/unit/static-coverage-evidence.test.mjs +++ b/tests/unit/static-coverage-evidence.test.mjs @@ -2,24 +2,53 @@ // Run: node tests/unit/static-coverage-evidence.test.mjs import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); const script = path.join(root, 'scripts/ci/static_coverage_evidence.mjs'); -function run(args) { +function run(args, cwd = root) { return spawnSync(process.execPath, [script, ...args], { - cwd: root, + cwd, encoding: 'utf8', }); } +function git(args, cwd) { + const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); + assert.equal(result.status, 0, `git ${args.join(' ')} failed: ${result.stderr}`); +} + // Happy path used by check:python-docstrings / OpenCode docstring gate. const ok = run(['docstrings']); assert.equal(ok.status, 0, `docstrings exit: ${ok.status}\n${ok.stderr}`); assert.match(ok.stdout, /not applicable/i, 'docstrings path prints N/A message'); +// The fail-closed branch must detect tracked runtime Python, while allowing +// explicitly scoped CI/test helpers. A temporary index exercises the same +// git-ls-files contract without mutating the real working tree. +const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), 'scopeweave-docstrings-')); +try { + mkdirSync(path.join(fixtureRoot, 'scripts', 'ci'), { recursive: true }); + mkdirSync(path.join(fixtureRoot, 'tests', 'config'), { recursive: true }); + writeFileSync(path.join(fixtureRoot, 'runtime.py'), 'def runtime():\n return 1\n'); + writeFileSync(path.join(fixtureRoot, 'scripts', 'ci', 'helper.py'), 'def helper():\n return 1\n'); + writeFileSync(path.join(fixtureRoot, 'tests', 'config', 'fixture.py'), 'VALUE = 1\n'); + git(['init', '--quiet'], fixtureRoot); + git(['add', 'runtime.py', 'scripts/ci/helper.py', 'tests/config/fixture.py'], fixtureRoot); + + const runtimePython = run(['docstrings'], fixtureRoot); + assert.equal(runtimePython.status, 1, 'tracked runtime Python fails the applicability gate closed'); + assert.match(runtimePython.stderr, /runtime\.py/); + assert.doesNotMatch(runtimePython.stderr, /scripts\/ci\/helper\.py/); + assert.doesNotMatch(runtimePython.stderr, /tests\/config\/fixture\.py/); +} finally { + rmSync(fixtureRoot, { recursive: true, force: true }); +} + // Usage / invalid mode must fail closed (covers the else branch). const bad = run(['coverage']); assert.equal(bad.status, 2, 'invalid mode → exit 2'); diff --git a/tests/unit/static-stylesheet-serving.test.mjs b/tests/unit/static-stylesheet-serving.test.mjs new file mode 100644 index 00000000..00eb016a --- /dev/null +++ b/tests/unit/static-stylesheet-serving.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const indexHtml = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); +const serverApp = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); +const staticDockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8'); +const serverDockerfile = readFileSync(new URL('../../Dockerfile.server', import.meta.url), 'utf8'); +const pagesWorkflow = readFileSync(new URL('../../.github/workflows/pages.yml', import.meta.url), 'utf8'); + +function linkedStylesheets(html) { + return [...html.matchAll(/]*>/gi)] + .map(([tag]) => { + const rel = tag.match(/\brel=["']([^"']+)["']/i)?.[1] ?? ''; + const href = tag.match(/\bhref=["']([^"']+\.css)["']/i)?.[1] ?? null; + const isStylesheet = rel.split(/\s+/).some((token) => token.toLowerCase() === 'stylesheet'); + return isStylesheet ? href : null; + }) + .filter(Boolean); +} + +function escaped(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +test('every planner stylesheet is shipped on every production serve path', () => { + const stylesheets = linkedStylesheets(indexHtml); + assert.notEqual(stylesheets.length, 0, 'the planner links at least one production stylesheet'); + + for (const asset of stylesheets) { + const assetPattern = escaped(asset); + assert.equal( + serverApp.includes(`'/${asset}': ['${asset}', 'text/css; charset=utf-8']`), + true, + `SaaS strict static allowlist maps ${asset} to itself with the CSS MIME type`, + ); + assert.match( + staticDockerfile, + new RegExp(`^COPY [^\\r\\n]*\\b${assetPattern}\\b[^\\r\\n]* /usr/share/nginx/html/$`, 'm'), + `static Docker image copy command ships ${asset}`, + ); + assert.match( + serverDockerfile, + new RegExp(`^COPY [^\\r\\n]*\\b${assetPattern}\\b[^\\r\\n]* \\./$`, 'm'), + `SaaS Docker image copy command ships ${asset}`, + ); + assert.match( + pagesWorkflow, + new RegExp(`^\\s*cp [^\\r\\n]*\\b${assetPattern}\\b[^\\r\\n]* _site/$`, 'm'), + `GitHub Pages staging command ships ${asset}`, + ); + } +}); diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index c0aa79a0..d13dd32c 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; const indexHtml = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); +const stylesCss = readFileSync(new URL('../../styles.css', import.meta.url), 'utf8'); const toastStateCss = readFileSync(new URL('../../toast-state.css', import.meta.url), 'utf8'); const cloudSyncJs = readFileSync(new URL('../../cloud-sync.js', import.meta.url), 'utf8'); @@ -62,3 +63,20 @@ test('cloud toast state is visibly rendered by a shipped stylesheet', () => { 'the shipped cloud toast state becomes visually observable', ); }); + +test('modal overflow behavior stays with modal layout styles', () => { + const modalScrollRule = stylesCss.match(/\.modal-panel:not\(\.gantt-panel\)\s*\{[^}]*\}/s)?.[0] ?? ''; + assert.notEqual(modalScrollRule, '', 'styles.css owns the non-Gantt modal scrolling rule'); + assert.match(modalScrollRule, /\boverflow-y\s*:\s*auto\s*;/, 'non-Gantt dialogs remain vertically scrollable'); + assert.match(modalScrollRule, /\boverscroll-behavior\s*:\s*contain\s*;/, 'non-Gantt dialogs contain scroll chaining'); + assert.doesNotMatch( + modalScrollRule, + /-webkit-overflow-scrolling\s*:/, + 'non-Gantt modal scrolling does not depend on the obsolete WebKit overflow extension', + ); + assert.doesNotMatch( + toastStateCss, + /\.modal-panel:not\(\.gantt-panel\)/, + 'toast-state.css remains scoped to toast presentation rather than modal layout', + ); +}); diff --git a/tests/unit/workflow-exact-head-contract.test.mjs b/tests/unit/workflow-exact-head-contract.test.mjs new file mode 100644 index 00000000..5bb86633 --- /dev/null +++ b/tests/unit/workflow-exact-head-contract.test.mjs @@ -0,0 +1,292 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const serverTestsWorkflow = readFileSync( + new URL('../../.github/workflows/server-tests.yml', import.meta.url), + 'utf8', +); +const codeqlWorkflow = readFileSync( + new URL('../../.github/workflows/codeql-required.yml', import.meta.url), + 'utf8', +); +const osvWorkflow = readFileSync( + new URL('../../.github/workflows/osvscanner.yml', import.meta.url), + 'utf8', +); +const packageJson = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +); +const serverCoverageScript = packageJson.scripts?.['test:coverage:server'] ?? ''; +const browserCoverageScript = packageJson.scripts?.['test:coverage:browser'] ?? ''; +const coverageCasesScript = packageJson.scripts?.['test:coverage:cases'] ?? ''; + +const exactHeadRef = 'ref: ${{ github.event.pull_request.head.sha || github.sha }}'; +const expectedShaEnv = 'EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}'; +const codeqlActionV4378Sha = 'db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28'; +const supersededCodeqlActionV4362Sha = '8aad20d150bbac5944a9f9d289da16a4b0d87c1e'; + +assert.equal( + serverTestsWorkflow.split(exactHeadRef).length - 1, + 2, + 'each Server Tests checkout must select the contributor head on PRs and github.sha on develop pushes', +); +assert.equal( + serverTestsWorkflow.split(expectedShaEnv).length - 1, + 2, + 'each Server Tests job must bind its runtime verification to the same expected SHA', +); +assert.equal( + serverTestsWorkflow.split('git rev-parse HEAD').length - 1, + 2, + 'each Server Tests job must inspect the commit it actually checked out', +); +assert.equal( + serverTestsWorkflow.split('persist-credentials: false').length - 1, + 2, + 'exact-head checkout must not regress credential persistence hardening', +); +assert.doesNotMatch( + serverTestsWorkflow, + /\bpull_request_target\s*:/, + 'exact-head testing must not gain the privileged pull_request_target trust context', +); +assert.match( + serverTestsWorkflow, + /- name: Install Playwright \(chromium for coverage\)\r?\n\s+timeout-minutes: 10\r?\n\s+run: npx playwright install chromium\r?\n[\s\S]*?- name: Exact owned production coverage/, + 'the unit-and-api coverage lane must install the real Chromium runtime with its bounded non-apt path before browser coverage executes', +); +assert.match( + serverTestsWorkflow, + /- name: Exact owned production coverage\r?\n\s+id: coverage\r?\n\s+run: npm run test:coverage\b/, + 'Server Tests must execute and identify the exact-head owned-production coverage gate', +); +assert.match( + serverTestsWorkflow, + /- name: Coverage failure diagnostics[\s\S]*?if: \$\{\{ failure\(\) && steps\.coverage\.conclusion == 'failure' \}\}[\s\S]*?node scripts\/ci\/coverage_diagnostics\.mjs "\$report"/, + 'coverage diagnostics must run only when the exact coverage step itself fails', +); +assert.match( + serverTestsWorkflow, + /for report in coverage\/coverage-final\.json coverage\/browser-coverage-final\.json/, + 'coverage failure diagnostics must inspect the actual server and browser Istanbul reports emitted by the coverage producers', +); +assert.match( + serverTestsWorkflow, + /if \[ -f "\$report" \]; then[\s\S]*?node scripts\/ci\/coverage_diagnostics\.mjs "\$report"/, + 'coverage diagnostics must tolerate a server-side failure before the browser report exists', +); +const coverageArtifactPin = + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1'; +assert.equal( + serverTestsWorkflow.split(coverageArtifactPin).length - 1, + 1, + 'coverage failure evidence must use the reviewed immutable upload-artifact revision', +); +assert.match( + serverTestsWorkflow, + /- name: Preserve exact coverage failure evidence[\s\S]*?if: \$\{\{ failure\(\) && steps\.coverage\.conclusion == 'failure' \}\}[\s\S]*?name: scopeweave-coverage-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}[\s\S]*?coverage\/coverage-final\.json[\s\S]*?coverage\/coverage-summary\.json[\s\S]*?coverage\/browser-coverage-final\.json[\s\S]*?coverage\/browser-coverage-summary\.json[\s\S]*?if-no-files-found: error[\s\S]*?retention-days: 3/, + 'failed coverage runs must retain exact server and browser Istanbul evidence only for coverage-step failures', +); +assert.match( + serverTestsWorkflow, + /- name: Public docstring gate[\s\S]*?run: npm run check:python-docstrings\b/, + 'Server Tests must execute the public docstring applicability gate', +); +for (const requiredCoverageOption of [ + '--all', + '--check-coverage', + '--per-file', + '--lines 100', + '--functions 100', + '--branches 100', + '--statements 100', +]) { + assert.equal( + serverCoverageScript.includes(requiredCoverageOption), + true, + `test:coverage:server must enforce ${requiredCoverageOption}`, + ); +} +assert.equal( + coverageCasesScript, + 'npm run test:unit && npm run test:api', + 'the exact server coverage case set must continue executing both unit and API suites', +); +assert.doesNotMatch( + serverTestsWorkflow, + /^\s+run: npm run test:(?:unit|api)\s*$/m, + 'Server Tests must not execute unit or API suites outside the exact coverage gate when coverage already owns those cases', +); +assert.equal( + browserCoverageScript, + 'node scripts/ci/browser_coverage.mjs', + 'test:coverage:browser must execute the repository-owned real-browser collector', +); + +assert.match( + codeqlWorkflow, + /name:\s*Analyze \(\$\{\{ matrix\.language \}\}\)/, + 'CodeQL must continue publishing the two protected-branch Analyze (...) required contexts', +); +assert.match( + codeqlWorkflow, + /- javascript-typescript\s*[\r\n]+\s*- python/, + 'CodeQL must analyze both JavaScript/TypeScript and Python', +); +assert.equal( + codeqlWorkflow.split(exactHeadRef).length - 1, + 1, + 'CodeQL checkout must select the exact contributor head on pull requests', +); +assert.equal( + codeqlWorkflow.split(expectedShaEnv).length - 1, + 1, + 'CodeQL verification must bind to the exact expected SHA', +); +assert.equal( + codeqlWorkflow.split('git rev-parse HEAD').length - 1, + 1, + 'CodeQL must inspect the commit it actually checked out', +); +assert.equal( + codeqlWorkflow.split('test "$actual_sha" = "$EXPECTED_CHECKOUT_SHA"').length - 1, + 1, + 'CodeQL must fail when the actual checkout differs from the expected SHA', +); +assert.equal( + codeqlWorkflow.split('persist-credentials: false').length - 1, + 1, + 'CodeQL exact-head checkout must not persist repository credentials', +); +assert.equal( + codeqlWorkflow.split(`github/codeql-action/init@${codeqlActionV4378Sha} # v4.37.8`).length - 1, + 1, + 'CodeQL initialization must use the reviewed immutable v4.37.8 action revision', +); +assert.equal( + codeqlWorkflow.split(`github/codeql-action/analyze@${codeqlActionV4378Sha} # v4.37.8`).length - 1, + 1, + 'CodeQL analysis must use the reviewed immutable v4.37.8 action revision', +); +assert.equal( + codeqlWorkflow.includes(supersededCodeqlActionV4362Sha), + false, + 'CodeQL Required must not regress to the superseded v4.36.2 action revision', +); +assert.match( + codeqlWorkflow, + /\bupload:\s*never\b/, + 'required-context CodeQL must analyze locally without conflicting with repository default setup SARIF ownership', +); +assert.doesNotMatch( + codeqlWorkflow, + /\bpull_request_target\s*:/, + 'CodeQL must remain on the unprivileged pull_request trust boundary', +); + +const liveBaseRef = 'ref: ${{ github.event.pull_request.base.ref }}'; +const liveBaseRefEnv = 'BASE_REF: ${{ github.event.pull_request.base.ref }}'; +const osvExactHeadRef = 'ref: ${{ github.event.pull_request.head.sha }}'; +const expectedHeadShaEnv = 'EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}'; +const osvScannerV251Pin = + 'google/osv-scanner-action/osv-scanner-action@6e4298ebc4db23e847df9b2e2de2939d6f066c67 # v2.5.1'; +const osvReporterV251Pin = + 'google/osv-scanner-action/osv-reporter-action@6e4298ebc4db23e847df9b2e2de2939d6f066c67 # v2.5.1'; + +assert.match( + osvWorkflow, + /^\s{2}osv-scan:\s*$/m, + 'OSV must preserve the protected-base osv-scan job identity used by code-scanning comparisons', +); +assert.equal( + osvWorkflow.split(liveBaseRef).length - 1, + 1, + 'OSV baseline checkout must resolve the live protected base ref instead of trusting the PR base snapshot SHA', +); +assert.equal( + osvWorkflow.split(liveBaseRefEnv).length - 1, + 1, + 'OSV baseline evidence must identify the protected base ref whose live tip was resolved by checkout', +); +assert.doesNotMatch( + osvWorkflow, + /github\.event\.pull_request\.base\.sha/, + 'OSV must not treat the historical pull-request base SHA snapshot as the current protected base tip', +); +assert.equal( + osvWorkflow.split(osvExactHeadRef).length - 1, + 1, + 'OSV must explicitly check out the exact contributor head for the candidate scan', +); +assert.match( + osvWorkflow, + /- name: Checkout exact contributor revision[\s\S]*?with:\s*[\r\n]+\s*ref: \$\{\{ github\.event\.pull_request\.head\.sha \}\}[\r\n]+\s*persist-credentials: false[\r\n]+\s*clean: false/, + 'OSV contributor checkout must preserve the live-base old-results.json across the second checkout', +); +assert.equal( + osvWorkflow.split('persist-credentials: false').length - 1, + 2, + 'both OSV checkouts must avoid persisting repository credentials', +); +assert.equal( + osvWorkflow.split('git rev-parse HEAD').length - 1, + 2, + 'OSV must record the live-base revision it resolved and verify the contributor commit it actually scans', +); +assert.equal( + osvWorkflow.split(expectedHeadShaEnv).length - 1, + 1, + 'OSV candidate verification must bind to the pull-request contributor SHA', +); +assert.doesNotMatch( + osvWorkflow, + /osv-scanner-reusable-pr\.yml/, + 'OSV must not delegate candidate selection to the reusable workflow that scans synthetic GITHUB_SHA merge commits', +); +assert.equal( + osvWorkflow.split(osvScannerV251Pin).length - 1, + 2, + 'OSV must scan both immutable revisions with the direct action pinned by upstream v2.5.1', +); +assert.equal( + osvWorkflow.split(osvReporterV251Pin).length - 1, + 1, + 'OSV must compare introduced vulnerabilities with the reporter pinned by upstream v2.5.1', +); +assert.doesNotMatch( + osvWorkflow, + /8dc09193bb540e09b23da07ad7e30bd33bf87018|# v2\.3\.8/, + 'OSV must not regress to the superseded v2.3.8 action revision or annotation', +); +assert.equal( + osvWorkflow.split(coverageArtifactPin).length - 1, + 1, + 'OSV exact-head SARIF evidence must use the reviewed immutable upload-artifact revision', +); +assert.match( + osvWorkflow, + /- name: Preserve exact-head OSV SARIF\r?\n\s+if: \$\{\{ !cancelled\(\) \}\}\r?\n\s+uses: actions\/upload-artifact@[\s\S]*?name: scopeweave-osv-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}[\s\S]*?path: results\.sarif[\s\S]*?if-no-files-found: error[\s\S]*?retention-days: 3/, + 'OSV must retain generated exact-head SARIF evidence even when the reporter fails on an introduced vulnerability, while still skipping cancelled runs', +); +assert.doesNotMatch( + osvWorkflow, + /github\/codeql-action\/upload-sarif@/, + 'OSV must not publish a second code-scanning analysis while organization code scanning is CodeQL-only', +); +assert.doesNotMatch( + osvWorkflow, + /^\s+security-events:\s+write\s*$/m, + 'OSV must remain read-only with respect to code scanning and retain SARIF as workflow evidence instead', +); +assert.doesNotMatch( + osvWorkflow, + /- name: Publish exact-head OSV SARIF to code scanning/, + 'OSV must not add a repository-local code-scanning publication path that competes with the CodeQL analysis identity', +); +assert.doesNotMatch( + osvWorkflow, + /\bpull_request_target\s*:/, + 'OSV must remain on the unprivileged pull_request trust boundary', +); + +console.log('✓ Server Tests, required CodeQL, and OSV exact-head/live-base workflow contracts passed'); \ No newline at end of file diff --git a/toast-state.css b/toast-state.css index 3cef049f..b253f29a 100644 --- a/toast-state.css +++ b/toast-state.css @@ -6,3 +6,10 @@ opacity: 1; transform: translateY(0); } + +/* Dynamic cloud/team close buttons delegate clicks from their modal roots. + * Their decorative icon must not become the pointer target, otherwise the + * button's data-* close marker is bypassed and the modal remains open. */ +.close-button > [aria-hidden="true"] { + pointer-events: none; +} \ No newline at end of file