diff --git a/CHANGELOG.md b/CHANGELOG.md index 29060f8d..665009f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and rejected cross-origin, credential-bearing, or fragmented artifact links until an explicit reviewed artifact-origin allowlist is configured by a later slice. +- Bounded hosted Clearfolio calls to non-redirecting 15-second requests and + 256 KiB streamed JSON responses, composed caller cancellation with the + provider budget, and validated document metadata/bytes and provider job IDs + before allocation, persistence, or URL construction. +- Preserved Clearfolio provider timeouts that occur while streaming a status + response so attachment-refresh timeout metrics remain accurate. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. - Neutralized audit-log CSV formulas even when executable prefixes are hidden diff --git a/docs/deploy.md b/docs/deploy.md index 83a099b3..cd08ca9a 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -61,6 +61,21 @@ Keep credentials in the dedicated HMAC secret setting rather than URL userinfo, and do not configure a path, query string, or fragment. The adapter constructs its own versioned API paths from the validated origin. +Every hosted Clearfolio request is non-redirecting and has a hard 15-second +adapter budget; attachment status lookups compose that budget with the caller's +own cancellation signal. Successful provider responses must be +`application/json`, and both declared and streamed response bytes are capped at +256 KiB before JSON parsing. Provider job identifiers are limited to 256 +characters, and upload metadata/bytes are validated before Blob/FormData +allocation. The adapter's document ceiling is 10 MiB, matching the current +ScopeWeave attachment API limit. + +These limits are code constants rather than operator-tunable environment +settings. A deployment that needs larger provider responses, longer requests, or +larger documents requires a reviewed application change with corresponding +resource, latency, and security evidence; do not work around these bounds at the +proxy layer. + ## Attachment status refresh operations The attachment-list API reads `job_id` in its initial project-scoped query and diff --git a/docs/doctoring/clearfolio-provider-response-boundary.md b/docs/doctoring/clearfolio-provider-response-boundary.md new file mode 100644 index 00000000..d8928c46 --- /dev/null +++ b/docs/doctoring/clearfolio-provider-response-boundary.md @@ -0,0 +1,83 @@ +# Clearfolio provider response and request boundary + +## Decision + +ScopeWeave treats the Clearfolio service as an untrusted external API even after its root origin and tenant HMAC configuration have passed the production configuration boundary. Every hosted submit, status, and artifact-link call therefore uses the same fail-closed transport and response rules before provider data can affect ScopeWeave state or browser-visible behavior. + +This record is intentionally narrower than the full Clearfolio production-readiness issue. It extends the configuration boundary introduced by the preceding production-configuration slice and does not claim that arbitrary cross-origin artifact delivery, retry/idempotency policy, or the complete provider lifecycle is finished. + +## Request contract + +Hosted provider requests: + +1. use the configuration-validated provider origin and adapter-owned endpoint path; +2. send tenant claims only to that direct origin request; +3. use Fetch `redirect: "error"` so a redirect is a transport failure rather than a credential-forwarding opportunity; +4. carry a hard 15,000 ms total-request `AbortSignal`; +5. compose a caller cancellation signal with that hard budget for status refreshes; and +6. collapse network, redirect, timeout, and cancellation details into fixed operation-level errors before they can reach browser or diagnostic payloads; and +7. preserve the timeout category when the hard budget aborts an in-progress status response body, so refresh metrics distinguish timeouts from malformed responses. + +ScopeWeave does not retry provider calls in this slice. Retry eligibility, idempotency keys, backoff, cancellation recovery, and persisted operation lifecycle remain explicit follow-up work rather than being guessed at the transport layer. + +## Response contract + +Successful provider responses are accepted only when the media type essence is `application/json`. If `Content-Length` is present it must be an exact non-negative decimal integer no greater than 256 KiB. The body stream is independently counted to the same 256 KiB ceiling, so missing or dishonest length metadata cannot bypass the resource limit. Empty bodies, malformed streams, invalid UTF-8, malformed JSON, and incompatible JSON shapes fail closed with fixed operation-specific errors. + +The adapter never uses `response.json()` directly for successful hosted provider responses. This prevents an otherwise successful response from being buffered without an application-level byte ceiling before validation. + +Provider conversion states remain the exact `PENDING`, `RUNNING`, `SUCCEEDED`, and `FAILED` set. Provider job identifiers are trimmed and limited to 256 characters without control characters before persistence or URL construction. + +## Document boundary + +Document metadata and bytes are validated before `Blob` or `FormData` construction. The provider adapter accepts only: + +- a non-empty document name of at most 512 characters without control characters; +- a MIME string of at most 255 characters without control characters; an empty value retains the existing `application/octet-stream` fallback; +- `Uint8Array`-compatible bytes no larger than 10 MiB. + +The 10 MiB limit matches the current ScopeWeave attachment API ceiling, so the downstream adapter cannot accept a document larger than the application path that feeds it. + +## Artifact boundary and remaining work + +The preceding slice already prevents a cross-origin `artifactToken` from being transplanted into the trusted Clearfolio viewer origin. This slice bounds and media-validates the artifact-link response itself and disables redirects on the request. + +It **does not yet approve arbitrary cross-origin artifact URLs**. Issue #489 still owns the reviewed artifact-origin allowlist and the remaining URL rules for returned links, including credential and fragment rejection. Until that later slice integrates, cross-origin artifact URLs retain the narrower predecessor behavior and must not be represented as a fully qualified production CDN/object-storage policy. + +## Verification contract + +Regression evidence covers: + +- `redirect: "error"` on submit, status, and artifact-link calls; +- hard request-budget signals and caller-signal composition; +- non-JSON media rejection; +- declared and streamed response-size overflow; +- cancellation-detail sanitization; +- document metadata/byte rejection before provider transport; +- oversized provider job identifiers before URL construction; +- valid streamed JSON compatibility for submission, status, HMAC, loopback-development, and artifact-link behavior; +- the predecessor configuration, HMAC, artifact-token-origin, sanitized-error, status-enum, and attachment-refresh contracts under the same normal unit/coverage paths. + +`server/clearfolio.mjs` remains an owned c8 production target. The new provider-boundary regression is registered in both `test:unit` and `test:coverage:cases`; exact statement, branch, function, and line evidence remains a merge gate rather than a documentation claim. + +## Security rationale + +OWASP API10:2023 identifies unsafe consumption of third-party APIs when applications trust integrated-service data, blindly follow redirects, fail to validate returned data, omit timeouts, or fail to limit resources used to process third-party responses. OWASP API4:2023 separately highlights unbounded memory, bandwidth, and execution-time consumption. The transport, timeout, media-type, streaming-byte, identifier, and document limits in this slice apply those controls at the provider boundary rather than relying on Clearfolio to behave correctly. + +The WHATWG Fetch Standard explicitly supports `redirect: "error"` to reject redirect responses. ScopeWeave uses that mode because tenant HMAC claims are provider-origin credentials and there is no reviewed redirect allowlist in the current protocol. + +Node.js 22 provides `AbortSignal.timeout()` and `AbortSignal.any()`, allowing the adapter to impose its own total request budget while preserving upstream cancellation without maintaining a second timer/cancellation protocol. + +## Rollback + +Rollback reverts the provider-boundary implementation, the new and adapted unit regressions, test registrations, deployment guidance, this doctoring record, and the CHANGELOG entry together. The slice adds no database schema or migration. Existing persisted attachment state remains readable by the predecessor implementation. + +## References + +Node.js contributors. (2026). *Global objects: AbortSignal*. Node.js documentation. Retrieved August 15, 2026, from https://nodejs.org/download/release/v22.18.0/docs/api/globals.html + +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/ + +WHATWG. (2026). *Fetch Standard* (Living Standard, updated May 8, 2026). https://fetch.spec.whatwg.org/ diff --git a/package.json b/package.json index 8cefdc74..e55546ef 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/clearfolio-provider-boundary.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-refresh-timeout.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-refresh-timeout.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/clearfolio-provider-boundary.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/server/attachment_status.mjs b/server/attachment_status.mjs index c6f9ae21..3471ca3b 100644 --- a/server/attachment_status.mjs +++ b/server/attachment_status.mjs @@ -281,9 +281,10 @@ export async function refreshAttachmentStatuses(rows, options) { } } catch (error) { counts.failed += 1; - const category = error?.name === ATTACHMENT_STATUS_TIMEOUT_ERROR - ? 'timeout' - : failureCategory; + const category = ( + error?.name === ATTACHMENT_STATUS_TIMEOUT_ERROR + || (failureCategory === 'downstream_lookup' && error?.name === 'TimeoutError') + ) ? 'timeout' : failureCategory; failureCounts[category] += 1; reportRefreshFailure(options.onError, category); } diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 8ba55a90..aee48084 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -10,6 +10,17 @@ const CLEARFOLIO_JOB_STATUSES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAI const MIN_HMAC_SECRET_LENGTH = 32; // WHATWG URL serializes an IPv6 hostname with brackets (`[::1]`). const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); +const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; +const MAX_DOCUMENT_NAME_LENGTH = 512; +const MAX_MIME_LENGTH = 255; +const MAX_JOB_ID_LENGTH = 256; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/; + +/** Hard total-request budget for each Clearfolio provider call. */ +export const CLEARFOLIO_REQUEST_TIMEOUT_MS = 15_000; + +/** Maximum successful Clearfolio JSON response bytes read into memory. */ +export const CLEARFOLIO_MAX_RESPONSE_BYTES = 256 * 1024; /** Whether the process uses the explicit in-memory Clearfolio development adapter. */ export const clearfolioMock = process.env.SCOPEWEAVE_DEV === '1' && !CF_URL_INPUT; @@ -182,6 +193,198 @@ function isClearfolioJobStatus(value) { return typeof value === 'string' && CLEARFOLIO_JOB_STATUSES.has(value); } +/** + * Validate document metadata and bytes before allocating Blob/FormData objects. + * + * ScopeWeave's browser/API attachment ceiling is 10 MiB, so the provider adapter + * never accepts a larger in-process document than the caller can legitimately + * upload. Empty MIME is preserved as the existing application/octet-stream + * fallback. + * + * @param {unknown} document - Untrusted adapter input. + * @returns {{name:string,mime:string,bytes:Uint8Array}} Validated document input. + * @throws {Error} If metadata or bytes are malformed or outside the bounded contract. + */ +function validateDocument(document) { + if (!isJsonRecord(document)) throw new Error('clearfolio document invalid'); + const { name, mime, bytes } = document; + if ( + typeof name !== 'string' + || name.trim().length === 0 + || name.length > MAX_DOCUMENT_NAME_LENGTH + || CONTROL_CHARACTER_PATTERN.test(name) + || typeof mime !== 'string' + || mime.length > MAX_MIME_LENGTH + || CONTROL_CHARACTER_PATTERN.test(mime) + || !(bytes instanceof Uint8Array) + || bytes.byteLength > MAX_DOCUMENT_BYTES + ) { + throw new Error('clearfolio document invalid'); + } + return { name, mime, bytes }; +} + +/** + * Canonicalize and bound a provider job identifier before it reaches a URL. + * + * @param {unknown} jobId - Persisted or provider-returned job identifier. + * @returns {string} Trimmed non-empty identifier no longer than 256 characters. + * @throws {Error} If the identifier is unusable. + */ +function validateJobId(jobId) { + if (typeof jobId !== 'string') throw new Error('clearfolio job id invalid'); + const canonical = jobId.trim(); + if ( + canonical.length === 0 + || canonical.length > MAX_JOB_ID_LENGTH + || CONTROL_CHARACTER_PATTERN.test(canonical) + ) { + throw new Error('clearfolio job id invalid'); + } + return canonical; +} + +/** + * Compose caller cancellation with a hard provider budget that can be disposed. + * + * The returned scope stays active until the provider response body has been + * fully validated or cancelled. Callers must dispose it in `finally` so fast + * requests do not retain a timeout or caller-signal listener for the full budget. + * + * @param {AbortSignal|undefined} callerSignal - Optional upstream cancellation signal. + * @returns {{signal:AbortSignal,dispose:()=>void}} Scoped provider cancellation contract. + */ +function providerSignal(callerSignal) { + if (callerSignal !== undefined && !(callerSignal instanceof AbortSignal)) { + throw new TypeError('signal must be an AbortSignal'); + } + + const controller = new AbortController(); + const timeoutError = new DOMException('Clearfolio provider request timed out', 'TimeoutError'); + const timeoutId = setTimeout( + controller.abort.bind(controller, timeoutError), + CLEARFOLIO_REQUEST_TIMEOUT_MS, + ); + timeoutId.unref(); + + const abortFromCaller = () => controller.abort(callerSignal.reason); + if (callerSignal !== undefined) { + if (callerSignal.aborted) controller.abort(callerSignal.reason); + else callerSignal.addEventListener('abort', abortFromCaller, { once: true }); + } + + return { + signal: controller.signal, + dispose() { + clearTimeout(timeoutId); + if (callerSignal !== undefined) { + callerSignal.removeEventListener('abort', abortFromCaller); + } + }, + }; +} + +/** + * Cancel an unread provider response body before returning a fixed rejection. + * + * Undici-backed fetch responses must be consumed or cancelled so rejected + * downstream bodies cannot strand connection-pool resources. Cancellation + * failures are deliberately hidden because the operation-level error remains + * the authoritative, non-secret client and operator signal. + * + * @param {Response} response - Provider response whose payload must remain unread. + * @param {string} errorMessage - Fixed non-secret error to throw after cancellation. + * @returns {Promise} Promise that always rejects with the fixed error. + */ +async function rejectProviderResponse(response, errorMessage) { + try { + if (response?.body && typeof response.body.cancel === 'function') { + await response.body.cancel(); + } + } catch { + // The fixed operation-level rejection remains authoritative. + } + throw new Error(errorMessage); +} + +/** + * Parse one successful provider JSON response with media-type and byte bounds. + * + * Content-Length is treated only as an early rejection hint; the body stream is + * independently counted so omitted or dishonest length headers cannot bypass the + * memory ceiling. Invalid UTF-8, JSON, stream errors, and cancellation are + * collapsed to one operation-level message. + * + * @param {Response} response - Successful fetch response. + * @param {string} invalidMessage - Fixed operation-level validation error. + * @returns {Promise} Parsed JSON value. + * @throws {Error} If media type, declared/streamed size, UTF-8, or JSON is invalid. + */ +async function readBoundedJson(response, invalidMessage) { + const contentType = response?.headers?.get?.('content-type'); + if ( + typeof contentType !== 'string' + || contentType.split(';', 1)[0].trim().toLowerCase() !== 'application/json' + ) { + return rejectProviderResponse(response, invalidMessage); + } + + const contentLength = response.headers.get('content-length'); + if (contentLength !== null) { + if (!/^\d+$/.test(contentLength)) return rejectProviderResponse(response, invalidMessage); + const declaredBytes = Number(contentLength); + if (!Number.isSafeInteger(declaredBytes) || declaredBytes > CLEARFOLIO_MAX_RESPONSE_BYTES) { + return rejectProviderResponse(response, invalidMessage); + } + } + + if (!response.body || typeof response.body.getReader !== 'function') { + return rejectProviderResponse(response, invalidMessage); + } + + const reader = response.body.getReader(); + const chunks = []; + let totalBytes = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) throw new Error(invalidMessage); + totalBytes += value.byteLength; + if (totalBytes > CLEARFOLIO_MAX_RESPONSE_BYTES) { + try { await reader.cancel(); } catch { /* validation remains authoritative */ } + throw new Error(invalidMessage); + } + chunks.push(value); + } + } catch (error) { + if (error?.message === invalidMessage) throw error; + throw new Error(invalidMessage); + } finally { + try { reader.releaseLock(); } catch { /* no observable effect */ } + } + + if (totalBytes === 0) throw new Error(invalidMessage); + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + let text; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + throw new Error(invalidMessage); + } + try { + return JSON.parse(text); + } catch { + throw new Error(invalidMessage); + } +} + // ---- explicit development-only mock store (restart discards it) ---- const mockDocs = new Map(); // jobId -> { name, mime, bytes } let mockSeq = 0; @@ -199,8 +402,6 @@ export const mockArtifact = (jobId) => (clearfolioMock ? mockDocs.get(jobId) || * * Downstream response text and transport errors are never copied into the * thrown error because the caller may serialize that message to a browser. - * Redirect following is disabled so tenant HMAC headers are never replayed to - * an untrusted Location target. * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting ScopeWeave user identifier. @@ -208,38 +409,51 @@ export const mockArtifact = (jobId) => (clearfolioMock ? mockDocs.get(jobId) || * @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 }) { +export async function submitJob(orgId, userId, document) { + const validatedDocument = validateDocument(document); const configuration = clearfolioConfiguration(); if (configuration.mock) { const jobId = `mockcf-${++mockSeq}`; - mockDocs.set(jobId, { name, mime, bytes }); + mockDocs.set(jobId, validatedDocument); return { jobId, status: 'SUCCEEDED' }; } const form = new FormData(); - form.append('file', new Blob([bytes], { type: mime || 'application/octet-stream' }), name); - let res; + form.append( + 'file', + new Blob([validatedDocument.bytes], { type: validatedDocument.mime || 'application/octet-stream' }), + validatedDocument.name, + ); + const request = providerSignal(); try { - res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs`, { - method: 'POST', - headers: tenantHeaders(orgId, userId, configuration.secret), - body: form, - redirect: 'error', - }); - } 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'); + let res; + try { + res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs`, { + method: 'POST', + headers: tenantHeaders(orgId, userId, configuration.secret), + body: form, + redirect: 'error', + signal: request.signal, + }); + } catch { + throw new Error('clearfolio submit unavailable'); + } + if (!res.ok) return rejectProviderResponse(res, `clearfolio submit failed (${res.status})`); + const data = await readBoundedJson(res, 'clearfolio submit response invalid'); + if (!isJsonRecord(data)) throw new Error('clearfolio submit response invalid'); + const status = data.status === undefined ? 'PENDING' : data.status; + if (typeof data.jobId !== 'string' || !isClearfolioJobStatus(status)) { + throw new Error('clearfolio submit response invalid'); + } + let jobId; + try { + jobId = validateJobId(data.jobId); + } catch { + throw new Error('clearfolio submit response invalid'); + } + return { jobId, status }; + } finally { + request.dispose(); } - return { jobId: data.jobId.trim(), status }; } /** @@ -249,8 +463,6 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { * 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. - * Redirect following is disabled so tenant HMAC headers stay bound to the - * configured provider origin. * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting user identifier. @@ -260,35 +472,58 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns a malformed status. */ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { + const canonicalJobId = validateJobId(jobId); const configuration = clearfolioConfiguration(); - if (configuration.mock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; - let res; + if (configuration.mock) return mockDocs.has(canonicalJobId) ? 'SUCCEEDED' : 'FAILED'; + let request; try { - res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { - headers: tenantHeaders(orgId, userId, configuration.secret), - signal, - redirect: 'error', - }); + request = providerSignal(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'); + try { + let res; + try { + res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs/${encodeURIComponent(canonicalJobId)}`, { + headers: tenantHeaders(orgId, userId, configuration.secret), + signal: request.signal, + redirect: 'error', + }); + } catch (error) { + const unavailable = new Error('clearfolio status unavailable'); + if (error?.name === 'TimeoutError') unavailable.name = 'TimeoutError'; + throw unavailable; + } + if (!res.ok) return rejectProviderResponse(res, `clearfolio status failed (${res.status})`); + let data; + try { + data = await readBoundedJson(res, 'clearfolio status response invalid'); + } catch (error) { + if (request.signal.aborted && request.signal.reason?.name === 'TimeoutError') { + const unavailable = new Error('clearfolio status unavailable'); + unavailable.name = 'TimeoutError'; + throw unavailable; + } + throw error; + } + if (!isJsonRecord(data) || !isClearfolioJobStatus(data.status)) { + throw new Error('clearfolio status response invalid'); + } + return data.status; + } finally { + request.dispose(); } - return data.status; } /** * Issue a viewable artifact URL for a completed Clearfolio job. * - * This root production-config slice accepts only the configured provider origin. - * Cross-origin artifact hosts remain fail-closed until an explicit reviewed - * allowlist lands. Credentials and fragments are never accepted as browser - * redirect authority. Same-origin `artifactToken` values may be translated into - * the trusted viewer route, and redirect following is disabled for the provider - * request so tenant HMAC headers cannot be replayed to a Location target. + * All browser redirect authority remains bound to the configured Clearfolio + * origin in this transport slice. Cross-origin artifact hosts stay fail-closed + * until the separately reviewed artifact-origin allowlist lands. Credentials + * and fragments are rejected for both token-bearing and tokenless links. + * Same-origin `artifactToken` values may be translated into the trusted viewer + * route without exposing the token to an unreviewed host. * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting ScopeWeave user identifier. @@ -297,50 +532,57 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns an invalid link. */ export async function artifactUrl(orgId, userId, jobId) { + const canonicalJobId = validateJobId(jobId); const configuration = clearfolioConfiguration(); - if (configuration.mock) return `/api/mock-clearfolio/${encodeURIComponent(jobId)}`; - let res; + if (configuration.mock) return `/api/mock-clearfolio/${encodeURIComponent(canonicalJobId)}`; + const request = providerSignal(); try { - res = await fetch(`${configuration.baseUrl}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { - method: 'POST', - headers: tenantHeaders(orgId, userId, configuration.secret), - redirect: 'error', - }); - } 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 (typeof link !== 'string' || link.length === 0) { - throw new Error('clearfolio artifact-link response invalid'); - } + let res; + try { + res = await fetch(`${configuration.baseUrl}/api/v1/viewer/${encodeURIComponent(canonicalJobId)}/artifact-links`, { + method: 'POST', + headers: tenantHeaders(orgId, userId, configuration.secret), + redirect: 'error', + signal: request.signal, + }); + } catch { + throw new Error('clearfolio artifact-link unavailable'); + } + if (!res.ok) return rejectProviderResponse(res, `clearfolio artifact-link failed (${res.status})`); + const data = await readBoundedJson(res, 'clearfolio artifact-link response invalid'); + if (!isJsonRecord(data)) throw new Error('clearfolio artifact-link response invalid'); + const link = data.artifactUrl || data.url || data.signedUrl; + if (typeof link !== 'string' || link.length === 0) { + throw new Error('clearfolio artifact-link response invalid'); + } - let url; - let clearfolioUrl; - try { - clearfolioUrl = new URL(configuration.baseUrl); - 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'); - } - if ( - url.origin !== clearfolioUrl.origin - || url.username - || url.password - || url.hash - ) { - throw new Error('clearfolio artifact-link response invalid'); - } + let url; + let clearfolioUrl; + try { + clearfolioUrl = new URL(configuration.baseUrl); + 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'); + } + if ( + url.origin !== clearfolioUrl.origin + || url.username + || url.password + || url.hash + ) { + throw new Error('clearfolio artifact-link response invalid'); + } - const token = url.searchParams.get('artifactToken'); - if (token) { - return `${configuration.baseUrl}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(token)}`; + const token = url.searchParams.get('artifactToken'); + if (token) { + return `${configuration.baseUrl}/viewer/${encodeURIComponent(canonicalJobId)}?artifactToken=${encodeURIComponent(token)}`; + } + return url.href; + } finally { + request.dispose(); } - return url.href; -} \ No newline at end of file +} diff --git a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs index fb48b570..eba5783e 100644 --- a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs +++ b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs @@ -7,6 +7,11 @@ async function freshModule(label) { return import(`../../server/clearfolio.mjs?${label}-${Date.now()}-${Math.random()}`); } +const jsonResponse = (value) => new Response(JSON.stringify(value), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, +}); + test('unconfigured production fails closed instead of creating fake conversions', async () => { delete process.env.SCOPEWEAVE_DEV; delete process.env.CLEARFOLIO_URL; @@ -89,11 +94,11 @@ test('production URL and HMAC configuration rejects ambiguous or unsafe input', process.env.CLEARFOLIO_HMAC_SECRET = HMAC_SECRET; const loopback = await freshModule('development-loopback-http'); const originalFetch = globalThis.fetch; - globalThis.fetch = async () => ({ - ok: true, - status: 200, - json: async () => ({ status: 'RUNNING' }), - }); + globalThis.fetch = async (_url, options) => { + assert.equal(options.redirect, 'error'); + assert.ok(options.signal instanceof AbortSignal); + return jsonResponse({ status: 'RUNNING' }); + }; try { assert.equal(await loopback.jobStatus(1, 2, 'job-1'), 'RUNNING'); @@ -102,7 +107,7 @@ test('production URL and HMAC configuration rejects ambiguous or unsafe input', assert.equal( await ipv6Loopback.jobStatus(1, 2, 'job-1'), 'RUNNING', - 'explicit development mode accepts the IPv6 loopback origin documented by the adapter', + 'explicit development mode accepts the WHATWG-serialized IPv6 loopback origin', ); } finally { globalThis.fetch = originalFetch; @@ -124,11 +129,7 @@ test('Clearfolio tenant claim headers use the documented HMAC contract', async ( globalThis.fetch = async (url, options) => { observedUrl = String(url); observedOptions = options; - return { - ok: true, - status: 200, - json: async () => ({ status: 'RUNNING' }), - }; + return jsonResponse({ status: 'RUNNING' }); }; try { @@ -139,6 +140,8 @@ test('Clearfolio tenant claim headers use the documented HMAC contract', async ( observedUrl, 'https://clearfolio.example/api/v1/convert/jobs/signed-job', ); + assert.equal(observedOptions.redirect, 'error'); + assert.ok(observedOptions.signal instanceof AbortSignal); const issuedAt = '1750000000'; assert.equal(observedOptions.headers['X-Clearfolio-Tenant-Id'], 'sw-org-21'); diff --git a/tests/unit/clearfolio-provider-boundary.test.mjs b/tests/unit/clearfolio-provider-boundary.test.mjs new file mode 100644 index 00000000..169eecaf --- /dev/null +++ b/tests/unit/clearfolio-provider-boundary.test.mjs @@ -0,0 +1,342 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const HMAC_SECRET = 'clearfolio-shared-secret-32-bytes!!'; +process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; +process.env.CLEARFOLIO_HMAC_SECRET = HMAC_SECRET; +delete process.env.SCOPEWEAVE_DEV; + +const originalFetch = globalThis.fetch; +const calls = []; +let responder; +globalThis.fetch = async (url, options = {}) => { + calls.push({ url: String(url), options }); + if (!responder) throw new Error('test responder is not configured'); + return responder(url, options); +}; + +const { + CLEARFOLIO_MAX_RESPONSE_BYTES, + CLEARFOLIO_REQUEST_TIMEOUT_MS, + artifactUrl, + jobStatus, + submitJob, +} = await import(`../../server/clearfolio.mjs?provider-boundary=${Date.now()}`); + +const jsonResponse = (value, init = {}) => new Response(JSON.stringify(value), { + status: init.status ?? 200, + headers: { + 'content-type': init.contentType ?? 'application/json; charset=utf-8', + ...(init.headers || {}), + }, +}); + +function useResponse(value, init = {}) { + responder = async () => jsonResponse(value, init); +} + +test.after(() => { + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; + delete process.env.CLEARFOLIO_HMAC_SECRET; +}); + +test('provider requests disable redirects and carry a bounded total-request signal', async () => { + useResponse({ status: 'RUNNING' }); + const before = calls.length; + assert.equal(await jobStatus(1, 2, 'job-1'), 'RUNNING'); + assert.equal(calls.length, before + 1); + const { options } = calls.at(-1); + assert.equal(options.redirect, 'error'); + assert.ok(options.signal instanceof AbortSignal); + assert.equal(options.signal.aborted, false); + assert.equal(Number.isSafeInteger(CLEARFOLIO_REQUEST_TIMEOUT_MS), true); + assert.equal(CLEARFOLIO_REQUEST_TIMEOUT_MS > 0 && CLEARFOLIO_REQUEST_TIMEOUT_MS <= 30_000, true); +}); + +test('completed provider requests dispose their timeout timers immediately', async () => { + useResponse({ status: 'RUNNING' }); + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const timer = { unref() {} }; + let scheduled = 0; + let cleared = 0; + + globalThis.setTimeout = (callback, delay) => { + assert.equal(typeof callback, 'function'); + assert.equal(delay, CLEARFOLIO_REQUEST_TIMEOUT_MS); + scheduled += 1; + return timer; + }; + globalThis.clearTimeout = (value) => { + assert.equal(value, timer); + cleared += 1; + }; + + try { + assert.equal(await jobStatus(1, 2, 'job-1'), 'RUNNING'); + assert.equal(scheduled, 1, 'one bounded provider timer is created'); + assert.equal(cleared, 1, 'the completed request clears its provider timer'); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } +}); + +test('status response requires JSON media type before parsing', async () => { + useResponse({ status: 'RUNNING' }, { contentType: 'text/plain' }); + await assert.rejects( + () => jobStatus(1, 2, 'job-1'), + /clearfolio status response invalid/, + ); +}); + +test('declared and streamed provider response bodies are bounded', async () => { + assert.equal(Number.isSafeInteger(CLEARFOLIO_MAX_RESPONSE_BYTES), true); + assert.equal(CLEARFOLIO_MAX_RESPONSE_BYTES >= 1024 && CLEARFOLIO_MAX_RESPONSE_BYTES <= 1024 * 1024, true); + + useResponse({ status: 'RUNNING' }, { + headers: { 'content-length': String(CLEARFOLIO_MAX_RESPONSE_BYTES + 1) }, + }); + await assert.rejects( + () => jobStatus(1, 2, 'job-1'), + /clearfolio status response invalid/, + ); + + responder = async () => { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(CLEARFOLIO_MAX_RESPONSE_BYTES)); + controller.enqueue(new Uint8Array(1)); + controller.close(); + }, + }), + { headers: { 'content-type': 'application/json' } }, + ); + assert.equal( + response.headers.get('content-length'), + null, + 'streamed-overflow regression must reach the byte counter without a declared length', + ); + return response; + }; + await assert.rejects( + () => jobStatus(1, 2, 'job-1'), + /clearfolio status response invalid/, + ); +}); + +test('caller cancellation remains composed with the provider request budget', async () => { + const controller = new AbortController(); + controller.abort(new Error('caller cancelled with private detail')); + responder = async (_url, options) => { + assert.equal(options.signal.aborted, true); + throw options.signal.reason; + }; + await assert.rejects( + () => jobStatus(1, 2, 'job-1', { signal: controller.signal }), + (error) => { + assert.equal(error.message, 'clearfolio status unavailable'); + assert.doesNotMatch(error.message, /private detail/); + return true; + }, + ); +}); + +test('caller cancellation after provider start preserves the caller abort reason', async () => { + const controller = new AbortController(); + const callerReason = new Error('caller cancelled after provider start'); + let providerSignal; + let requestStarted; + const started = new Promise((resolve) => { requestStarted = resolve; }); + + responder = async (_url, options) => { + providerSignal = options.signal; + requestStarted(); + return new Promise((_resolve, reject) => { + const rejectOnAbort = () => reject(providerSignal.reason); + if (providerSignal.aborted) rejectOnAbort(); + else providerSignal.addEventListener('abort', rejectOnAbort, { once: true }); + }); + }; + + const pending = jobStatus(1, 2, 'job-1', { signal: controller.signal }); + await started; + controller.abort(callerReason); + + await assert.rejects( + () => pending, + (error) => { + assert.equal(error.message, 'clearfolio status unavailable'); + return true; + }, + ); + assert.equal(providerSignal.aborted, true); + assert.equal(providerSignal.reason, callerReason); +}); + +test('status body timeout remains a timeout for refresh categorization', async () => { + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + globalThis.setTimeout = (callback) => { + callback(); + return { unref() {} }; + }; + globalThis.clearTimeout = () => {}; + responder = async () => new Response( + new ReadableStream({ + start(controller) { + controller.error(new DOMException('body aborted', 'AbortError')); + }, + }), + { headers: { 'content-type': 'application/json' } }, + ); + + try { + await assert.rejects( + () => jobStatus(1, 2, 'job-1'), + (error) => { + assert.equal(error.name, 'TimeoutError'); + assert.equal(error.message, 'clearfolio status unavailable'); + return true; + }, + ); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } +}); + +test('document validation fails before Blob, FormData, or provider transport', async () => { + const before = calls.length; + const invalidDocuments = [ + null, + { name: '', mime: 'text/plain', bytes: Buffer.from('x') }, + { name: 'x'.repeat(513), mime: 'text/plain', bytes: Buffer.from('x') }, + { name: 'x.txt', mime: 'x'.repeat(256), bytes: Buffer.from('x') }, + { name: 'x.txt', mime: 'text/plain', bytes: 'not-bytes' }, + { name: 'x.txt', mime: 'text/plain', bytes: new Uint8Array(10 * 1024 * 1024 + 1) }, + ]; + for (const document of invalidDocuments) { + await assert.rejects( + () => submitJob(1, 2, document), + /clearfolio document invalid/, + ); + } + assert.equal(calls.length, before, 'invalid documents never reach provider transport'); +}); + +test('provider job identifiers are bounded before URL construction', async () => { + const before = calls.length; + for (const operation of [ + () => jobStatus(1, 2, 'x'.repeat(257)), + () => artifactUrl(1, 2, 'x'.repeat(257)), + ]) { + await assert.rejects(operation, /clearfolio job id invalid/); + } + assert.equal(calls.length, before, 'oversized job identifiers never reach provider transport'); +}); + +test('artifact redirects remain bound to the configured provider origin', async () => { + for (const artifactUrlValue of [ + 'https://cdn.example/file.pdf', + 'https://user:pass@clearfolio.example/file.pdf', + 'https://clearfolio.example/file.pdf#private-fragment', + ]) { + useResponse({ artifactUrl: artifactUrlValue }); + await assert.rejects( + () => artifactUrl(1, 2, 'job-1'), + /clearfolio artifact-link response invalid/, + `${artifactUrlValue} must not become browser redirect authority`, + ); + } + + useResponse({ artifactUrl: 'https://clearfolio.example/file.pdf' }); + assert.equal( + await artifactUrl(1, 2, 'job-1'), + 'https://clearfolio.example/file.pdf', + ); +}); + +test('valid submit response remains compatible with the bounded transport', async () => { + useResponse({ jobId: ' job-2 ', status: 'PENDING' }); + assert.deepEqual( + await submitJob(7, 9, { name: 'status.txt', mime: 'text/plain', bytes: Buffer.from('status') }), + { jobId: 'job-2', status: 'PENDING' }, + ); + const { options } = calls.at(-1); + assert.equal(options.redirect, 'error'); + assert.ok(options.signal instanceof AbortSignal); + assert.ok(options.body instanceof FormData); +}); + +test('non-success provider responses cancel unread bodies without parsing downstream payloads', async () => { + let cancelledBodies = 0; + const privatePayload = 'private downstream payload that must remain unread'; + responder = async () => new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(privatePayload)); + }, + cancel() { + cancelledBodies += 1; + }, + }), + { + status: 503, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }, + ); + + const operations = [ + { + run: () => submitJob(1, 2, { name: 'x.txt', mime: 'text/plain', bytes: Buffer.from('x') }), + expected: 'clearfolio submit failed (503)', + }, + { + run: () => jobStatus(1, 2, 'job-1'), + expected: 'clearfolio status failed (503)', + }, + { + run: () => artifactUrl(1, 2, 'job-1'), + expected: 'clearfolio artifact-link failed (503)', + }, + ]; + + for (const [index, operation] of operations.entries()) { + await assert.rejects( + operation.run, + (error) => { + assert.equal(error.message, operation.expected); + assert.doesNotMatch(error.message, /private downstream payload/); + return true; + }, + ); + assert.equal(cancelledBodies, index + 1, 'each rejected response body is explicitly cancelled'); + } + + responder = async () => new Response(null, { status: 503 }); + await assert.rejects( + () => jobStatus(1, 2, 'job-1'), + /clearfolio status failed \(503\)/, + ); + assert.equal(cancelledBodies, 3, 'a response without a body needs no cancellation'); + + responder = async () => new Response( + new ReadableStream({ + cancel() { + throw new Error('private cancel failure'); + }, + }), + { status: 503 }, + ); + await assert.rejects( + () => artifactUrl(1, 2, 'job-1'), + (error) => { + assert.equal(error.message, 'clearfolio artifact-link failed (503)'); + assert.doesNotMatch(error.message, /private cancel failure/); + return true; + }, + ); +}); diff --git a/tests/unit/clearfolio-refresh-timeout.test.mjs b/tests/unit/clearfolio-refresh-timeout.test.mjs new file mode 100644 index 00000000..b3817cc2 --- /dev/null +++ b/tests/unit/clearfolio-refresh-timeout.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { refreshAttachmentStatuses } from '../../server/attachment_status.mjs'; + +const HMAC_SECRET = 'clearfolio-shared-secret-32-bytes!!'; +const originalFetch = globalThis.fetch; +process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; +process.env.CLEARFOLIO_HMAC_SECRET = HMAC_SECRET; + +globalThis.fetch = async () => { + throw new DOMException('private provider timeout detail', 'TimeoutError'); +}; + +const { jobStatus } = await import( + '../../server/clearfolio.mjs?refresh-timeout-classification-test=1' +); + +test.after(() => { + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; + delete process.env.CLEARFOLIO_HMAC_SECRET; +}); + +test('provider timeout stays sanitized and is counted as a refresh timeout', async () => { + await assert.rejects( + () => jobStatus(1, 2, 'job-timeout'), + (error) => { + assert.equal(error.name, 'TimeoutError'); + assert.equal(error.message, 'clearfolio status unavailable'); + assert.doesNotMatch(error.message, /private provider timeout detail/); + return true; + }, + ); + + const rows = [{ id: 1, jobId: 'job-timeout', status: 'PENDING' }]; + const categories = []; + const metrics = {}; + const counts = await refreshAttachmentStatuses(rows, { + orgId: 1, + userId: 2, + timeoutMs: 30_000, + budgetMs: 60_000, + metrics, + onError: ({ category }) => categories.push(category), + jobStatus, + updateStatus: () => { + throw new Error('timed-out status must not be persisted'); + }, + }); + + assert.deepEqual(counts, { + attempted: 1, + changed: 0, + failed: 1, + skipped: 0, + deferred: 0, + }); + assert.deepEqual(categories, ['timeout']); + assert.equal(metrics.attachmentStatusRefreshTimeoutFailures, 1); + assert.equal(metrics.attachmentStatusRefreshDownstreamLookupFailures, 0); + assert.equal(rows[0].status, 'PENDING'); +}); + +test('a persistence TimeoutError remains a persistence failure', async () => { + const rows = [{ id: 1, jobId: 'job-ready', status: 'PENDING' }]; + const categories = []; + const metrics = {}; + const counts = await refreshAttachmentStatuses(rows, { + orgId: 1, + userId: 2, + timeoutMs: 30_000, + budgetMs: 60_000, + metrics, + onError: ({ category }) => categories.push(category), + jobStatus: async () => 'SUCCEEDED', + updateStatus: async () => { + throw new DOMException('storage deadline', 'TimeoutError'); + }, + }); + + assert.deepEqual(counts, { + attempted: 1, + changed: 0, + failed: 1, + skipped: 0, + deferred: 0, + }); + assert.deepEqual(categories, ['status_persistence']); + assert.equal(metrics.attachmentStatusRefreshTimeoutFailures, 0); + assert.equal(metrics.attachmentStatusRefreshPersistenceFailures, 1); + assert.equal(rows[0].status, 'PENDING'); +}); diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index 1ed3b643..13531d5c 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -14,16 +14,27 @@ globalThis.fetch = async (url, options = {}) => { observedUrl = String(url); observedOptions = options; if (downstreamError) throw downstreamError; - return downstreamResponse; + return downstreamResponse(); }; const { artifactUrl, jobStatus, submitJob } = await import( '../../server/clearfolio.mjs?downstream-contract-test=1' ); -function setResponse({ ok = true, status = 200, json }) { +function setResponse({ status = 200, json }) { downstreamError = undefined; - downstreamResponse = { ok, status, json }; + downstreamResponse = async () => { + let body; + try { + body = JSON.stringify(await json()); + } catch { + body = '{'; + } + return new Response(body, { + status, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + }; } function setNetworkError(error) { @@ -52,7 +63,8 @@ test('jobStatus enforces endpoint, signal, transport, HTTP, and status contracts 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); + assert.ok(observedOptions.signal instanceof AbortSignal); + assert.notEqual(observedOptions.signal, controller.signal, 'caller signal is composed with provider timeout'); assert.equal(observedOptions.redirect, 'error'); setNetworkError(new Error('connect ECONNREFUSED https://private-clearfolio.internal')); @@ -63,7 +75,6 @@ test('jobStatus enforces endpoint, signal, transport, HTTP, and status contracts ); setResponse({ - ok: false, status: 503, json: async () => ({ message: 'sensitive downstream text' }), }); @@ -114,7 +125,6 @@ test('submitJob rejects transport details and malformed successful responses', a ); setResponse({ - ok: false, status: 422, json: async () => ({ message: 'tenant-internal rejection detail' }), }); @@ -126,6 +136,7 @@ test('submitJob rejects transport details and malformed successful responses', a assert.equal(observedUrl, 'https://clearfolio.example/api/v1/convert/jobs'); assert.equal(observedOptions.method, 'POST'); assert.equal(observedOptions.redirect, 'error'); + assert.ok(observedOptions.signal instanceof AbortSignal); assert.ok(observedOptions.body instanceof FormData); const malformedPayloads = [ @@ -177,7 +188,6 @@ test('artifactUrl validates links and never exposes transport or response text', ); setResponse({ - ok: false, status: 502, json: async () => ({ message: 'signed URL service secret detail' }), }); @@ -192,6 +202,7 @@ test('artifactUrl validates links and never exposes transport or response text', ); assert.equal(observedOptions.method, 'POST'); assert.equal(observedOptions.redirect, 'error'); + assert.ok(observedOptions.signal instanceof AbortSignal); const malformedPayloads = [ { @@ -265,4 +276,4 @@ test('artifactUrl permits HTTP only for explicit loopback development', async () process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; delete process.env.SCOPEWEAVE_DEV; } -}); \ No newline at end of file +});