diff --git a/CHANGELOG.md b/CHANGELOG.md index e84f41f8..787ee51b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,9 +32,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added regression coverage that prevents array-valued passwords from being coerced into valid credentials. - Updated Hono runtime dependencies to patched supported releases. +- Sanitized Clearfolio submission, status, and artifact-link transport failures + so network details and downstream response text cannot reach browser or + diagnostic payloads; rejected unknown or whitespace-padded conversion states + and malformed, unsupported-scheme, or HTTPS-downgrade artifact links. +- Centralized session JWT verification and database-backed `token_version` + revocation across bearer middleware, calendar feeds, server-sent events, and + attachment-view URL transports. +- Made session-token minting fail closed unless the subject, token version, and + lifetime are bounded safe integers, and capped general session lifetime at + seven days so internal callers cannot mint excessive or numerically unsafe + credentials. +- Rejected signed session JWTs with a non-HS256/JWT header, non-object claims, + missing or invalid subject/expiry, or a missing, Boolean, fractional, + negative, unsafe, or otherwise invalid token-version claim before user lookup. +- Added cross-device regression coverage proving that `logout-all` rejects stale + tokens on bearer, calendar, SSE, and attachment-view transports while the + replacement token continues through the same authentication boundary. ### Changed +- Attachment-list status refresh now removes the per-row database lookup, + uses a configurable bounded worker pool with per-item abortable timeouts and + a request-wide latency budget, preserves stale status after downstream, + timeout, malformed-response, and persistence failures, excludes internal + conversion identifiers from responses, reports attempted, changed, failed, + skipped-data, and deferred-budget counters separately, and exposes fixed + low-cardinality timeout, lookup, validation, and persistence failure counters. - 프로젝트 이름 입력 필드에 입력 예시(placeholder)를 추가하여 사용자 편의성을 개선했습니다. - 데이터 테이블의 반복되는 액션 버튼에 컨텍스트 정보(작업명)를 포함한 명시적인 ARIA 레이블을 추가하고, 유효성 검사 에러를 폼 필드에 연결하여 접근성을 개선했습니다. - `createGanttBarElement`, `renderGantt`, `buildWeekdayTimeline`에서 반복적으로 호출되던 `compareDateStrings`를 직접적인 문자열 비교 연산(`>=`, `<=`)으로 교체하여 O(N*D) 복잡도의 캐시 스레싱과 정규식 검사를 방지했습니다. @@ -61,4 +85,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. \ No newline at end of file +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. diff --git a/docs/deploy.md b/docs/deploy.md index db9d4c56..0cfdb799 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -41,8 +41,84 @@ persists the database in the `scopeweave-data` volume. | `ORCHESTRATOR_TOKEN` | with URL | orchestrator Bearer 토큰 (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | | `CLEARFOLIO_URL` | for 산출물 viewer | Clearfolio 문서 뷰어 백엔드 주소. Unset → built-in mock (dev/test). | | `CLEARFOLIO_HMAC_SECRET` | optional | Signs tenant-claim headers (`clearfolio.tenant-claims.hmac-secret`와 동일 값). | +| `SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY` | no (default 8, maximum 32) | Maximum concurrent Clearfolio status lookups during one attachment-list request. Invalid values fall back to 8; values above 32 are clamped. | +| `SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS` | no (default 3000, maximum 30000) | Hard caller-side timeout for each Clearfolio status lookup. The AbortSignal is also forwarded downstream. | +| `SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS` | no (default 5000, maximum 60000) | Wall-clock budget for the entire best-effort refresh pass. Work not started before the deadline is deferred to a later list request. | | `SCOPEWEAVE_RATE_LIMIT_MAX` (+ `SCOPEWEAVE_RATE_LIMIT_WINDOW_MS`) | recommended | Per-IP fixed-window rate limiting (429 + Retry-After). Off when unset. | +## Attachment status refresh operations + +The attachment-list API reads `job_id` in its initial project-scoped query and +refreshes only `PENDING` or `RUNNING` rows through a bounded worker pool. It +never performs one database lookup per row. A timeout, unsuccessful HTTP +response, malformed response body, invalid status value, or persistence failure +is isolated to that attachment: ScopeWeave preserves its previously stored +status and still returns the rest of the list. Internal Clearfolio job +identifiers are removed before JSON serialization. + +The process metrics endpoint exposes cumulative counters for operational +monitoring: + +- `attachmentStatusRefreshAttempted` +- `attachmentStatusRefreshChanged` +- `attachmentStatusRefreshFailed` +- `attachmentStatusRefreshSkipped` +- `attachmentStatusRefreshDeferred` +- `attachmentStatusRefreshTimeoutFailures` +- `attachmentStatusRefreshDownstreamLookupFailures` +- `attachmentStatusRefreshInvalidStatusFailures` +- `attachmentStatusRefreshPersistenceFailures` + +`skipped` counts pending rows that cannot be refreshed because their persisted +Clearfolio job identifier is absent or blank. `deferred` counts valid work that +was not started before the request-wide latency budget expired. Keeping these +causes separate prevents malformed stored data from being mistaken for +insufficient concurrency or downstream latency. + +The four failure-category counters are fixed, low-cardinality diagnostics whose +sum equals the aggregate `failed` delta for a refresh pass. They contain no job +identifier, URL, downstream response text, or raw exception. The Prometheus +representation uses corresponding `scopeweave_attachment_status_refresh_*` +names. Alert on a sustained increase in `failed`, use the category counters for +triage, investigate `skipped` as a data-quality or migration defect, and compare +`deferred` with list traffic before increasing concurrency or the request-wide +budget. Raise limits conservatively because every worker consumes a downstream +Clearfolio connection; horizontal ScopeWeave replicas multiply the aggregate +concurrency. + +### Rollout and alerting + +Roll this behavior out behind a canary replica before raising limits across the +fleet. Start with concurrency `2`, the default per-item timeout, and a budget no +longer than the attachment-list latency objective. Compare the canary with the +previous version using the same tenant and Clearfolio environment. + +Derive rates from counter deltas over the same observation window: + +```text +failure_ratio = failed_delta / max(attempted_delta, 1) +skipped_ratio = skipped_delta / max(attempted_delta + skipped_delta, 1) +deferred_ratio = deferred_delta / max(attempted_delta + deferred_delta, 1) +change_ratio = changed_delta / max(attempted_delta, 1) +``` + +A high `failure_ratio` indicates downstream, timeout, malformed-response, or +persistence errors and should block rollout. A non-zero `skipped_ratio` indicates +an attachment persistence or migration defect and should be investigated before +changing worker limits. A high `deferred_ratio` indicates that the request-wide +budget is protecting latency at the cost of freshness; first inspect Clearfolio +latency and attachment-list size before increasing worker count or budget. Track +attachment-list p50, p95, and p99 latency beside these ratios. Thresholds must be +derived from observed production baselines and an agreed service-level objective +rather than copied from development data. + +Rollback is configuration-first: reduce concurrency and budget without changing +the persisted attachment statuses. If the application version must be rolled +back, the previous implementation can read the unchanged schema; no migration +is required by this feature. Never place Clearfolio job IDs, HMAC material, +request URLs containing credentials, or downstream response bodies in metrics, +logs, traces, or alert annotations. + ## Data & scale path - **Dev / single node**: `node:sqlite` on a persistent volume (this setup). Simple, no external DB. diff --git a/docs/doctoring/attachment-status-refresh.md b/docs/doctoring/attachment-status-refresh.md new file mode 100644 index 00000000..eca86521 --- /dev/null +++ b/docs/doctoring/attachment-status-refresh.md @@ -0,0 +1,132 @@ +# Attachment status refresh and Clearfolio boundary: evidence and design record + +## Decision + +Attachment listing is a buyer-visible read path and must remain responsive when +Clearfolio is slow, unavailable, or returns malformed data. ScopeWeave therefore +refreshes only stale conversion states through a reusable bounded worker module +that is independent of Hono and SQLite. The Clearfolio HTTP adapter separately +owns downstream transport, tenant headers, response-shape validation, and +artifact-link validation. + +The implementation: + +1. includes the internal conversion identifier in the initial project-scoped + database query, eliminating one lookup per returned row; +2. limits per-request downstream concurrency through a configurable worker pool; +3. applies a caller-side timeout to every Clearfolio request and forwards the + same `AbortSignal` to `fetch`; +4. applies a wall-clock budget to the complete best-effort refresh pass and + defers valid work that cannot start within that budget; +5. counts pending rows with absent or blank conversion identifiers as skipped + data-quality cases rather than misclassifying them as latency deferrals; +6. validates conversion states against the exact `PENDING`, `RUNNING`, + `SUCCEEDED`, and `FAILED` contract rather than trimming or accepting unknown + strings; +7. preserves the previously stored status after timeout, transport, HTTP, + malformed-response, invalid-state, diagnostic, or persistence failure; +8. persists only changed states; +9. strips internal conversion identifiers from both upload and list JSON before + they cross the browser-facing API boundary; +10. publishes attempted, changed, failed, skipped, and deferred counters without + sensitive downstream payloads or identifiers; +11. publishes fixed timeout, downstream-lookup, invalid-status, and persistence + failure counters so operators can distinguish failure modes without labels or + raw diagnostic data; +12. replaces raw network and downstream response messages with fixed + operation-level submission, status, and artifact-link errors; +13. validates successful submission and artifact-link JSON before property use; +14. accepts artifact links only when they resolve to HTTP(S), and prevents an + HTTPS Clearfolio deployment from returning an HTTP downgrade link; and +15. keeps the in-memory development adapter and HMAC tenant-claim contract under + focused tests so MSA extraction cannot silently change interoperability. + +## Standards and threat rationale + +OWASP API Security Top 10 2023 identifies unrestricted resource consumption as a +risk when APIs do not bound client interactions or resources. The per-request +worker cap, per-item timeout, request-wide budget, and existing endpoint rate +limit are complementary controls: they bound one list operation, one downstream +operation, the complete refresh pass, and repeated client traffic respectively. + +OWASP API10:2023 identifies unsafe consumption of third-party APIs when an +integrating service fails to validate returned data, limit processing resources, +or implement timeouts. ScopeWeave therefore treats Clearfolio responses as +untrusted input even after HTTP success. Rejected JSON, null, primitives, +arrays, missing or non-string fields, empty or whitespace-padded states, unknown +states, malformed links, unsupported URI schemes, and HTTPS downgrade links fail +closed without changing persisted attachment state. + +The browser-facing API may serialize adapter errors, so the adapter never copies +DNS names, socket errors, downstream response text, private URLs, or parser +messages into thrown errors. Operation name and HTTP status are the maximum +external diagnostic detail. The refresh engine records only four fixed failure +categories. Detailed downstream diagnostics belong in a separately redacted +operator channel, not a client response, metric label, audit payload, or trace +attribute. + +The worker and validation contract is placed in framework- and database-neutral +modules so a future MSA extraction can reuse the same behavior with another HTTP +adapter or persistence implementation. The monolith remains fully operable on +its own. Adapters must pass the same contract suite before they are considered +substitutable. + +## Verification contract + +Regression tests must prove: + +- one hundred pending rows reach but never exceed configured concurrency; +- task-filtered and project-wide lists share one refresh contract; +- unchanged states are not written; +- downstream, timeout, malformed-response, invalid-state, diagnostic, and write + failures are isolated to the affected row; +- pending rows with missing conversion identifiers are counted as skipped; +- valid unstarted work beyond the request budget is counted as deferred; +- skipped and deferred metrics remain distinct in JSON and Prometheus output; +- the four failure-category counters sum to the aggregate failure count and + never contain raw errors, identifiers, URLs, or downstream response text; +- upload responses and attachment-list responses omit the internal Clearfolio + conversion identifier while retaining the public attachment identifier and + current status; +- downstream response text and network details never appear in client JSON; +- the caller `AbortSignal` reaches Clearfolio; +- submission, status, and artifact-link non-success responses expose only fixed + operation-level errors; +- rejected JSON and every malformed successful payload branch fail closed; +- relative and absolute HTTPS links, artifact-token viewer links, and explicitly + configured local HTTP links remain supported; +- HTTPS-to-HTTP downgrade and non-HTTP(S) links are rejected; +- the mock adapter preserves uploaded bytes and status semantics; +- HMAC tenant claims use the documented newline-delimited canonical payload; +- the bounded refresh production module retains 100% statement, branch, + function, and line coverage; and +- every new shipped symbol has complete beginner-readable JSDoc. + +## Operational acceptance + +Rollout begins with a canary and conservative concurrency. Operators compare +attachment-list p50, p95, and p99 latency with refresh failure, skipped, and +deferral ratios. A high failure ratio blocks rollout and the fixed category +counters identify whether the dominant cause is timeout, downstream lookup, +invalid state, or persistence. A non-zero skipped ratio indicates a persistence +or migration defect and is investigated independently of latency. A high +deferred ratio indicates the latency budget is containing work at the cost of +freshness and requires Clearfolio latency and list-size diagnosis before +increasing resource limits. Rollback is configuration-first and requires no +schema migration. + +The rollout review also samples client error payloads, structured logs, traces, +audit exports, and alert annotations to prove that Clearfolio response bodies, +internal DNS names, signed links, HMAC material, and conversion identifiers are +absent. Horizontal replica count is multiplied by configured per-request +concurrency when assessing the downstream connection budget. + +## References + +OWASP Foundation. (2023a). *API4:2023 unrestricted resource consumption*. OWASP +API Security Top 10. +https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/ + +OWASP Foundation. (2023b). *API10:2023 unsafe consumption of APIs*. OWASP API +Security Top 10. +https://owasp.org/API-Security/editions/2023/en/0xaa-unsafe-consumption-of-apis/ diff --git a/docs/doctoring/session-revocation.md b/docs/doctoring/session-revocation.md new file mode 100644 index 00000000..d162e8f7 --- /dev/null +++ b/docs/doctoring/session-revocation.md @@ -0,0 +1,82 @@ +# Session JWT revocation: evidence and design record + +## Decision + +Every ScopeWeave transport that accepts a general session JWT uses one +fail-closed verifier. Bearer middleware, calendar feeds, server-sent events, and +attachment-view routes therefore share signature, header, claim, subject, expiry, +and database-backed revocation checks. + +The implementation: + +1. pins the compact token to an authenticated `HS256` signature and signed `JWT` + type; +2. authenticates the compact representation before interpreting the JOSE header + or claim set; +3. requires a non-array claims object, positive safe-integer subject, future + safe-integer expiry, and non-negative safe-integer token version; +4. requires the subject to exist and compares the signed token version exactly + with the current persisted version; +5. rejects malformed, forged, expired, missing-user, and stale sessions before + tenant or resource lookup; +6. caps general session minting at seven days and rejects fractional, unsafe, + non-positive, or longer lifetimes; and +7. reserves narrower and shorter authority for the opaque access-grant design in + issue #413 rather than overloading the general session JWT. + +## Standards rationale + +RFC 7519 defines a JWT claims set as a JSON object and defines `sub` and `exp` as +registered claims. ScopeWeave narrows those flexible JSON representations to +safe integers because its database identifiers and token-version comparisons are +integer security boundaries. + +RFC 8725 requires callers to perform algorithm verification, validate every +cryptographic operation, use explicit typing for new JWT uses, and apply mutually +exclusive validation rules where different token kinds coexist. ScopeWeave pins +one algorithm and one type for general sessions and does not reuse this JWT +contract for the scoped URL grants planned in issue #413. + +RFC 6750 explains that any holder of a bearer token can exercise its authority, +recommends short-lived and audience-scoped credentials, and warns against page +URL transport because browser history and server logs can expose tokens. RFC +9700 updates OAuth security best current practice and prohibits clients from +passing access tokens in URI query parameters. This pull request does not claim +to remove the existing URL transport; it makes revocation and validation +consistent until issue #413 replaces those general credentials with narrowly +scoped opaque grants and separately revocable calendar subscription secrets. + +## Verification contract + +Regression tests must prove: + +- the signer rejects invalid subject, token version, fractional lifetime, + numerically unsafe lifetime, and any general-session lifetime over seven days; +- malformed compact tokens, signatures, JOSE headers, claim-set shapes, subjects, + expiries, and token-version values fail across every transport; +- a correctly signed token for a nonexistent subject fails before resource + lookup; +- two independently minted device sessions work before revocation; +- `logout-all` invalidates both stale sessions on bearer, calendar, SSE, and + attachment-view paths; and +- the replacement session continues through the same authentication boundary. + +All changed production helpers require complete JSDoc and 100% statement, +branch, function, and line coverage before the pull request can leave Draft. + +## References + +Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Token (JWT)* (RFC +7519). Internet Engineering Task Force. https://doi.org/10.17487/RFC7519 + +Jones, M. B., & Hardt, D. (2012). *The OAuth 2.0 authorization framework: +Bearer token usage* (RFC 6750). Internet Engineering Task Force. +https://doi.org/10.17487/RFC6750 + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current +practice for OAuth 2.0 security* (BCP 240; RFC 9700). Internet Engineering Task +Force. https://doi.org/10.17487/RFC9700 + +Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current +practices* (BCP 225; RFC 8725). Internet Engineering Task Force. +https://doi.org/10.17487/RFC8725 diff --git a/package-lock.json b/package-lock.json index 079e2031..859ec2a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@hono/node-server": "^2.0.12", - "hono": "^4.12.32" + "hono": "^4.13.0" }, "devDependencies": { "@playwright/test": "1.61.1", @@ -382,9 +382,9 @@ } }, "node_modules/hono": { - "version": "4.12.32", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", - "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/package.json b/package.json index 7790e678..46d07bfb 100644 --- a/package.json +++ b/package.json @@ -10,20 +10,21 @@ }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", - "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage", + "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", - "test:unit": "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/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", - "test:coverage": "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/session-revocation.test.mjs", + "test:unit": "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/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", + "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 --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/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:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", - "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js", + "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", "test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js", "fuzz": "node --test tests/fuzz/*.mjs" }, "dependencies": { "@hono/node-server": "^2.0.12", - "hono": "^4.12.32" + "hono": "^4.13.0" }, "devDependencies": { "@playwright/test": "1.61.1", diff --git a/server/app.mjs b/server/app.mjs index 13d95e5d..450be878 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -8,6 +8,7 @@ import { db, rowid } from './db.mjs'; import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, hashApiToken } from './auth.mjs'; import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs'; import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; +import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; import { chat as orchestratorChat } from './orchestrator.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client @@ -73,7 +74,20 @@ function projectAccess(userId, projectId) { } // --- observability: in-process counters + structured request log. -const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 }; +const metrics = { + startedAt: new Date().toISOString(), + requests: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, + signups: 0, + projectsCreated: 0, + webhookDeliveries: 0, + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshDeferred: 0, +}; // Outbound webhooks: POST signed JSON to each active hook subscribed to `event`. // Fire-and-forget with a timeout, one retry on failure, and a recorded outcome @@ -993,6 +1007,31 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { // 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio // 자격이 절대 노출되지 않음. const ATTACH_MAX_BYTES = 10 * 1024 * 1024; + +const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, +); +const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, +); +const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, +); +const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, + a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; +const ATTACHMENT_LIST_FROM = + 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; +const listAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? ORDER BY a.id DESC`, +); +const listTaskAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, +); +const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', +); app.post('/api/projects/:id/attachments', requireAuth, async (c) => { const uid = c.get('user').sub; const p = projectAccess(uid, c.req.param('id')); @@ -1015,35 +1054,31 @@ app.post('/api/projects/:id/attachments', requireAuth, async (c) => { '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)); logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); - return c.json({ id: aid, jobId: job.jobId, status: job.status }); + return c.json({ id: aid, status: job.status }); }); app.get('/api/projects/:id/attachments', requireAuth, async (c) => { const uid = c.get('user').sub; const p = projectAccess(uid, c.req.param('id')); if (!p) return c.json({ error: 'not found' }, 404); + const taskId = c.req.query('taskId'); - const rows = (taskId - ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) - : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); - // PENDING 잡 상태 갱신(최선 노력) - for (const r of rows) { - if (r.status === 'PENDING' || r.status === 'RUNNING') { - try { - const jid = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(r.id).job_id; - const st = await jobStatus(p.org_id, uid, jid); - if (st !== r.status) { - db.prepare('UPDATE attachments SET status = ? WHERE id = ?').run(st, r.id); - r.status = st; - } - } catch { /* keep stale status */ } - } - } - return c.json({ attachments: rows }); + const rows = taskId + ? listTaskAttachmentsStatement.all(p.id, taskId) + : listAttachmentsStatement.all(p.id); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + budgetMs: ATTACH_STATUS_BUDGET_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments }); }); // 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). diff --git a/server/attachment_status.mjs b/server/attachment_status.mjs new file mode 100644 index 00000000..c6f9ae21 --- /dev/null +++ b/server/attachment_status.mjs @@ -0,0 +1,297 @@ +/** Default maximum concurrent Clearfolio status lookups. */ +export const ATTACHMENT_STATUS_DEFAULT_CONCURRENCY = 8; + +/** Conservative hard ceiling for operator-configured lookup concurrency. */ +export const ATTACHMENT_STATUS_MAX_CONCURRENCY = 32; + +/** Default downstream status lookup timeout in milliseconds. */ +export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000; + +/** Hard ceiling for a downstream status lookup timeout in milliseconds. */ +export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000; + +/** Default wall-clock budget for one attachment-list refresh pass. */ +export const ATTACHMENT_STATUS_DEFAULT_BUDGET_MS = 5_000; + +/** Hard ceiling for one attachment-list refresh pass. */ +export const ATTACHMENT_STATUS_MAX_BUDGET_MS = 60_000; + +/** Status values accepted from the Clearfolio conversion contract. */ +const ATTACHMENT_STATUS_VALUES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); + +/** Timeout error name used only for sanitized failure categorization. */ +const ATTACHMENT_STATUS_TIMEOUT_ERROR = 'AttachmentStatusTimeoutError'; + +/** Fixed low-cardinality failure categories safe for operational metrics. */ +const ATTACHMENT_STATUS_FAILURE_METRICS = Object.freeze({ + timeout: 'attachmentStatusRefreshTimeoutFailures', + downstream_lookup: 'attachmentStatusRefreshDownstreamLookupFailures', + invalid_status: 'attachmentStatusRefreshInvalidStatusFailures', + status_persistence: 'attachmentStatusRefreshPersistenceFailures', +}); + +/** + * Normalize a positive integer while applying a conservative upper bound. + * + * @param {unknown} value - Untrusted environment or caller value. + * @param {number} fallback - Value used for missing or invalid input. + * @param {number} maximum - Largest accepted value. + * @returns {number} A safe positive integer no greater than `maximum`. + */ +function normalizeBoundedInteger(value, fallback, maximum) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; + return Math.min(parsed, maximum); +} + +/** + * Normalize the configured attachment-status worker count. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} An integer between 1 and 32, defaulting to 8. + */ +export function normalizeAttachmentStatusConcurrency(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ); +} + +/** + * Normalize the configured Clearfolio status timeout. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} A positive timeout no greater than 30 seconds. + */ +export function normalizeAttachmentStatusTimeoutMs(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + ); +} + +/** + * Normalize the request-wide attachment refresh budget. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} A positive budget no greater than 60 seconds. + */ +export function normalizeAttachmentStatusBudgetMs(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_BUDGET_MS, + ATTACHMENT_STATUS_MAX_BUDGET_MS, + ); +} + +/** + * Read a clock dependency and reject unusable values before deadline math. + * + * @param {() => number} clock - Clock returning epoch-like milliseconds. + * @returns {number} A finite millisecond value. + * @throws {TypeError} If the clock returns a non-finite value. + */ +function readClock(clock) { + const value = clock(); + if (!Number.isFinite(value)) throw new TypeError('clock must return a finite number'); + return value; +} + +/** + * Add one refresh result to process-level operational counters. + * + * Aggregate counters preserve the public refresh result contract. Fixed + * category counters provide operator diagnostics without job identifiers, + * downstream text, URLs, or other high-cardinality labels. + * + * @param {object|undefined} metrics - Mutable process metric registry. + * @param {{attempted:number,changed:number,failed:number,skipped:number,deferred:number}} counts - Refresh result. + * @param {Record} failureCounts - Sanitized fixed-category failures. + * @returns {void} + */ +function addRefreshMetrics(metrics, counts, failureCounts) { + if (!metrics) return; + const fields = { + attachmentStatusRefreshAttempted: 'attempted', + attachmentStatusRefreshChanged: 'changed', + attachmentStatusRefreshFailed: 'failed', + attachmentStatusRefreshSkipped: 'skipped', + attachmentStatusRefreshDeferred: 'deferred', + }; + for (const [metric, count] of Object.entries(fields)) { + metrics[metric] = (Number(metrics[metric]) || 0) + counts[count]; + } + for (const [category, metric] of Object.entries(ATTACHMENT_STATUS_FAILURE_METRICS)) { + metrics[metric] = (Number(metrics[metric]) || 0) + failureCounts[category]; + } +} + +/** + * Publish a sanitized refresh-failure category without risking the request. + * + * The callback never receives a Clearfolio job identifier, URL, response body, + * or raw downstream error. A failing diagnostic sink is isolated because + * observability must not break attachment listing. + * + * @param {((event:{category:string}) => unknown)|undefined} onError - Optional diagnostic sink. + * @param {string} category - Fixed safe failure category. + * @returns {void} + */ +function reportRefreshFailure(onError, category) { + if (!onError) return; + try { + onError({ category }); + } catch { + // Diagnostics are best effort and must never fail the list response. + } +} + +/** + * Await one downstream lookup with an AbortSignal and a hard caller-side timeout. + * + * The explicit race means a non-compliant downstream adapter cannot hold a list + * response open forever even if it ignores the supplied AbortSignal. + * + * @param {() => Promise} lookup - Deferred downstream lookup. + * @param {AbortController} controller - Controller whose signal is passed downstream. + * @param {number} timeoutMs - Hard timeout in milliseconds. + * @returns {Promise} The downstream status. + */ +async function withTimeout(lookup, controller, timeoutMs) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + const error = new Error('attachment status lookup timed out'); + error.name = ATTACHMENT_STATUS_TIMEOUT_ERROR; + reject(error); + }, timeoutMs); + }); + try { + return await Promise.race([lookup(), timeout]); + } finally { + clearTimeout(timer); + } +} + +/** + * Refresh pending attachment statuses through a bounded worker pool. + * + * Rows are updated in place so the caller can serialize the refreshed public + * representation. A shared wall-clock deadline bounds the whole refresh pass; + * workers clamp each lookup timeout to the remaining request budget and mark + * unstarted rows as deferred after the deadline. Rows with missing conversion + * identifiers are counted as skipped data-quality cases. Downstream, + * validation, or persistence failures preserve stale status and never fail the + * attachment-list response. + * + * @param {Array} rows - Attachment rows containing `id`, `status`, and `jobId`. + * @param {object} options - Downstream functions, tenant identifiers, limits, and metrics. + * @param {number|string} options.orgId - ScopeWeave organization identifier. + * @param {number|string} options.userId - Requesting user identifier. + * @param {(orgId: unknown, userId: unknown, jobId: string, options: {signal: AbortSignal}) => Promise} options.jobStatus - Downstream lookup. + * @param {(status: string, attachmentId: unknown) => unknown|Promise} options.updateStatus - Changed-only persistence callback. + * @param {unknown} [options.concurrency] - Maximum concurrent lookups. + * @param {unknown} [options.timeoutMs] - Per-lookup timeout in milliseconds. + * @param {unknown} [options.budgetMs] - Request-wide refresh budget in milliseconds. + * @param {object} [options.metrics] - Mutable process metrics object. + * @param {(event:{category:string}) => unknown} [options.onError] - Sanitized diagnostic callback. + * @param {() => number} [options.now] - Injectable finite millisecond clock for deterministic tests. + * @returns {Promise<{attempted:number,changed:number,failed:number,skipped:number,deferred:number}>} Structured counters. + */ +export async function refreshAttachmentStatuses(rows, options) { + if (!Array.isArray(rows)) throw new TypeError('rows must be an array'); + if (typeof options?.jobStatus !== 'function') throw new TypeError('jobStatus must be a function'); + if (typeof options?.updateStatus !== 'function') throw new TypeError('updateStatus must be a function'); + if (options.onError !== undefined && typeof options.onError !== 'function') { + throw new TypeError('onError must be a function'); + } + if (options.now !== undefined && typeof options.now !== 'function') { + throw new TypeError('now must be a function'); + } + + const counts = { attempted: 0, changed: 0, failed: 0, skipped: 0, deferred: 0 }; + const failureCounts = Object.fromEntries( + Object.keys(ATTACHMENT_STATUS_FAILURE_METRICS).map((category) => [category, 0]), + ); + const pending = rows.filter((row) => row?.status === 'PENDING' || row?.status === 'RUNNING'); + const concurrency = normalizeAttachmentStatusConcurrency(options.concurrency); + const timeoutMs = normalizeAttachmentStatusTimeoutMs(options.timeoutMs); + const budgetMs = normalizeAttachmentStatusBudgetMs(options.budgetMs); + const clock = options.now || Date.now; + const deadline = readClock(clock) + budgetMs; + let cursor = 0; + + /** + * Process pending rows until the shared cursor is exhausted. + * + * JavaScript advances the cursor synchronously before each await, so workers + * claim distinct rows without locks and context switching stays bounded by the + * configured worker count. + * + * @returns {Promise} Resolves after this worker has no remaining row. + */ + async function worker() { + for (;;) { + const index = cursor; + cursor += 1; + if (index >= pending.length) return; + const row = pending[index]; + const remainingBudgetMs = deadline - readClock(clock); + if (remainingBudgetMs <= 0) { + counts.deferred += 1; + continue; + } + + const jobId = typeof row.jobId === 'string' ? row.jobId.trim() : ''; + if (!jobId) { + counts.skipped += 1; + continue; + } + + counts.attempted += 1; + const controller = new AbortController(); + let failureCategory = 'downstream_lookup'; + try { + const effectiveTimeoutMs = Math.max( + 1, + Math.min(timeoutMs, Math.ceil(remainingBudgetMs)), + ); + const nextStatus = await withTimeout( + () => options.jobStatus( + options.orgId, + options.userId, + jobId, + { signal: controller.signal }, + ), + controller, + effectiveTimeoutMs, + ); + failureCategory = 'invalid_status'; + if (!ATTACHMENT_STATUS_VALUES.has(nextStatus)) { + throw new Error('invalid downstream status'); + } + if (nextStatus !== row.status) { + failureCategory = 'status_persistence'; + await options.updateStatus(nextStatus, row.id); + row.status = nextStatus; + counts.changed += 1; + } + } catch (error) { + counts.failed += 1; + const category = error?.name === ATTACHMENT_STATUS_TIMEOUT_ERROR + ? 'timeout' + : failureCategory; + failureCounts[category] += 1; + reportRefreshFailure(options.onError, category); + } + } + } + + const workerCount = Math.min(concurrency, pending.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + addRefreshMetrics(options.metrics, counts, failureCounts); + return counts; +} diff --git a/server/auth.mjs b/server/auth.mjs index a16a7281..d8e147be 100644 --- a/server/auth.mjs +++ b/server/auth.mjs @@ -2,13 +2,30 @@ // Passwords: scrypt. Tokens: HS256 JWT with a PINNED algorithm (no header-alg // trust → immune to alg-confusion). This is a security boundary; do not simplify. import { scryptSync, randomBytes, timingSafeEqual, createHmac, createHash } from 'node:crypto'; +import { db } from './db.mjs'; -// Personal Access Tokens. Format: swk_. Only the SHA-256 hash is -// stored; the full secret is shown to the user exactly once at creation. +/** Maximum lifetime for a general ScopeWeave session token, in seconds. */ +const MAX_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; + +/** + * Generate a one-time-visible ScopeWeave personal access token. + * + * Only the SHA-256 hash is suitable for persistence. The `full` value must be + * shown exactly once, while `prefix` is safe for later identification. + * + * @returns {{full:string,prefix:string,hash:string}} Token material and safe metadata. + */ export function generateApiToken() { const full = `swk_${randomBytes(24).toString('base64url')}`; return { full, prefix: full.slice(0, 12), hash: createHash('sha256').update(full).digest('hex') }; } + +/** + * Hash a personal access token for constant-shape database lookup. + * + * @param {unknown} full - Full token supplied by a client. + * @returns {string} Lowercase hexadecimal SHA-256 digest. + */ export function hashApiToken(full) { return createHash('sha256').update(String(full)).digest('hex'); } @@ -25,10 +42,15 @@ if ( throw new Error('SCOPEWEAVE_JWT_SECRET must be set to at least 32 non-whitespace characters'); } -// scryptSync requires string|ArrayBufferView — untyped JSON bodies must not -// throw TypeError (request-level DoS). hashPassword coerces non-strings to '' -// for a stable hash path; verifyPassword rejects non-strings with false so a -// malicious `{}` body never authenticates even if an empty-password hash exists. +/** + * Hash a password with a fresh random salt using Node's scrypt implementation. + * + * Non-string values are normalized to an empty string so an untyped request + * cannot crash the process. API boundaries must still reject non-string inputs. + * + * @param {unknown} pw - Password value to hash. + * @returns {string} Persistable `salt:hash` representation. + */ export function hashPassword(pw) { const password = typeof pw === 'string' ? pw : ''; const salt = randomBytes(16).toString('hex'); @@ -36,6 +58,16 @@ export function hashPassword(pw) { return `${salt}:${hash}`; } +/** + * Verify a candidate password against a stored scrypt representation. + * + * Non-string candidates and malformed stored values fail closed. Equal-length + * digests are compared with `timingSafeEqual` to avoid content-dependent timing. + * + * @param {unknown} pw - Candidate password. + * @param {unknown} stored - Persisted `salt:hash` representation. + * @returns {boolean} Whether the candidate matches the stored password hash. + */ export function verifyPassword(pw, stored) { if (typeof pw !== 'string') return false; const [salt, hash] = String(stored || '').split(':'); @@ -45,9 +77,54 @@ export function verifyPassword(pw, stored) { return test.length === known.length && timingSafeEqual(test, known); } -const b64urlJson = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); +/** + * Serialize a JSON value using the unpadded base64url form required by JWT. + * + * @param {unknown} value - JSON-serializable value. + * @returns {string} Base64url-encoded JSON. + */ +const b64urlJson = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + +/** + * Determine whether a decoded JWT segment is a non-array JSON object. + * + * @param {unknown} value - Decoded JSON value. + * @returns {value is Record} Whether the value is a claims object. + */ +function isClaimsObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Sign a ScopeWeave session JWT with pinned HS256 semantics. + * + * Session tokens are minted only for a positive safe-integer user subject and a + * non-negative safe-integer token version. The lifetime must be a positive safe + * integer no greater than seven days, so an internal caller cannot create an + * immortal, already-expired, excessively long-lived, or numerically imprecise + * general session token. Narrower credentials use the separate access-grant + * design tracked in issue #413 rather than extending this lifetime. + * + * @param {Record} payload - Session claims to include. + * @param {number} [ttlSec=604800] - Token lifetime in seconds, at most seven days. + * @returns {string} Signed compact JWT. + * @throws {TypeError|RangeError} If the payload, subject, token version, or lifetime is invalid. + */ +export function signToken(payload, ttlSec = MAX_SESSION_TTL_SECONDS) { + if (!isClaimsObject(payload)) throw new TypeError('session claims must be an object'); + if (!Number.isSafeInteger(payload.sub) || payload.sub < 1) { + throw new TypeError('session subject must be a positive safe integer'); + } + if (!Number.isSafeInteger(payload.tv) || payload.tv < 0) { + throw new TypeError('session token version must be a non-negative safe integer'); + } + if (!Number.isSafeInteger(ttlSec) || ttlSec < 1) { + throw new RangeError('session lifetime must be a positive safe integer'); + } + if (ttlSec > MAX_SESSION_TTL_SECONDS) { + throw new RangeError(`session maximum lifetime is ${MAX_SESSION_TTL_SECONDS} seconds`); + } -export function signToken(payload, ttlSec = 60 * 60 * 24 * 7) { const now = Math.floor(Date.now() / 1000); const header = b64urlJson({ alg: 'HS256', typ: 'JWT' }); const body = b64urlJson({ ...payload, iat: now, exp: now + ttlSec }); @@ -55,16 +132,57 @@ export function signToken(payload, ttlSec = 60 * 60 * 24 * 7) { return `${header}.${body}.${sig}`; } +/** + * Verify a signed ScopeWeave session JWT and enforce database-backed revocation. + * + * The verifier recomputes an HS256 signature before parsing claims, then requires + * the signed header to declare the same pinned algorithm and JWT type. Session + * claims must contain a positive safe-integer subject, a future safe-integer + * expiry, and a non-negative safe-integer token version. The referenced user must + * exist and the token version must equal the current database value. Every + * session-JWT transport uses this function so `logout-all` cannot be bypassed by + * calendar, SSE, attachment-view, or bearer-token routes. + * + * @param {unknown} token - Compact JWT supplied by a client. + * @returns {Record} Verified session claims. + * @throws {Error} If structure, signature, header, claims, expiry, user, or revocation checks fail. + */ export function verifyToken(token) { const parts = String(token || '').split('.'); if (parts.length !== 3) throw new Error('malformed token'); const [header, body, sig] = parts; - // Recompute HS256 signature; never read/trust the header's declared alg. + + // Recompute HS256 first; do not parse or trust attacker-controlled claims + // before the compact representation has authenticated successfully. const expected = createHmac('sha256', SECRET).update(`${header}.${body}`).digest('base64url'); - const a = Buffer.from(sig); - const b = Buffer.from(expected); - if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Error('bad signature'); + const actualSignature = Buffer.from(sig); + const expectedSignature = Buffer.from(expected); + if ( + actualSignature.length !== expectedSignature.length + || !timingSafeEqual(actualSignature, expectedSignature) + ) { + throw new Error('bad signature'); + } + + const headerClaims = JSON.parse(Buffer.from(header, 'base64url').toString()); + if (!isClaimsObject(headerClaims)) throw new Error('invalid token header'); + if (headerClaims.alg !== 'HS256') throw new Error('invalid token algorithm'); + if (headerClaims.typ !== 'JWT') throw new Error('invalid token type'); + const payload = JSON.parse(Buffer.from(body, 'base64url').toString()); - if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('expired'); + if (!isClaimsObject(payload)) throw new Error('invalid session claims'); + if (!Number.isSafeInteger(payload.sub) || payload.sub < 1) { + throw new Error('invalid session subject'); + } + if (!Number.isSafeInteger(payload.exp) || payload.exp <= Math.floor(Date.now() / 1000)) { + throw new Error('expired or invalid session expiry'); + } + if (!Number.isSafeInteger(payload.tv) || payload.tv < 0) { + throw new Error('invalid token version'); + } + + const user = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!user) throw new Error('unknown session subject'); + if (payload.tv !== user.token_version) throw new Error('revoked session'); return payload; } diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index ae5cd8f3..8933e961 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -6,17 +6,36 @@ import { createHmac } from 'node:crypto'; const CF_URL = (process.env.CLEARFOLIO_URL || '').replace(/\/$/, ''); const CF_SECRET = process.env.CLEARFOLIO_HMAC_SECRET || ''; const PERMISSIONS = 'job:create,job:read,viewer:read,artifact-link:create'; +const CLEARFOLIO_JOB_STATUSES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); +/** Whether the process uses the in-memory Clearfolio development adapter. */ export const clearfolioMock = !CF_URL; -// Clearfolio TenantAccessService.signClaims와 동일한 규격: -// payload = tenantId \n subjectId \n permissions \n issuedAt(epoch초), -// HMAC-SHA256 → base64url(무패딩). +/** + * Sign tenant claims using the Clearfolio HMAC interoperability contract. + * + * The payload is the newline-delimited tenant ID, subject ID, permissions, and + * issued-at epoch value. The signature is unpadded base64url HMAC-SHA256. + * + * @param {string} tenantId - Clearfolio tenant identifier. + * @param {string} subjectId - Clearfolio subject identifier. + * @param {string} permissions - Comma-separated permission contract. + * @param {string|number} issuedAt - Epoch-second issue time. + * @param {string} secret - Shared HMAC secret. + * @returns {string} Unpadded base64url signature. + */ export function signClaims(tenantId, subjectId, permissions, issuedAt, secret) { const payload = [tenantId, subjectId, permissions, issuedAt].join('\n'); return createHmac('sha256', secret).update(payload).digest('base64url'); } +/** + * Build tenant-scoped Clearfolio request headers without exposing credentials. + * + * @param {string|number} orgId - ScopeWeave organization identifier. + * @param {string|number} userId - Requesting ScopeWeave user identifier. + * @returns {Record} Tenant, subject, permission, and optional HMAC headers. + */ function tenantHeaders(orgId, userId) { const tenantId = `sw-org-${orgId}`; const subjectId = `sw-user-${userId}`; @@ -28,16 +47,67 @@ function tenantHeaders(orgId, userId) { if (CF_SECRET) { const issuedAt = String(Math.floor(Date.now() / 1000)); headers['X-Clearfolio-Claims-Issued-At'] = issuedAt; - headers['X-Clearfolio-Claims-Signature'] = signClaims(tenantId, subjectId, PERMISSIONS, issuedAt, CF_SECRET); + headers['X-Clearfolio-Claims-Signature'] = signClaims( + tenantId, + subjectId, + PERMISSIONS, + issuedAt, + CF_SECRET, + ); } return headers; } +/** + * Test whether an untrusted parsed JSON value is a plain record-like object. + * + * Arrays and null are rejected so property access cannot silently accept an + * incompatible downstream response shape. + * + * @param {unknown} value - Parsed downstream JSON value. + * @returns {value is Record} Whether the value is a non-array object. + */ +function isJsonRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Test whether an untrusted value is an exact Clearfolio conversion state. + * + * Whitespace-padded and unknown strings are rejected rather than normalized so + * the database cannot persist a state outside the documented workflow contract. + * + * @param {unknown} value - Parsed downstream status value. + * @returns {value is string} Whether the value is an exact accepted state. + */ +function isClearfolioJobStatus(value) { + return typeof value === 'string' && CLEARFOLIO_JOB_STATUSES.has(value); +} + // ---- mock store (dev/test 전용; 재시작 시 소실) ---- const mockDocs = new Map(); // jobId -> { name, mime, bytes } let mockSeq = 0; + +/** + * Read one in-memory mock artifact. + * + * @param {string} jobId - Mock conversion job identifier. + * @returns {{name:string,mime:string,bytes:Buffer}|null} Stored artifact or null. + */ export const mockArtifact = (jobId) => mockDocs.get(jobId) || null; +/** + * Submit a document conversion job through Clearfolio or the local mock. + * + * Downstream response text and transport errors are never copied into the + * thrown error because the caller may serialize that message to a browser. + * + * @param {string|number} orgId - ScopeWeave organization identifier. + * @param {string|number} userId - Requesting ScopeWeave user identifier. + * @param {{name:string,mime:string,bytes:Buffer|Uint8Array}} document - Conversion payload. + * @returns {Promise<{jobId:string,status:string}>} Downstream job identity and initial status. + * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns a malformed response. + */ export async function submitJob(orgId, userId, { name, mime, bytes }) { if (clearfolioMock) { const jobId = `mockcf-${++mockSeq}`; @@ -46,41 +116,115 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { } const form = new FormData(); form.append('file', new Blob([bytes], { type: mime || 'application/octet-stream' }), name); - const res = await fetch(`${CF_URL}/api/v1/convert/jobs`, { - method: 'POST', - headers: tenantHeaders(orgId, userId), - body: form, - }); - const data = await res.json().catch(() => ({})); - if (!res.ok || !data.jobId) throw new Error(data.message || `clearfolio submit failed (${res.status})`); - return { jobId: data.jobId, status: data.status || 'PENDING' }; + let res; + try { + res = await fetch(`${CF_URL}/api/v1/convert/jobs`, { + method: 'POST', + headers: tenantHeaders(orgId, userId), + body: form, + }); + } catch { + throw new Error('clearfolio submit unavailable'); + } + if (!res.ok) throw new Error(`clearfolio submit failed (${res.status})`); + const data = await res.json().catch(() => null); + if (!isJsonRecord(data)) throw new Error('clearfolio submit response invalid'); + const status = data.status === undefined ? 'PENDING' : data.status; + if ( + typeof data.jobId !== 'string' + || data.jobId.trim().length === 0 + || !isClearfolioJobStatus(status) + ) { + throw new Error('clearfolio submit response invalid'); + } + return { jobId: data.jobId.trim(), status }; } -export async function jobStatus(orgId, userId, jobId) { +/** + * Read a Clearfolio conversion status with optional caller cancellation. + * + * Transport failures, non-success HTTP responses, and successful responses + * without an exact documented conversion state all throw fixed operation-level + * errors. The bounded refresh engine can therefore preserve the previously + * persisted state without logging or returning private downstream details. + * + * @param {string|number} orgId - ScopeWeave organization identifier. + * @param {string|number} userId - Requesting user identifier. + * @param {string} jobId - Clearfolio conversion job identifier. + * @param {{signal?:AbortSignal}} [options] - Optional request cancellation signal. + * @returns {Promise} Validated downstream conversion status. + * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns a malformed status. + */ +export async function jobStatus(orgId, userId, jobId, { signal } = {}) { if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; - const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { - headers: tenantHeaders(orgId, userId), - }); - const data = await res.json().catch(() => ({})); - return data.status || 'FAILED'; + let res; + try { + res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { + headers: tenantHeaders(orgId, userId), + signal, + }); + } catch { + throw new Error('clearfolio status unavailable'); + } + if (!res.ok) throw new Error(`clearfolio status failed (${res.status})`); + const data = await res.json().catch(() => null); + if (!isJsonRecord(data) || !isClearfolioJobStatus(data.status)) { + throw new Error('clearfolio status response invalid'); + } + return data.status; } -// SUCCEEDED 잡의 서명 아티팩트 URL 발급 → 뷰어/직접 열람용 절대 URL 반환. +/** + * Issue a viewable artifact URL for a completed Clearfolio job. + * + * The hosted path prefers Clearfolio's external PDF.js viewer when an + * `artifactToken` is available and otherwise returns a validated HTTP(S) URL. + * Downstream response text and transport errors are never exposed to callers. + * An HTTPS Clearfolio deployment cannot downgrade an artifact link to HTTP. + * + * @param {string|number} orgId - ScopeWeave organization identifier. + * @param {string|number} userId - Requesting ScopeWeave user identifier. + * @param {string} jobId - Completed conversion job identifier. + * @returns {Promise} Relative mock path or validated absolute artifact URL. + * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns an invalid link. + */ export async function artifactUrl(orgId, userId, jobId) { if (clearfolioMock) return `/api/mock-clearfolio/${encodeURIComponent(jobId)}`; - const res = await fetch(`${CF_URL}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { - method: 'POST', - headers: tenantHeaders(orgId, userId), - }); - const data = await res.json().catch(() => ({})); + let res; + try { + res = await fetch(`${CF_URL}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { + method: 'POST', + headers: tenantHeaders(orgId, userId), + }); + } catch { + throw new Error('clearfolio artifact-link unavailable'); + } + if (!res.ok) throw new Error(`clearfolio artifact-link failed (${res.status})`); + const data = await res.json().catch(() => null); + if (!isJsonRecord(data)) throw new Error('clearfolio artifact-link response invalid'); const link = data.artifactUrl || data.url || data.signedUrl; - if (!res.ok || !link) throw new Error(data.message || `clearfolio artifact-link failed (${res.status})`); - // PDF.js 뷰어 페이지 우선(clearfolio external artifactToken 모드): 토큰을 - // 추출해 /viewer/{docId}?artifactToken=… 으로 보낸다. 실패 시 원시 아티팩트. + if (typeof link !== 'string' || link.length === 0) { + throw new Error('clearfolio artifact-link response invalid'); + } + + let url; + let clearfolioUrl; try { - const u = new URL(link, CF_URL); - const tok = u.searchParams.get('artifactToken'); - if (tok) return `${CF_URL}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(tok)}`; - } catch { /* fall through to raw link */ } - return link.startsWith('http') ? link : `${CF_URL}${link}`; + clearfolioUrl = new URL(CF_URL); + url = new URL(link, clearfolioUrl); + } catch { + throw new Error('clearfolio artifact-link response invalid'); + } + const allowsHttp = clearfolioUrl.protocol === 'http:' && url.protocol === 'http:'; + if (url.protocol !== 'https:' && !allowsHttp) { + throw new Error('clearfolio artifact-link response invalid'); + } + + // PDF.js 뷰어 페이지 우선(clearfolio external artifactToken 모드): 토큰을 + // 추출해 /viewer/{docId}?artifactToken=… 으로 보낸다. 없으면 검증한 URL. + const token = url.searchParams.get('artifactToken'); + if (token) { + return `${CF_URL}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(token)}`; + } + return url.href; } diff --git a/tests/api/attachment-status.test.mjs b/tests/api/attachment-status.test.mjs new file mode 100644 index 00000000..51bd5ea0 --- /dev/null +++ b/tests/api/attachment-status.test.mjs @@ -0,0 +1,130 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; +process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; +process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS = '1000'; +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 || {}) }, +}); + +async function upload(projectId, token, taskId) { + const form = new FormData(); + form.append( + 'file', + new Blob([`content-${taskId}`], { type: 'text/plain' }), + `${taskId}.txt`, + ); + form.set('taskId', taskId); + const response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: form, + }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(Object.hasOwn(payload, 'jobId'), false); + return payload; +} + +test('attachment listing refreshes without internal identifier leakage', async () => { + let response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: JSON.stringify({ + email: 'attachments@scopeweave.test', + password: 'password123', + name: 'Attachments', + }), + }); + assert.equal(response.status, 200); + const token = (await response.json()).token; + const auth = { authorization: `Bearer ${token}` }; + + response = await jsonRequest('/api/me', { headers: auth }); + const userId = (await response.json()).user.id; + response = await jsonRequest('/api/projects', { + method: 'POST', + headers: auth, + body: JSON.stringify({ name: 'Attachment Status Project' }), + }); + assert.equal(response.status, 200); + const projectId = (await response.json()).id; + + const first = await upload(projectId, token, 'task-a'); + const second = await upload(projectId, token, 'task-b'); + db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)") + .run(first.id, second.id); + db.prepare( + 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)', + ).run( + projectId, + 'task-missing', + 'missing.txt', + 'text/plain', + 1, + '', + 'PENDING', + userId, + ); + + response = await jsonRequest( + `/api/projects/${projectId}/attachments?taskId=task-a`, + { headers: auth }, + ); + assert.equal(response.status, 200); + let attachments = (await response.json()).attachments; + assert.equal(attachments.length, 1); + assert.equal(attachments[0].taskId, 'task-a'); + assert.equal(attachments[0].status, 'SUCCEEDED'); + assert.equal(Object.hasOwn(attachments[0], 'jobId'), false); + + response = await jsonRequest(`/api/projects/${projectId}/attachments`, { + headers: auth, + }); + assert.equal(response.status, 200); + attachments = (await response.json()).attachments; + assert.equal(attachments.length, 3); + assert.equal( + attachments.every((row) => !Object.hasOwn(row, 'jobId')), + true, + ); + assert.equal( + attachments.find((row) => row.taskId === 'task-b').status, + 'SUCCEEDED', + ); + assert.equal( + attachments.find((row) => row.taskId === 'task-missing').status, + 'PENDING', + ); + + response = await jsonRequest('/api/metrics'); + const metrics = await response.json(); + assert.equal(metrics.attachmentStatusRefreshAttempted, 2); + assert.equal(metrics.attachmentStatusRefreshChanged, 2); + assert.equal(metrics.attachmentStatusRefreshFailed, 0); + assert.equal(metrics.attachmentStatusRefreshSkipped, 1); + assert.equal(metrics.attachmentStatusRefreshDeferred, 0); + assert.equal(metrics.attachmentStatusRefreshTimeoutFailures, 0); + assert.equal(metrics.attachmentStatusRefreshDownstreamLookupFailures, 0); + assert.equal(metrics.attachmentStatusRefreshInvalidStatusFailures, 0); + assert.equal(metrics.attachmentStatusRefreshPersistenceFailures, 0); + + response = await jsonRequest('/api/metrics?format=prometheus'); + const prometheus = await response.text(); + assert.match(prometheus, /scopeweave_attachment_status_refresh_attempted 2/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_changed 2/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_failed 0/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_skipped 1/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_deferred 0/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_timeout_failures 0/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_downstream_lookup_failures 0/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_invalid_status_failures 0/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_persistence_failures 0/); +}); diff --git a/tests/api/session-revocation.test.mjs b/tests/api/session-revocation.test.mjs new file mode 100644 index 00000000..6164798b --- /dev/null +++ b/tests/api/session-revocation.test.mjs @@ -0,0 +1,208 @@ +// Security invariant: logout-all revocation and strict session-claim validation +// must apply uniformly to every JWT transport. Calendar clients and EventSource +// cannot reliably send Authorization headers, so query-token routes must share +// the same fail-closed verifier as bearer middleware. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; + +const JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = JWT_SECRET; + +const { app } = await import('../../server/app.mjs'); +const { signToken } = await import('../../server/auth.mjs'); + +const req = (path, opts = {}) => + app.request(path, { + ...opts, + headers: { 'content-type': 'application/json', ...(opts.headers || {}) }, + }); +const body = (value) => JSON.stringify(value); +const encodeSegment = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + +/** + * Create a correctly signed but intentionally unvalidated compact JWT. + * + * Production code cannot mint malformed session claims through `signToken`. + * This test-only signer is therefore required to exercise the verifier's + * hostile-input boundary without weakening the production signer. + * + * @param {unknown} payload - Raw signed payload value. + * @param {unknown} [headerClaims] - Raw signed header value. + * @returns {string} Compact HS256 token signed with the test secret. + */ +function signUnsafe( + payload, + headerClaims = { alg: 'HS256', typ: 'JWT' }, +) { + const header = encodeSegment(headerClaims); + const encodedBody = encodeSegment(payload); + const signature = createHmac('sha256', JWT_SECRET) + .update(`${header}.${encodedBody}`) + .digest('base64url'); + return `${header}.${encodedBody}.${signature}`; +} + +async function expectStreamStatus(projectId, token, status, message) { + const response = await req( + `/api/projects/${projectId}/stream?token=${encodeURIComponent(token)}`, + ); + assert.equal(response.status, status, message); + await response.body?.cancel?.(); +} + +async function expectCalendarStatus(projectId, token, status, message) { + const response = await req( + `/api/projects/${projectId}/calendar.ics?token=${encodeURIComponent(token)}`, + ); + assert.equal(response.status, status, message); +} + +async function expectAttachmentViewStatus(projectId, token, status, message) { + const response = await req( + `/api/projects/${projectId}/attachments/missing/view?token=${encodeURIComponent(token)}`, + ); + assert.equal(response.status, status, message); +} + +async function expectBearerStatus(token, status, message) { + const response = await req('/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(response.status, status, message); +} + +/** + * Assert that one invalid session token is rejected by every supported transport. + * + * @param {number} projectId - Accessible project used by URL-token routes. + * @param {string} token - Invalid or revoked compact JWT. + * @param {string} label - Diagnostic label for assertion messages. + * @returns {Promise} Resolves after all four transport assertions. + */ +async function expectRejectedEverywhere(projectId, token, label) { + await expectBearerStatus(token, 401, `bearer rejects ${label}`); + await expectCalendarStatus(projectId, token, 401, `calendar rejects ${label}`); + await expectStreamStatus(projectId, token, 401, `SSE rejects ${label}`); + await expectAttachmentViewStatus(projectId, token, 401, `attachment view rejects ${label}`); +} + +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/); + assert.throws(() => signToken({ sub: '1', tv: 0 }), /subject/); + assert.throws(() => signToken({ sub: 0, tv: 0 }), /subject/); + assert.throws( + () => signToken({ sub: Number.MAX_SAFE_INTEGER + 1, tv: 0 }), + /subject/, + ); + assert.throws(() => signToken({ sub: 1, tv: '0' }), /token version/); + assert.throws(() => signToken({ sub: 1, tv: -1 }), /token version/); + assert.throws( + () => signToken({ sub: 1, tv: Number.MAX_SAFE_INTEGER + 1 }), + /token version/, + ); + assert.throws(() => signToken({ sub: 1, tv: 0 }, '60'), /lifetime/); + assert.throws(() => signToken({ sub: 1, tv: 0 }, 0), /lifetime/); + assert.throws(() => signToken({ sub: 1, tv: 0 }, 1.5), /lifetime/); + assert.throws( + () => signToken({ sub: 1, tv: 0 }, 60 * 60 * 24 * 7 + 1), + /maximum lifetime/, + ); + assert.throws( + () => signToken({ sub: 1, tv: 0 }, Number.MAX_SAFE_INTEGER), + /maximum lifetime/, + ); +}); + +test('logout-all and strict JWT validation cover every session transport', async () => { + let response = await req('/api/auth/signup', { + method: 'POST', + body: body({ + email: 'revocation-test@scopeweave.test', + password: 'password123', + name: 'Revocation Test', + }), + }); + assert.equal(response.status, 200, 'signup succeeds'); + const tokenA = (await response.json()).token; + + const authA = { authorization: `Bearer ${tokenA}` }; + response = await req('/api/me', { headers: authA }); + assert.equal(response.status, 200, 'current session resolves the user'); + const userId = (await response.json()).user.id; + + response = await req('/api/projects', { + method: 'POST', + headers: authA, + body: body({ name: 'Revocation Probe' }), + }); + assert.equal(response.status, 200, 'project creation succeeds'); + const projectId = (await response.json()).id; + + response = await req('/api/auth/login', { + method: 'POST', + body: body({ + email: 'revocation-test@scopeweave.test', + password: 'password123', + }), + }); + assert.equal(response.status, 200, 'second-device login succeeds'); + const tokenB = (await response.json()).token; + + const now = Math.floor(Date.now() / 1000); + const validClaims = { sub: userId, tv: 0, iat: now, exp: now + 3_600 }; + const malformedTokens = [ + ['malformed compact token', 'not-a-jwt'], + ['invalid signature', `${tokenA.split('.').slice(0, 2).join('.')}.x`], + ['array header', signUnsafe(validClaims, [])], + ['non-HS256 header', signUnsafe(validClaims, { alg: 'none', typ: 'JWT' })], + ['non-JWT type', signUnsafe(validClaims, { alg: 'HS256', typ: 'JWS' })], + ['array claims', signUnsafe([])], + ['missing subject', signUnsafe({ tv: 0, iat: now, exp: now + 3_600 })], + ['string subject', signUnsafe({ ...validClaims, sub: '1' })], + ['zero subject', signUnsafe({ ...validClaims, sub: 0 })], + ['missing expiry', signUnsafe({ sub: userId, tv: 0, iat: now })], + ['string expiry', signUnsafe({ ...validClaims, exp: String(now + 3_600) })], + ['expired claim', signUnsafe({ ...validClaims, exp: now })], + ['missing token version', signUnsafe({ sub: userId, iat: now, exp: now + 3_600 })], + ['null token version', signUnsafe({ ...validClaims, tv: null })], + ['boolean token version', signUnsafe({ ...validClaims, tv: false })], + ['string token version', signUnsafe({ ...validClaims, tv: '0' })], + ['fractional token version', signUnsafe({ ...validClaims, tv: 0.5 })], + ['negative token version', signUnsafe({ ...validClaims, tv: -1 })], + ['unsafe token version', signUnsafe({ ...validClaims, tv: Number.MAX_SAFE_INTEGER + 1 })], + ]; + for (const [label, malformedToken] of malformedTokens) { + await expectRejectedEverywhere(projectId, malformedToken, label); + } + + const missingUserToken = signToken({ sub: userId + 1_000_000, tv: 0 }); + await expectRejectedEverywhere(projectId, missingUserToken, 'signed token for a missing user'); + + 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'); + await expectCalendarStatus(projectId, tokenB, 200, 'calendar accepts token B before revocation'); + await expectStreamStatus(projectId, tokenA, 200, 'SSE accepts token A before revocation'); + await expectStreamStatus(projectId, tokenB, 200, 'SSE accepts token B before revocation'); + await expectAttachmentViewStatus(projectId, tokenA, 404, 'attachment view authenticates token A before lookup'); + await expectAttachmentViewStatus(projectId, tokenB, 404, 'attachment view authenticates token B before lookup'); + + response = await req('/api/auth/logout-all', { + method: 'POST', + headers: authA, + }); + assert.equal(response.status, 200, 'logout-all succeeds'); + const freshToken = (await response.json()).token; + + for (const [label, staleToken] of [['A', tokenA], ['B', tokenB]]) { + await expectRejectedEverywhere(projectId, staleToken, `stale token ${label}`); + } + + await expectBearerStatus(freshToken, 200, 'bearer accepts replacement token'); + 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'); +}); diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index 84809c69..8cb0f4a2 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -632,7 +632,8 @@ assert.equal(r.status, 404, 'non-member ai brief → 404'); r = await app.request(`/api/projects/${proj.id}/attachments`, { method: 'POST', headers: auth, body: fd }); assert.equal(r.status, 200, 'attachment upload'); const att = await r.json(); - assert.ok(att.id && att.jobId.startsWith('mockcf-'), 'mock job id'); + assert.ok(att.id, 'attachment id'); + assert.equal(Object.hasOwn(att, 'jobId'), false, 'internal job id omitted'); assert.equal(att.status, 'SUCCEEDED', 'mock converts immediately'); // 목록 + 작업 바인딩 + 업로더 r = await req(`/api/projects/${proj.id}/attachments?taskId=s1`, { headers: auth }); diff --git a/tests/unit/attachment-status.test.mjs b/tests/unit/attachment-status.test.mjs new file mode 100644 index 00000000..6423cc2e --- /dev/null +++ b/tests/unit/attachment-status.test.mjs @@ -0,0 +1,315 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + ATTACHMENT_STATUS_DEFAULT_BUDGET_MS, + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_BUDGET_MS, + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + normalizeAttachmentStatusBudgetMs, + normalizeAttachmentStatusConcurrency, + normalizeAttachmentStatusTimeoutMs, + refreshAttachmentStatuses, +} from '../../server/attachment_status.mjs'; + +test('attachment status configuration is bounded and fail-safe', () => { + assert.equal( + normalizeAttachmentStatusConcurrency(undefined), + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ); + assert.equal(normalizeAttachmentStatusConcurrency('4'), 4); + assert.equal( + normalizeAttachmentStatusConcurrency(0), + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ); + assert.equal( + normalizeAttachmentStatusConcurrency(1.5), + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ); + assert.equal( + normalizeAttachmentStatusConcurrency(999), + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ); + + assert.equal( + normalizeAttachmentStatusTimeoutMs(undefined), + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ); + assert.equal(normalizeAttachmentStatusTimeoutMs('25'), 25); + assert.equal( + normalizeAttachmentStatusTimeoutMs(-1), + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ); + assert.equal( + normalizeAttachmentStatusTimeoutMs(50_000), + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + ); + + assert.equal( + normalizeAttachmentStatusBudgetMs(undefined), + ATTACHMENT_STATUS_DEFAULT_BUDGET_MS, + ); + assert.equal(normalizeAttachmentStatusBudgetMs('2500'), 2_500); + assert.equal( + normalizeAttachmentStatusBudgetMs(0), + ATTACHMENT_STATUS_DEFAULT_BUDGET_MS, + ); + assert.equal( + normalizeAttachmentStatusBudgetMs(100_000), + ATTACHMENT_STATUS_MAX_BUDGET_MS, + ); +}); + +test('refresh validates its dependency, diagnostic, and clock contracts', async () => { + const dependencies = { + jobStatus: async () => 'PENDING', + updateStatus() {}, + }; + await assert.rejects( + () => refreshAttachmentStatuses(null, {}), + /rows must be an array/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], undefined), + /jobStatus must be a function/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { updateStatus() {} }), + /jobStatus must be a function/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { jobStatus() {} }), + /updateStatus must be a function/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { ...dependencies, onError: 'log' }), + /onError must be a function/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { ...dependencies, now: 1 }), + /now must be a function/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { ...dependencies, now: () => Number.NaN }), + /clock must return a finite number/, + ); +}); + +test('empty and settled rows perform no downstream work', async () => { + const dependencies = { + jobStatus: async () => { throw new Error('must not run'); }, + updateStatus: () => { throw new Error('must not run'); }, + }; + assert.deepEqual(await refreshAttachmentStatuses([], dependencies), { + attempted: 0, + changed: 0, + failed: 0, + skipped: 0, + deferred: 0, + }); + + const metrics = {}; + assert.deepEqual( + await refreshAttachmentStatuses( + [null, { id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], + { ...dependencies, metrics }, + ), + { attempted: 0, changed: 0, failed: 0, skipped: 0, deferred: 0 }, + ); + assert.deepEqual(metrics, { + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshSkipped: 0, + attachmentStatusRefreshDeferred: 0, + attachmentStatusRefreshTimeoutFailures: 0, + attachmentStatusRefreshDownstreamLookupFailures: 0, + attachmentStatusRefreshInvalidStatusFailures: 0, + attachmentStatusRefreshPersistenceFailures: 0, + }); +}); + +test('100 pending rows reach but never exceed configured concurrency', async () => { + const rows = Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + jobId: `job-${index + 1}`, + status: index % 3 === 0 ? 'RUNNING' : 'PENDING', + })); + let active = 0; + let peak = 0; + let started = 0; + let releaseInitialWorkers; + const initialWorkerGate = new Promise((resolve) => { + releaseInitialWorkers = resolve; + }); + const updates = []; + const metrics = { + attachmentStatusRefreshAttempted: 10, + attachmentStatusRefreshChanged: 20, + attachmentStatusRefreshFailed: 30, + attachmentStatusRefreshSkipped: 35, + attachmentStatusRefreshDeferred: 40, + attachmentStatusRefreshTimeoutFailures: 1, + attachmentStatusRefreshDownstreamLookupFailures: 2, + attachmentStatusRefreshInvalidStatusFailures: 3, + attachmentStatusRefreshPersistenceFailures: 4, + }; + + const counts = await refreshAttachmentStatuses(rows, { + orgId: 7, + userId: 9, + concurrency: 8, + timeoutMs: 1_000, + budgetMs: 10_000, + metrics, + jobStatus: async (orgId, userId, jobId, { signal }) => { + assert.equal(orgId, 7); + assert.equal(userId, 9); + assert.equal(signal.aborted, false); + active += 1; + started += 1; + peak = Math.max(peak, active); + if (started === 8) releaseInitialWorkers(); + await initialWorkerGate; + active -= 1; + const rowNumber = Number(jobId.split('-')[1]); + return rowNumber % 2 === 0 ? 'SUCCEEDED' : rows[rowNumber - 1].status; + }, + updateStatus: async (status, attachmentId) => { + updates.push([status, attachmentId]); + }, + }); + + assert.equal(peak, 8, `peak concurrency ${peak} did not match configured limit`); + assert.deepEqual(counts, { + attempted: 100, + changed: 50, + failed: 0, + skipped: 0, + deferred: 0, + }); + assert.equal(updates.length, 50); + assert.deepEqual(metrics, { + attachmentStatusRefreshAttempted: 110, + attachmentStatusRefreshChanged: 70, + attachmentStatusRefreshFailed: 30, + attachmentStatusRefreshSkipped: 35, + attachmentStatusRefreshDeferred: 40, + attachmentStatusRefreshTimeoutFailures: 1, + attachmentStatusRefreshDownstreamLookupFailures: 2, + attachmentStatusRefreshInvalidStatusFailures: 3, + attachmentStatusRefreshPersistenceFailures: 4, + }); +}); + +test('request-wide deadline defers work that has not started', async () => { + const rows = [ + { id: 1, jobId: 'job-1', status: 'PENDING' }, + { id: 2, jobId: 'job-2', status: 'PENDING' }, + { id: 3, jobId: 'job-3', status: 'PENDING' }, + ]; + const clockValues = [0, 0, 20, 30]; + const counts = await refreshAttachmentStatuses(rows, { + concurrency: 1, + timeoutMs: 1_000, + budgetMs: 15, + now: () => clockValues.shift() ?? 30, + jobStatus: async () => 'PENDING', + updateStatus: () => { throw new Error('unchanged status must not be written'); }, + }); + + assert.deepEqual(counts, { + attempted: 1, + changed: 0, + failed: 0, + skipped: 0, + deferred: 2, + }); + assert.deepEqual(rows.map((row) => row.status), ['PENDING', 'PENDING', 'PENDING']); +}); + +test('invalid identifiers and categorized failures preserve stale state', async () => { + const rows = [ + { id: 1, jobId: null, status: 'PENDING' }, + { id: 2, jobId: '', status: 'RUNNING' }, + { id: 3, jobId: ' ', status: 'PENDING' }, + { id: 4, jobId: 'throws', status: 'PENDING' }, + { id: 5, jobId: 'invalid-status', status: 'PENDING' }, + { id: 6, jobId: 'write-fails', status: 'PENDING' }, + { id: 7, jobId: 'times-out', status: 'PENDING' }, + ]; + let aborted = false; + const categories = []; + const metrics = {}; + const counts = await refreshAttachmentStatuses(rows, { + concurrency: 3, + timeoutMs: 5, + budgetMs: 1_000, + metrics, + onError: ({ category }) => categories.push(category), + jobStatus: async (_orgId, _userId, jobId, { signal }) => { + if (jobId === 'throws') throw new Error('downstream failure with sensitive detail'); + if (jobId === 'invalid-status') return 'UNKNOWN'; + if (jobId === 'write-fails') return 'SUCCEEDED'; + return new Promise(() => { + signal.addEventListener('abort', () => { aborted = true; }, { once: true }); + }); + }, + updateStatus: (_status, attachmentId) => { + if (attachmentId === 6) throw new Error('write failure'); + }, + }); + + assert.equal(aborted, true); + assert.deepEqual(counts, { + attempted: 4, + changed: 0, + failed: 4, + skipped: 3, + deferred: 0, + }); + assert.deepEqual( + categories.sort(), + ['downstream_lookup', 'invalid_status', 'status_persistence', 'timeout'].sort(), + ); + assert.equal(categories.some((category) => category.includes('sensitive')), false); + assert.deepEqual(metrics, { + attachmentStatusRefreshAttempted: 4, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 4, + attachmentStatusRefreshSkipped: 3, + attachmentStatusRefreshDeferred: 0, + attachmentStatusRefreshTimeoutFailures: 1, + attachmentStatusRefreshDownstreamLookupFailures: 1, + attachmentStatusRefreshInvalidStatusFailures: 1, + attachmentStatusRefreshPersistenceFailures: 1, + }); + assert.equal(rows[5].status, 'PENDING'); + assert.equal(rows[6].status, 'PENDING'); +}); + +test('diagnostic sink failures and omitted diagnostics stay isolated', async () => { + const row = [{ id: 1, jobId: 'job-1', status: 'PENDING' }]; + const dependencies = { + timeoutMs: 100, + budgetMs: 1_000, + jobStatus: async () => { throw new Error('downstream failure'); }, + updateStatus() {}, + }; + + assert.deepEqual(await refreshAttachmentStatuses(row, dependencies), { + attempted: 1, + changed: 0, + failed: 1, + skipped: 0, + deferred: 0, + }); + assert.deepEqual( + await refreshAttachmentStatuses(row, { + ...dependencies, + onError: () => { throw new Error('logger unavailable'); }, + }), + { attempted: 1, changed: 0, failed: 1, skipped: 0, deferred: 0 }, + ); +}); diff --git a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs new file mode 100644 index 00000000..85ca5894 --- /dev/null +++ b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +test('Clearfolio mock adapter preserves artifacts and local status semantics', async () => { + delete process.env.CLEARFOLIO_URL; + delete process.env.CLEARFOLIO_HMAC_SECRET; + const mock = await import('../../server/clearfolio.mjs?mock-adapter-contract-test=1'); + + assert.equal(mock.clearfolioMock, true); + assert.equal(mock.mockArtifact('missing-job'), null); + + const bytes = Buffer.from('mock document'); + const submitted = await mock.submitJob(11, 12, { + name: 'mock.txt', + mime: '', + bytes, + }); + assert.match(submitted.jobId, /^mockcf-\d+$/); + assert.equal(submitted.status, 'SUCCEEDED'); + assert.deepEqual(mock.mockArtifact(submitted.jobId), { + name: 'mock.txt', + mime: '', + bytes, + }); + assert.equal(await mock.jobStatus(11, 12, submitted.jobId), 'SUCCEEDED'); + assert.equal(await mock.jobStatus(11, 12, 'missing-job'), 'FAILED'); + assert.equal( + await mock.artifactUrl(11, 12, 'job/with space'), + '/api/mock-clearfolio/job%2Fwith%20space', + ); +}); + +test('Clearfolio tenant claim headers use the documented HMAC contract', async () => { + process.env.CLEARFOLIO_URL = 'https://clearfolio.example/'; + process.env.CLEARFOLIO_HMAC_SECRET = 'clearfolio-shared-secret'; + const originalFetch = globalThis.fetch; + const originalNow = Date.now; + let observedUrl; + let observedOptions; + Date.now = () => 1_750_000_000_000; + globalThis.fetch = async (url, options) => { + observedUrl = String(url); + observedOptions = options; + return { + ok: true, + status: 200, + json: async () => ({ status: 'RUNNING' }), + }; + }; + + try { + const signed = await import('../../server/clearfolio.mjs?hmac-header-contract-test=1'); + assert.equal(signed.clearfolioMock, false); + assert.equal(await signed.jobStatus(21, 34, 'signed-job'), 'RUNNING'); + assert.equal( + observedUrl, + 'https://clearfolio.example/api/v1/convert/jobs/signed-job', + ); + + const issuedAt = '1750000000'; + assert.equal(observedOptions.headers['X-Clearfolio-Tenant-Id'], 'sw-org-21'); + assert.equal(observedOptions.headers['X-Clearfolio-Subject-Id'], 'sw-user-34'); + assert.equal( + observedOptions.headers['X-Clearfolio-Permissions'], + 'job:create,job:read,viewer:read,artifact-link:create', + ); + assert.equal(observedOptions.headers['X-Clearfolio-Claims-Issued-At'], issuedAt); + assert.equal( + observedOptions.headers['X-Clearfolio-Claims-Signature'], + signed.signClaims( + 'sw-org-21', + 'sw-user-34', + 'job:create,job:read,viewer:read,artifact-link:create', + issuedAt, + 'clearfolio-shared-secret', + ), + ); + assert.doesNotMatch( + observedOptions.headers['X-Clearfolio-Claims-Signature'], + /=/, + ); + } finally { + Date.now = originalNow; + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; + delete process.env.CLEARFOLIO_HMAC_SECRET; + } +}); diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs new file mode 100644 index 00000000..cf3ad02c --- /dev/null +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -0,0 +1,251 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; +const originalFetch = globalThis.fetch; +let observedUrl; +let observedOptions; +let downstreamResponse; +let downstreamError; + +globalThis.fetch = async (url, options = {}) => { + observedUrl = String(url); + observedOptions = options; + if (downstreamError) throw downstreamError; + return downstreamResponse; +}; + +const { artifactUrl, jobStatus, submitJob } = await import( + '../../server/clearfolio.mjs?downstream-contract-test=1' +); + +function setResponse({ ok = true, status = 200, json }) { + downstreamError = undefined; + downstreamResponse = { ok, status, json }; +} + +function setNetworkError(error) { + downstreamResponse = undefined; + downstreamError = error; +} + +async function expectSanitizedFailure(operation, expectedMessage, forbiddenPattern) { + await assert.rejects(operation, (error) => { + assert.equal(error.message, expectedMessage); + if (forbiddenPattern) assert.doesNotMatch(error.message, forbiddenPattern); + return true; + }); +} + +test.after(() => { + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; +}); + +test('jobStatus enforces endpoint, signal, transport, HTTP, and status contracts', async () => { + setResponse({ json: async () => ({ status: 'RUNNING' }) }); + const controller = new AbortController(); + const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); + assert.equal(status, 'RUNNING'); + assert.equal(observedUrl, 'https://clearfolio.example/api/v1/convert/jobs/job-1'); + assert.equal(observedOptions.signal, controller.signal); + + setNetworkError(new Error('connect ECONNREFUSED https://private-clearfolio.internal')); + await expectSanitizedFailure( + () => jobStatus(1, 2, 'job-1'), + 'clearfolio status unavailable', + /private-clearfolio|ECONNREFUSED/, + ); + + setResponse({ + ok: false, + status: 503, + json: async () => ({ message: 'sensitive downstream text' }), + }); + await expectSanitizedFailure( + () => jobStatus(1, 2, 'job-1'), + 'clearfolio status failed (503)', + /sensitive downstream text/, + ); + + const malformedPayloads = [ + { + label: 'unparseable JSON', + json: async () => { throw new SyntaxError('downstream parser detail'); }, + }, + { label: 'null body', json: async () => null }, + { label: 'primitive body', json: async () => 'RUNNING' }, + { label: 'array body', json: async () => [{ status: 'RUNNING' }] }, + { label: 'missing status', json: async () => ({}) }, + { label: 'non-string status', json: async () => ({ status: 200 }) }, + { label: 'empty status', json: async () => ({ status: '' }) }, + { label: 'whitespace status', json: async () => ({ status: ' ' }) }, + { label: 'padded status', json: async () => ({ status: ' RUNNING ' }) }, + { label: 'unknown status', json: async () => ({ status: 'QUEUED' }) }, + ]; + + for (const malformed of malformedPayloads) { + setResponse({ json: malformed.json }); + await expectSanitizedFailure( + () => jobStatus(1, 2, 'job-1'), + 'clearfolio status response invalid', + /downstream parser detail/, + ); + } +}); + +test('submitJob rejects transport details and malformed successful responses', async () => { + const document = { + name: 'status.txt', + mime: 'text/plain', + bytes: Buffer.from('status'), + }; + + setNetworkError(new Error('connect ECONNREFUSED https://private-clearfolio.internal')); + await expectSanitizedFailure( + () => submitJob(7, 9, document), + 'clearfolio submit unavailable', + /private-clearfolio|ECONNREFUSED/, + ); + + setResponse({ + ok: false, + status: 422, + json: async () => ({ message: 'tenant-internal rejection detail' }), + }); + await expectSanitizedFailure( + () => submitJob(7, 9, document), + 'clearfolio submit failed (422)', + /tenant-internal rejection detail/, + ); + assert.equal(observedUrl, 'https://clearfolio.example/api/v1/convert/jobs'); + assert.equal(observedOptions.method, 'POST'); + assert.ok(observedOptions.body instanceof FormData); + + const malformedPayloads = [ + { + label: 'unparseable JSON', + json: async () => { throw new SyntaxError('private parser detail'); }, + }, + { label: 'null body', json: async () => null }, + { label: 'primitive body', json: async () => 'job-1' }, + { label: 'array body', json: async () => [{ jobId: 'job-1' }] }, + { label: 'missing jobId', json: async () => ({ status: 'PENDING' }) }, + { label: 'non-string jobId', json: async () => ({ jobId: 7 }) }, + { label: 'blank jobId', json: async () => ({ jobId: ' ' }) }, + { label: 'non-string status', json: async () => ({ jobId: 'job-1', status: 7 }) }, + { label: 'empty status', json: async () => ({ jobId: 'job-1', status: '' }) }, + { label: 'whitespace status', json: async () => ({ jobId: 'job-1', status: ' ' }) }, + { label: 'padded status', json: async () => ({ jobId: 'job-1', status: ' RUNNING ' }) }, + { label: 'unknown status', json: async () => ({ jobId: 'job-1', status: 'QUEUED' }) }, + ]; + + for (const malformed of malformedPayloads) { + setResponse({ json: malformed.json }); + await expectSanitizedFailure( + () => submitJob(7, 9, document), + 'clearfolio submit response invalid', + /private parser detail/, + ); + } + + setResponse({ json: async () => ({ jobId: ' job-2 ' }) }); + assert.deepEqual(await submitJob(7, 9, document), { + jobId: 'job-2', + status: 'PENDING', + }); + + setResponse({ json: async () => ({ jobId: 'job-3', status: 'RUNNING' }) }); + assert.deepEqual(await submitJob(7, 9, document), { + jobId: 'job-3', + status: 'RUNNING', + }); +}); + +test('artifactUrl validates links and never exposes transport or response text', async () => { + setNetworkError(new Error('getaddrinfo ENOTFOUND private-clearfolio.internal')); + await expectSanitizedFailure( + () => artifactUrl(4, 5, 'job-1'), + 'clearfolio artifact-link unavailable', + /private-clearfolio|ENOTFOUND/, + ); + + setResponse({ + ok: false, + status: 502, + json: async () => ({ message: 'signed URL service secret detail' }), + }); + await expectSanitizedFailure( + () => artifactUrl(4, 5, 'job-1'), + 'clearfolio artifact-link failed (502)', + /signed URL service secret detail/, + ); + assert.equal( + observedUrl, + 'https://clearfolio.example/api/v1/viewer/job-1/artifact-links', + ); + assert.equal(observedOptions.method, 'POST'); + + const malformedPayloads = [ + { + label: 'unparseable JSON', + json: async () => { throw new SyntaxError('private artifact parser detail'); }, + }, + { label: 'null body', json: async () => null }, + { label: 'primitive body', json: async () => '/signed/file.pdf' }, + { label: 'array body', json: async () => [{ url: '/signed/file.pdf' }] }, + { label: 'missing link', json: async () => ({}) }, + { label: 'non-string link', json: async () => ({ artifactUrl: 42 }) }, + { label: 'empty link', json: async () => ({ artifactUrl: '' }) }, + { label: 'malformed URL', json: async () => ({ artifactUrl: 'http://[' }) }, + { label: 'unsupported URL scheme', json: async () => ({ artifactUrl: 'javascript:alert(1)' }) }, + { label: 'HTTPS downgrade', json: async () => ({ artifactUrl: 'http://cdn.example/file.pdf' }) }, + ]; + + for (const malformed of malformedPayloads) { + setResponse({ json: malformed.json }); + await expectSanitizedFailure( + () => artifactUrl(4, 5, 'job-1'), + 'clearfolio artifact-link response invalid', + /private artifact parser detail/, + ); + } + + setResponse({ json: async () => ({ artifactUrl: '/signed/file.pdf' }) }); + assert.equal( + await artifactUrl(4, 5, 'job-1'), + 'https://clearfolio.example/signed/file.pdf', + ); + + setResponse({ json: async () => ({ url: 'https://cdn.example/file.pdf' }) }); + assert.equal( + await artifactUrl(4, 5, 'job-1'), + 'https://cdn.example/file.pdf', + ); + + setResponse({ + json: async () => ({ + signedUrl: 'https://cdn.example/file.pdf?artifactToken=token%20value', + }), + }); + assert.equal( + await artifactUrl(4, 5, 'job-1'), + 'https://clearfolio.example/viewer/job-1?artifactToken=token%20value', + ); +}); + +test('artifactUrl permits HTTP only when the configured Clearfolio endpoint is HTTP', async () => { + process.env.CLEARFOLIO_URL = 'http://clearfolio.local'; + try { + const { artifactUrl: httpArtifactUrl } = await import( + '../../server/clearfolio.mjs?http-artifact-contract-test=1' + ); + setResponse({ json: async () => ({ artifactUrl: 'http://cdn.local/file.pdf' }) }); + assert.equal( + await httpArtifactUrl(4, 5, 'job-http'), + 'http://cdn.local/file.pdf', + ); + } finally { + process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; + } +}); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs new file mode 100644 index 00000000..149440e5 --- /dev/null +++ b/tests/unit/coverage-script-contract.test.mjs @@ -0,0 +1,48 @@ +// 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. +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 scripts = packageJson.scripts; + +assert.equal( + scripts.coverage, + 'npm run test:coverage', + 'the public coverage command delegates to the canonical coverage producer', +); +assert.match( + scripts['test:coverage'], + /\bc8\b.*--reporter=json(?![-\w]).*npm run test:coverage:cases/, + 'test:coverage creates Istanbul JSON before executing coverage cases', +); +assert.match( + scripts['test:coverage'], + /--reporter=json-summary\b/, + 'test:coverage also creates the Istanbul JSON summary', +); +assert.match( + scripts['test:coverage'], + /--include=server\/attachment_status\.mjs/, + 'the bounded refresh module is instrumented', +); +assert.match( + scripts['test:coverage'], + /--include=server\/clearfolio\.mjs/, + 'the abortable Clearfolio adapter is instrumented', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/clearfolio-status-signal\.test\.mjs/, + 'the Clearfolio signal and HTTP failure regression executes under c8', +); +assert.doesNotMatch( + scripts['test:coverage:cases'], + /npm run (?:coverage|test:coverage)(?:\s|$)/, + 'coverage cases never recursively invoke a coverage wrapper', +); + +console.log('✓ coverage script contract tests passed');