diff --git a/CHANGELOG.md b/CHANGELOG.md index 27fa389d..a5bdf651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 membership-revocation, random-source, clock, audit, and atomic repository ports. Route, database, calendar-subscription, and client migration remain follow-up work under issue #413. +- Added a separate framework-neutral calendar-subscription credential domain + with one-time secret display on create/rotate, hash-only persistence ports, + project-scoped management, reusable feed authorization, membership-version + rechecks, safe lifecycle metadata, rotation, revocation, and usage evidence. + Runtime route, database adapter/migration, and UI implementation remain + follow-up work under issue #413; the required calendar-management interaction + contract is captured in Figma and traced in the doctoring record. ### Security diff --git a/docs/doctoring/calendar-subscription-domain.md b/docs/doctoring/calendar-subscription-domain.md new file mode 100644 index 00000000..91b818a3 --- /dev/null +++ b/docs/doctoring/calendar-subscription-domain.md @@ -0,0 +1,77 @@ +# Calendar subscription credential domain + +Status: **active stacked PR work; not shipped on protected `develop`**. + +Issue: #413. + +## Why this slice exists + +Protected `develop` still accepts the general ScopeWeave session credential in the query string of `/api/projects/:id/calendar.ics`. Calendar clients commonly require a reusable URL rather than an `Authorization` header, but reusing a broad session JWT gives that URL the authority of a user session and exposes an unnecessarily powerful credential to URL-handling surfaces such as browser history, copied links, reverse proxies, observability systems, and downstream calendar clients. + +The IETF's OAuth bearer-token guidance is not a specification for ScopeWeave calendar subscriptions. It is relevant threat evidence: RFC 6750 warns against URI-query bearer-token transport because URLs are likely to be logged, while the current OAuth 2.0 Security BCP (RFC 9700, January 2025) further restricts token exposure and recommends limiting token privilege and audience. RFC 8725 provides current BCP guidance for JWT validation. ScopeWeave therefore treats the existing session-JWT calendar URL as a legacy authority boundary to retire, not as the target reusable-subscription design. + +This bounded slice introduces only a framework-neutral lifecycle domain. It does **not** change the protected calendar route, create database tables, migrate stored data, add HTTP endpoints, or implement the management UI. The product-interaction prerequisite from issue #413 is now captured in the Figma design described below; future UI implementation must conform to that reviewed interaction contract and the eventual API semantics rather than inventing an independent credential lifecycle. + +## Figma interaction design + +The editable product contract is `ScopeWeave Calendar Subscription Management — Issue 413`: . + +The design is active-PR evidence, not shipped-product evidence. It uses the current protected ScopeWeave visual tokens and reusable button/status components and covers seven explicit states: + +1. interaction/security contract; +2. subscription management list; +3. creation dialog; +4. one-time secret reveal; +5. rotation confirmation; +6. revocation confirmation; and +7. empty state. + +The interaction contract requires a recognizable subscription name and explicit expiry, read-only/current-project purpose copy, one-time secret display on create or rotate, an explicit copy/save acknowledgement, lifecycle metadata without stored secret material, immediate old-secret invalidation on rotation, named destructive consequences on revocation, and next-action-oriented failure/recovery copy. Active, expired, and revoked states use text in addition to color. + +Accessibility acceptance is part of the design rather than a later visual-polish step: the dialog title is the accessible name, helper/error content is associated with its control, focus enters the dialog and returns to its invoker, Escape never silently commits, primary actions retain visible focus, and copy/status feedback is announced without moving focus. UI implementation must add executable browser acceptance for these interactions before the Figma design can be represented as shipped behavior. + +## Domain contract + +`server/calendar_subscription_domain.mjs` defines one fixed resource audience, `scopeweave:calendar`, and an injected service with five operations: create, list, authorize, rotate, and revoke. + +A newly created or rotated subscription secret is generated from 32 random bytes and encoded as unpadded base64url. The plaintext secret is returned only by `create()` or `rotate()`. Only its SHA-256 hash crosses the repository port. Audit events contain the independently random correlation identifier (`csub_` plus 128 random bits), actor/resource identifiers, audience, and lifecycle metadata, but never the plaintext secret or its hash. + +The reusable credential is deliberately separate from the short-lived `stream` and `attachment_view` grant domain in PR #506. A calendar subscription needs an operator-visible lifecycle—name, creation time, last-use time, expiry, rotation, revocation, purpose/resource, and status—whereas the short-lived access-grant domain is one-time and capped at five minutes. Reusing either the general session JWT or the one-time grant lifecycle would collapse materially different authority and recovery semantics. + +Creation and lifecycle management require project-management authorization. Every secret use re-checks active membership and passes the captured membership version into `recordUsageAtomically(...)`. Rotation similarly passes the current membership version into `rotateSubscriptionAtomically(...)`. Production adapters must compare that supplied version with live membership state in the same atomic transition, closing a revoke-between-check-and-use race. If a deployment cannot provide that shared transaction boundary, its membership-removal path must atomically revoke affected calendar subscriptions instead. + +Expiry is mandatory and exact expiry is unusable. This domain intentionally does not invent a universal lifetime: the product/API layer must choose a bounded policy appropriate to deployment risk and communicate it to operators. Rotation invalidates the previous secret as part of one atomic repository transition; revocation is idempotent from the operator perspective. Listing returns safe lifecycle metadata only and never permits the stored secret, secret hash, or membership-version value to be redisplayed. + +Audit delivery occurs only after the durable repository transition and is best-effort at this framework-neutral boundary. An audit transport outage must not convert an already completed secret creation, rotation, revocation, or usage record into a client-visible failure that encourages unsafe retry. Production persistence that requires durable evidence should couple the state change to a transactional audit outbox. + +## Persistence boundary for the follow-up adapter + +Issue #413 owns the intended normalized durable objects `calendar_subscriptions`, `subscription_rotations`, and `subscription_usage_events`. This PR does not create them. The separate schema-migration work in PR #500 establishes migration identity/fail-closed generation handling for the broader database modernization and must remain the schema-transition authority rather than being duplicated here. + +A future adapter must preserve at least these invariants: + +- `calendar_subscriptions` contains the subscription identity, project/subject binding, name, fixed audience, current secret hash, membership version, creation/expiry/last-use/rotation/revocation metadata; plaintext secrets are never persisted. +- `subscription_rotations` records non-secret correlation/evidence for each successful rotation without retaining previous plaintext secrets. +- `subscription_usage_events` records bounded operational usage evidence suitable for access/export investigation without turning request URLs or secrets into logs. +- create, rotate, revoke, and last-use updates are transactionally consistent with their lifecycle evidence, and membership invalidation cannot race a successful use or rotation into continued access. +- owned database object names remain descriptive multiword `snake_case` and the eventual relational design is normalized rather than embedding lifecycle history in an opaque JSON column. + +## Verification and traceability + +TDD began at commit `c86c878f6e9876fe9ca5c85d8b7e8b25fcb5ed77`, whose behavior contract imported an absent `server/calendar_subscription_domain.mjs`; the focused Node execution failed RED with `ERR_MODULE_NOT_FOUND`. Commit `1210d52225fa25e9f122e23fe80578334e64cff8` supplied the production domain. Commit `1bb891b9c34b997c0b618857093bba41a967cbe6` added failure-boundary coverage for port contracts, invalid identifiers/names/expiry, malformed membership versions, authorization denial, invalid clock/random sources, safe listing states, malformed/unknown/wrong-boundary credentials, and atomic lifecycle rejection. Both focused Node test files passed locally after implementation. + +The canonical repository coverage producer now instruments `server/calendar_subscription_domain.mjs` and executes both calendar-domain test files. Local `c8` evidence is unavailable in the current execution environment because the package is not locally cached; therefore hosted exact-head Istanbul evidence remains mandatory and no local statement/branch percentage is claimed as passing evidence. + +The protected `develop` truth remains unchanged by this active branch. Before integration, the exact child head must be reconciled after PR #506, all applicable repository/organization checks and security/dependency/supply-chain evidence must be terminal-success on the unchanged current head, owned production coverage must satisfy the repository contract, every valid review finding must be resolved, and the live rulesets' qualifying independent current-head/last-push approval must exist. Predecessor-head, skipped-required, neutral, synthetic, status-only, or model-only evidence does not transfer. + +## Rollback + +Until a persistence/route slice exists, rollback is code-only: remove this module, its focused tests, coverage registrations, doctoring entry, and changelog line together. No database migration or credential invalidation is required because this slice cannot yet issue a production calendar subscription. Once persistence is added, rollback must preserve the durable revocation/rotation history and must never restore the broad session-JWT query credential as the security-safe steady state. + +## References + +Jones, M., & 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.json b/package.json index 22fb1448..0b0d2dbf 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", - "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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.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/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.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 --include=server/orchestrator.mjs --include=server/access_grant_domain.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/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.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/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: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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.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/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.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 --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --include=server/calendar_subscription_domain.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/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.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/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", diff --git a/server/calendar_subscription_domain.mjs b/server/calendar_subscription_domain.mjs new file mode 100644 index 00000000..f40e0717 --- /dev/null +++ b/server/calendar_subscription_domain.mjs @@ -0,0 +1,338 @@ +import { createHash } from 'node:crypto'; + +const SECRET_BYTES = 32; +const SUBSCRIPTION_ID_BYTES = 16; +const SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const NAME_MAX_LENGTH = 120; +const MEMBERSHIP_VERSION_MAX_LENGTH = 128; +const CONTROL_PATTERN = /[\u0000-\u001F\u007F-\u009F]/u; + +/** Fixed resource-server audience for reusable project calendar subscriptions. */ +export const CALENDAR_SUBSCRIPTION_AUDIENCE = 'scopeweave:calendar'; + +/** Stable domain error safe for thin HTTP adapters to map without secret-state disclosure. */ +export class CalendarSubscriptionError extends Error { + /** + * @param {string} code Stable machine-readable error code. + * @param {number} status Suggested HTTP status for an adapter. + */ + constructor(code, status) { + super(code); + this.name = 'CalendarSubscriptionError'; + this.code = code; + this.status = status; + } +} + +function requireMethod(port, method) { + if (!port || typeof port[method] !== 'function') { + throw new TypeError(`calendar-subscription dependency must provide ${method}()`); + } +} + +function isBoundedString(value) { + return typeof value === 'string' + && value.length > 0 + && value === value.trim() + && !CONTROL_PATTERN.test(value); +} + +function normalizeName(value) { + if (!isBoundedString(value) || value.length > NAME_MAX_LENGTH) { + throw new CalendarSubscriptionError('calendar_subscription_request_invalid', 400); + } + return value; +} + +function validateIdentity(subjectId, projectId) { + if (!isBoundedString(subjectId) || !isBoundedString(projectId)) { + throw new CalendarSubscriptionError('calendar_subscription_request_invalid', 400); + } +} + +function normalizeSubscriptionId(value) { + if (!isBoundedString(value)) { + throw new CalendarSubscriptionError('calendar_subscription_request_invalid', 400); + } + return value; +} + +function readNow(clock) { + const nowMs = clock.nowMs(); + if (!Number.isSafeInteger(nowMs) || nowMs < 0) { + throw new TypeError('calendar-subscription clock must return a non-negative safe integer'); + } + return nowMs; +} + +function normalizeExpiry(expiresAtMs, nowMs) { + if (!Number.isSafeInteger(expiresAtMs) || expiresAtMs <= nowMs) { + throw new CalendarSubscriptionError('calendar_subscription_expiry_invalid', 400); + } + return expiresAtMs; +} + +function normalizeMembershipVersion(value) { + if (Number.isSafeInteger(value) && value >= 0) return value; + if ( + typeof value === 'string' + && value.length > 0 + && value.length <= MEMBERSHIP_VERSION_MAX_LENGTH + && value === value.trim() + && !CONTROL_PATTERN.test(value) + ) return value; + throw unauthorizedSubscription(); +} + +function encodeSecret(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== SECRET_BYTES) { + throw new TypeError(`calendar-subscription random source must return ${SECRET_BYTES} bytes`); + } + return Buffer.from(bytes).toString('base64url'); +} + +function encodeSubscriptionId(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== SUBSCRIPTION_ID_BYTES) { + throw new TypeError(`calendar-subscription random source must return ${SUBSCRIPTION_ID_BYTES} bytes for subscription id`); + } + return `csub_${Buffer.from(bytes).toString('hex')}`; +} + +function hashSecret(secret) { + return createHash('sha256').update(secret, 'utf8').digest('hex'); +} + +function unauthorizedSubscription() { + return new CalendarSubscriptionError('calendar_subscription_unauthorized', 401); +} + +function notFoundSubscription() { + return new CalendarSubscriptionError('calendar_subscription_not_found', 404); +} + +function statusOf(record, nowMs) { + if (record.revoked_at_ms !== null && record.revoked_at_ms !== undefined) return 'revoked'; + if (record.expires_at_ms <= nowMs) return 'expired'; + return 'active'; +} + +function viewOf(record, nowMs) { + return Object.freeze({ + subscriptionId: record.subscription_id, + subjectId: record.subject_id, + projectId: record.project_id, + name: record.name, + audience: record.audience, + createdAtMs: record.created_at_ms, + expiresAtMs: record.expires_at_ms, + lastUsedAtMs: record.last_used_at_ms ?? null, + rotatedAtMs: record.rotated_at_ms ?? null, + revokedAtMs: record.revoked_at_ms ?? null, + status: statusOf(record, nowMs), + }); +} + +async function recordAuditBestEffort(auditSink, event) { + try { + await auditSink.record(event); + } catch { + // Durable subscription state is authoritative after the repository commits. + // Production persistence that requires guaranteed evidence should pair the + // state transition with a transactional outbox rather than retrying secrets. + } +} + +async function assertManage(projectAuthorization, subjectId, projectId) { + try { + await projectAuthorization.assertCanManage({ subjectId, projectId }); + } catch { + throw notFoundSubscription(); + } +} + +async function readMembershipVersion(membershipRevocation, subjectId, projectId) { + try { + return normalizeMembershipVersion(await membershipRevocation.assertActive({ subjectId, projectId })); + } catch { + throw unauthorizedSubscription(); + } +} + +/** + * Build the framework-neutral lifecycle for reusable calendar-subscription secrets. + * + * Plaintext subscription secrets are returned only from create/rotate and only + * their SHA-256 hashes cross the repository port. Repository adapters own the + * durable `calendar_subscriptions`, `subscription_rotations`, and + * `subscription_usage_events` state and must make usage/rotation/revocation + * transitions atomic. `recordUsageAtomically()` and + * `rotateSubscriptionAtomically()` must compare the supplied membership version + * with live membership state in the same transaction, closing the + * revoke-between-check-and-use race. Adapters without a shared transaction must + * atomically revoke affected subscriptions when membership changes. + * + * Audit delivery is best-effort after repository commits so an audit transport + * outage cannot cause a client to retry and accidentally expose multiple active + * secrets. Production adapters that require durable audit evidence should use a + * transactional outbox. + * + * @param {object} ports Injected infrastructure and authorization ports. + * @param {object} ports.repository CalendarSubscriptionRepository. + * @param {object} ports.clock Clock exposing nowMs(). + * @param {object} ports.randomSource Random source exposing randomBytes(). + * @param {object} ports.auditSink Secret-free audit sink exposing record(). + * @param {object} ports.projectAuthorization Project authorization port. + * @param {object} ports.membershipRevocation Membership revocation/version port. + * @returns {{create: Function, list: Function, authorize: Function, rotate: Function, revoke: Function}} Immutable service. + */ +export function createCalendarSubscriptionService({ + repository, + clock, + randomSource, + auditSink, + projectAuthorization, + membershipRevocation, +} = {}) { + requireMethod(repository, 'insertSubscription'); + requireMethod(repository, 'listSubscriptions'); + requireMethod(repository, 'findSubscriptionByHash'); + requireMethod(repository, 'recordUsageAtomically'); + requireMethod(repository, 'rotateSubscriptionAtomically'); + requireMethod(repository, 'revokeSubscriptionAtomically'); + requireMethod(clock, 'nowMs'); + requireMethod(randomSource, 'randomBytes'); + requireMethod(auditSink, 'record'); + requireMethod(projectAuthorization, 'assertCanManage'); + requireMethod(membershipRevocation, 'assertActive'); + + async function create({ subjectId, projectId, name, expiresAtMs }) { + validateIdentity(subjectId, projectId); + const normalizedName = normalizeName(name); + await assertManage(projectAuthorization, subjectId, projectId); + const membershipVersion = await readMembershipVersion(membershipRevocation, subjectId, projectId); + const nowMs = readNow(clock); + const normalizedExpiry = normalizeExpiry(expiresAtMs, nowMs); + const secret = encodeSecret(randomSource.randomBytes(SECRET_BYTES)); + const subscriptionId = encodeSubscriptionId(randomSource.randomBytes(SUBSCRIPTION_ID_BYTES)); + const record = { + subscription_id: subscriptionId, + secret_hash: hashSecret(secret), + subject_id: subjectId, + project_id: projectId, + name: normalizedName, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + membership_version: membershipVersion, + created_at_ms: nowMs, + expires_at_ms: normalizedExpiry, + last_used_at_ms: null, + rotated_at_ms: null, + revoked_at_ms: null, + }; + await repository.insertSubscription(record); + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.created', + subscription_id: subscriptionId, + subject_id: subjectId, + project_id: projectId, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + expires_at_ms: normalizedExpiry, + }); + return Object.freeze({ secret, ...viewOf(record, nowMs) }); + } + + async function list({ subjectId, projectId }) { + validateIdentity(subjectId, projectId); + await assertManage(projectAuthorization, subjectId, projectId); + const nowMs = readNow(clock); + const records = await repository.listSubscriptions({ subject_id: subjectId, project_id: projectId }); + if (!Array.isArray(records)) throw new TypeError('calendar-subscription repository must return an array from listSubscriptions()'); + return Object.freeze(records.map((record) => viewOf(record, nowMs))); + } + + async function authorize({ secret, projectId }) { + if (typeof secret !== 'string' || !SECRET_PATTERN.test(secret) || !isBoundedString(projectId)) { + throw unauthorizedSubscription(); + } + const secretHash = hashSecret(secret); + const existing = await repository.findSubscriptionByHash(secretHash); + if (!existing || existing.project_id !== projectId || existing.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE) { + throw unauthorizedSubscription(); + } + const membershipVersion = await readMembershipVersion( + membershipRevocation, + existing.subject_id, + existing.project_id, + ); + const used = await repository.recordUsageAtomically(secretHash, { + now_ms: readNow(clock), + project_id: projectId, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + membership_version: membershipVersion, + }); + if (!used) throw unauthorizedSubscription(); + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.used', + subscription_id: used.subscription_id, + subject_id: used.subject_id, + project_id: used.project_id, + audience: used.audience, + }); + return Object.freeze({ + subscriptionId: used.subscription_id, + subjectId: used.subject_id, + projectId: used.project_id, + audience: used.audience, + }); + } + + async function rotate({ subjectId, projectId, subscriptionId, expiresAtMs }) { + validateIdentity(subjectId, projectId); + const normalizedSubscriptionId = normalizeSubscriptionId(subscriptionId); + await assertManage(projectAuthorization, subjectId, projectId); + const membershipVersion = await readMembershipVersion(membershipRevocation, subjectId, projectId); + const nowMs = readNow(clock); + const normalizedExpiry = normalizeExpiry(expiresAtMs, nowMs); + const secret = encodeSecret(randomSource.randomBytes(SECRET_BYTES)); + const rotated = await repository.rotateSubscriptionAtomically(normalizedSubscriptionId, { + subject_id: subjectId, + project_id: projectId, + new_secret_hash: hashSecret(secret), + now_ms: nowMs, + expires_at_ms: normalizedExpiry, + membership_version: membershipVersion, + }); + if (!rotated) throw notFoundSubscription(); + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.rotated', + subscription_id: rotated.subscription_id, + subject_id: rotated.subject_id, + project_id: rotated.project_id, + audience: rotated.audience, + expires_at_ms: rotated.expires_at_ms, + }); + return Object.freeze({ secret, ...viewOf(rotated, nowMs) }); + } + + async function revoke({ subjectId, projectId, subscriptionId }) { + validateIdentity(subjectId, projectId); + const normalizedSubscriptionId = normalizeSubscriptionId(subscriptionId); + await assertManage(projectAuthorization, subjectId, projectId); + const nowMs = readNow(clock); + const revoked = await repository.revokeSubscriptionAtomically(normalizedSubscriptionId, { + subject_id: subjectId, + project_id: projectId, + now_ms: nowMs, + }); + if (!revoked) throw notFoundSubscription(); + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.revoked', + subscription_id: revoked.subscription_id, + subject_id: revoked.subject_id, + project_id: revoked.project_id, + audience: revoked.audience, + }); + return viewOf(revoked, nowMs); + } + + return Object.freeze({ create, list, authorize, rotate, revoke }); +} diff --git a/tests/unit/calendar-subscription-domain-edge.test.mjs b/tests/unit/calendar-subscription-domain-edge.test.mjs new file mode 100644 index 00000000..6bd31b85 --- /dev/null +++ b/tests/unit/calendar-subscription-domain-edge.test.mjs @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import { + CALENDAR_SUBSCRIPTION_AUDIENCE, + CalendarSubscriptionError, + createCalendarSubscriptionService, +} from '../../server/calendar_subscription_domain.mjs'; + +const NOW = 1_900_000_000_000; +const VALID_SECRET = Buffer.alloc(32, 7).toString('base64url'); + +function ports(overrides = {}) { + const repository = { + insertSubscription: async () => {}, + listSubscriptions: async () => [], + findSubscriptionByHash: async () => null, + recordUsageAtomically: async () => null, + rotateSubscriptionAtomically: async () => null, + revokeSubscriptionAtomically: async () => null, + ...(overrides.repository || {}), + }; + return { + repository, + clock: overrides.clock || { nowMs: () => NOW }, + randomSource: overrides.randomSource || { randomBytes: (size) => new Uint8Array(size).fill(9) }, + auditSink: overrides.auditSink || { record: async () => {} }, + projectAuthorization: overrides.projectAuthorization || { assertCanManage: async () => {} }, + membershipRevocation: overrides.membershipRevocation || { assertActive: async () => 'membership-v1' }, + }; +} + +function service(overrides = {}) { + return createCalendarSubscriptionService(ports(overrides)); +} + +async function expectError(promise, code, status) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof CalendarSubscriptionError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +{ + const valid = ports(); + const requirements = [ + ['repository', 'insertSubscription'], + ['repository', 'listSubscriptions'], + ['repository', 'findSubscriptionByHash'], + ['repository', 'recordUsageAtomically'], + ['repository', 'rotateSubscriptionAtomically'], + ['repository', 'revokeSubscriptionAtomically'], + ['clock', 'nowMs'], + ['randomSource', 'randomBytes'], + ['auditSink', 'record'], + ['projectAuthorization', 'assertCanManage'], + ['membershipRevocation', 'assertActive'], + ]; + for (const [portName, method] of requirements) { + const broken = ports(); + broken[portName] = { ...broken[portName] }; + delete broken[portName][method]; + assert.throws( + () => createCalendarSubscriptionService(broken), + new RegExp(`calendar-subscription dependency must provide ${method}\\(\\)`), + ); + } + assert.ok(createCalendarSubscriptionService(valid)); + assert.throws(() => createCalendarSubscriptionService(), /insertSubscription/); +} + +{ + const svc = service(); + const invalidIdentities = [ + { subjectId: '', projectId: 'project-1' }, + { subjectId: ' user-1', projectId: 'project-1' }, + { subjectId: 'user\n1', projectId: 'project-1' }, + { subjectId: 'user-1', projectId: '' }, + { subjectId: 'user-1', projectId: ' project-1' }, + { subjectId: 1, projectId: 'project-1' }, + ]; + for (const identity of invalidIdentities) { + await expectError( + svc.create({ ...identity, name: 'Calendar', expiresAtMs: NOW + 10 }), + 'calendar_subscription_request_invalid', 400, + ); + } + for (const name of ['', ' Calendar', 'Calendar\u0000', 'x'.repeat(121), null]) { + await expectError( + svc.create({ subjectId: 'user-1', projectId: 'project-1', name, expiresAtMs: NOW + 10 }), + 'calendar_subscription_request_invalid', 400, + ); + } + for (const expiresAtMs of [NOW, NOW - 1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + await expectError( + svc.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs }), + 'calendar_subscription_expiry_invalid', 400, + ); + } +} + +{ + for (const version of [0, 4, 'v4']) { + const svc = service({ membershipRevocation: { assertActive: async () => version } }); + const created = await svc.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }); + assert.equal(created.status, 'active'); + } + for (const version of [-1, Number.NaN, '', ' v1', 'v1\u0000', 'x'.repeat(129), {}, null]) { + const svc = service({ membershipRevocation: { assertActive: async () => version } }); + await expectError( + svc.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + 'calendar_subscription_unauthorized', 401, + ); + } + const revoked = service({ membershipRevocation: { assertActive: async () => { throw new Error('revoked'); } } }); + await expectError( + revoked.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + 'calendar_subscription_unauthorized', 401, + ); +} + +{ + const denied = service({ projectAuthorization: { assertCanManage: async () => { throw new Error('no'); } } }); + await expectError( + denied.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + 'calendar_subscription_not_found', 404, + ); + await expectError(denied.list({ subjectId: 'user-1', projectId: 'project-1' }), 'calendar_subscription_not_found', 404); +} + +{ + const badClock = service({ clock: { nowMs: () => -1 } }); + await assert.rejects( + badClock.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + /clock must return a non-negative safe integer/, + ); + const fractionalClock = service({ clock: { nowMs: () => 1.5 } }); + await assert.rejects(fractionalClock.list({ subjectId: 'user-1', projectId: 'project-1' }), /clock must return/); +} + +{ + const badSecretBytes = service({ randomSource: { randomBytes: () => new Uint8Array(31) } }); + await assert.rejects( + badSecretBytes.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + /must return 32 bytes/, + ); + let call = 0; + const badIdBytes = service({ randomSource: { randomBytes: (size) => { call += 1; return new Uint8Array(call === 1 ? size : 15); } } }); + await assert.rejects( + badIdBytes.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + /must return 16 bytes for subscription id/, + ); + const notBytes = service({ randomSource: { randomBytes: () => 'not-bytes' } }); + await assert.rejects( + notBytes.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + /must return 32 bytes/, + ); +} + +{ + const nonArray = service({ repository: { listSubscriptions: async () => ({}) } }); + await assert.rejects(nonArray.list({ subjectId: 'user-1', projectId: 'project-1' }), /must return an array/); + + const expiredRow = { + subscription_id: 'csub_expired', subject_id: 'user-1', project_id: 'project-1', name: 'Old', + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, created_at_ms: NOW - 100, expires_at_ms: NOW, + last_used_at_ms: NOW - 10, rotated_at_ms: NOW - 20, revoked_at_ms: null, + }; + const revokedRow = { ...expiredRow, subscription_id: 'csub_revoked', revoked_at_ms: NOW - 1 }; + const listed = await service({ repository: { listSubscriptions: async () => [expiredRow, revokedRow] } }) + .list({ subjectId: 'user-1', projectId: 'project-1' }); + assert.deepEqual(listed.map((row) => row.status), ['expired', 'revoked']); + assert.equal(listed[0].lastUsedAtMs, NOW - 10); + assert.equal(listed[0].rotatedAtMs, NOW - 20); +} + +{ + const svc = service(); + for (const candidate of [null, '', 'short', `${VALID_SECRET}=`, VALID_SECRET]) { + const projectId = candidate === VALID_SECRET ? '' : 'project-1'; + await expectError(svc.authorize({ secret: candidate, projectId }), 'calendar_subscription_unauthorized', 401); + } + + const baseRecord = { + subscription_id: 'csub_123', subject_id: 'user-1', project_id: 'project-1', name: 'Calendar', + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, created_at_ms: NOW - 1, expires_at_ms: NOW + 100, + last_used_at_ms: null, rotated_at_ms: null, revoked_at_ms: null, + }; + for (const record of [null, { ...baseRecord, project_id: 'project-2' }, { ...baseRecord, audience: 'other' }]) { + const candidate = service({ repository: { findSubscriptionByHash: async () => record } }); + await expectError(candidate.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); + } + const atomicReject = service({ repository: { + findSubscriptionByHash: async () => baseRecord, + recordUsageAtomically: async () => null, + } }); + await expectError(atomicReject.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); + + const membershipReject = service({ + repository: { findSubscriptionByHash: async () => baseRecord }, + membershipRevocation: { assertActive: async () => { throw new Error('gone'); } }, + }); + await expectError(membershipReject.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); +} + +{ + const svc = service(); + for (const input of [ + { subjectId: '', projectId: 'project-1', subscriptionId: 'csub_1', expiresAtMs: NOW + 10 }, + { subjectId: 'user-1', projectId: 'project-1', subscriptionId: '', expiresAtMs: NOW + 10 }, + ]) { + await expectError(svc.rotate(input), 'calendar_subscription_request_invalid', 400); + } + await expectError( + svc.rotate({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_1', expiresAtMs: NOW }), + 'calendar_subscription_expiry_invalid', 400, + ); + await expectError( + svc.rotate({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_missing', expiresAtMs: NOW + 10 }), + 'calendar_subscription_not_found', 404, + ); + await expectError( + svc.revoke({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_missing' }), + 'calendar_subscription_not_found', 404, + ); + await expectError( + svc.revoke({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: '' }), + 'calendar_subscription_request_invalid', 400, + ); +} + +console.log('calendar subscription domain edge tests passed'); diff --git a/tests/unit/calendar-subscription-domain.test.mjs b/tests/unit/calendar-subscription-domain.test.mjs new file mode 100644 index 00000000..3739a975 --- /dev/null +++ b/tests/unit/calendar-subscription-domain.test.mjs @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + CALENDAR_SUBSCRIPTION_AUDIENCE, + CalendarSubscriptionError, + createCalendarSubscriptionService, +} from '../../server/calendar_subscription_domain.mjs'; + +function sequentialRandomSource() { + let seed = 1; + return { + randomBytes(size) { + const bytes = new Uint8Array(size); + for (let index = 0; index < size; index += 1) bytes[index] = (seed + index) % 256; + seed += size; + return bytes; + }, + }; +} + +function makeHarness({ nowMs = 1_800_000_000_000, auditThrows = false } = {}) { + const rows = new Map(); + const hashIndex = new Map(); + const rotations = []; + const usages = []; + const audits = []; + const activeMemberships = new Map([['user-1:project-1', 3]]); + + const repository = { + async insertSubscription(record) { + rows.set(record.subscription_id, structuredClone(record)); + hashIndex.set(record.secret_hash, record.subscription_id); + }, + async listSubscriptions({ subject_id, project_id }) { + return [...rows.values()].filter((row) => row.subject_id === subject_id && row.project_id === project_id); + }, + async findSubscriptionByHash(secretHash) { + const id = hashIndex.get(secretHash); + return id ? structuredClone(rows.get(id)) : null; + }, + async recordUsageAtomically(secretHash, expected) { + const id = hashIndex.get(secretHash); + const row = id ? rows.get(id) : null; + const liveVersion = activeMemberships.get(`${row?.subject_id}:${row?.project_id}`); + if ( + !row + || row.project_id !== expected.project_id + || row.audience !== expected.audience + || row.revoked_at_ms !== null + || row.expires_at_ms <= expected.now_ms + || liveVersion !== expected.membership_version + ) return null; + row.last_used_at_ms = expected.now_ms; + usages.push({ subscription_id: row.subscription_id, used_at_ms: expected.now_ms }); + return structuredClone(row); + }, + async rotateSubscriptionAtomically(subscriptionId, expected) { + const row = rows.get(subscriptionId); + const liveVersion = activeMemberships.get(`${row?.subject_id}:${row?.project_id}`); + if ( + !row + || row.subject_id !== expected.subject_id + || row.project_id !== expected.project_id + || row.revoked_at_ms !== null + || row.expires_at_ms <= expected.now_ms + || liveVersion !== expected.membership_version + ) return null; + hashIndex.delete(row.secret_hash); + rotations.push({ + subscription_id: row.subscription_id, + previous_secret_hash: row.secret_hash, + rotated_at_ms: expected.now_ms, + }); + row.secret_hash = expected.new_secret_hash; + row.rotated_at_ms = expected.now_ms; + row.expires_at_ms = expected.expires_at_ms; + row.membership_version = expected.membership_version; + hashIndex.set(row.secret_hash, row.subscription_id); + return structuredClone(row); + }, + async revokeSubscriptionAtomically(subscriptionId, expected) { + const row = rows.get(subscriptionId); + if (!row || row.subject_id !== expected.subject_id || row.project_id !== expected.project_id) return null; + if (row.revoked_at_ms === null) row.revoked_at_ms = expected.now_ms; + return structuredClone(row); + }, + }; + + const projectAuthorization = { + async assertCanManage({ subjectId, projectId }) { + if (!activeMemberships.has(`${subjectId}:${projectId}`)) throw new Error('not authorized'); + }, + }; + const membershipRevocation = { + async assertActive({ subjectId, projectId }) { + const version = activeMemberships.get(`${subjectId}:${projectId}`); + if (version === undefined) throw new Error('revoked'); + return version; + }, + }; + const auditSink = { + async record(event) { + if (auditThrows) throw new Error('audit unavailable'); + audits.push(structuredClone(event)); + }, + }; + const clock = { nowMs: () => nowMs }; + const service = createCalendarSubscriptionService({ + repository, + clock, + randomSource: sequentialRandomSource(), + auditSink, + projectAuthorization, + membershipRevocation, + }); + + return { + service, + repository, + rows, + hashIndex, + rotations, + usages, + audits, + activeMemberships, + setNow(value) { nowMs = value; }, + }; +} + +async function expectDomainError(promise, code, status) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof CalendarSubscriptionError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +{ + const h = makeHarness(); + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Executive calendar', + expiresAtMs: 1_800_086_400_000, + }); + assert.equal(CALENDAR_SUBSCRIPTION_AUDIENCE, 'scopeweave:calendar'); + assert.match(created.secret, /^[A-Za-z0-9_-]{43}$/); + assert.match(created.subscriptionId, /^csub_[a-f0-9]{32}$/); + assert.equal(created.name, 'Executive calendar'); + assert.equal(created.projectId, 'project-1'); + assert.equal(created.subjectId, 'user-1'); + assert.equal(created.audience, CALENDAR_SUBSCRIPTION_AUDIENCE); + assert.equal(created.createdAtMs, 1_800_000_000_000); + assert.equal(created.expiresAtMs, 1_800_086_400_000); + assert.equal(created.lastUsedAtMs, null); + assert.equal(created.rotatedAtMs, null); + assert.equal(created.revokedAtMs, null); + assert.equal(created.status, 'active'); + assert.equal(Object.isFrozen(created), true); + + const stored = h.rows.get(created.subscriptionId); + assert.equal(stored.secret_hash, createHash('sha256').update(created.secret).digest('hex')); + assert.equal(stored.membership_version, 3); + assert.equal(JSON.stringify(stored).includes(created.secret), false); + assert.equal(JSON.stringify(h.audits).includes(created.secret), false); + assert.equal(JSON.stringify(h.audits).includes(stored.secret_hash), false); + assert.deepEqual(h.audits[0], { + event: 'calendar_subscription.created', + subscription_id: created.subscriptionId, + subject_id: 'user-1', + project_id: 'project-1', + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + expires_at_ms: 1_800_086_400_000, + }); + + const listed = await h.service.list({ subjectId: 'user-1', projectId: 'project-1' }); + assert.equal(listed.length, 1); + assert.deepEqual(listed[0], { + subscriptionId: created.subscriptionId, + subjectId: 'user-1', + projectId: 'project-1', + name: 'Executive calendar', + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + createdAtMs: 1_800_000_000_000, + expiresAtMs: 1_800_086_400_000, + lastUsedAtMs: null, + rotatedAtMs: null, + revokedAtMs: null, + status: 'active', + }); + assert.equal('secret' in listed[0], false); + assert.equal('secretHash' in listed[0], false); + assert.equal('secret_hash' in listed[0], false); + assert.equal('membership_version' in listed[0], false); + + h.setNow(1_800_000_005_000); + const principal = await h.service.authorize({ secret: created.secret, projectId: 'project-1' }); + assert.deepEqual(principal, { + subscriptionId: created.subscriptionId, + subjectId: 'user-1', + projectId: 'project-1', + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + }); + assert.deepEqual(h.usages, [{ subscription_id: created.subscriptionId, used_at_ms: 1_800_000_005_000 }]); + + h.setNow(1_800_000_010_000); + const rotated = await h.service.rotate({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + expiresAtMs: 1_800_172_800_000, + }); + assert.notEqual(rotated.secret, created.secret); + assert.equal(rotated.subscriptionId, created.subscriptionId); + assert.equal(rotated.rotatedAtMs, 1_800_000_010_000); + assert.equal(rotated.expiresAtMs, 1_800_172_800_000); + assert.equal(h.rotations.length, 1); + await expectDomainError( + h.service.authorize({ secret: created.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); + const rotatedPrincipal = await h.service.authorize({ secret: rotated.secret, projectId: 'project-1' }); + assert.equal(rotatedPrincipal.subscriptionId, created.subscriptionId); + + h.setNow(1_800_000_020_000); + const revoked = await h.service.revoke({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + }); + assert.equal(revoked.status, 'revoked'); + assert.equal(revoked.revokedAtMs, 1_800_000_020_000); + const revokedAgain = await h.service.revoke({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + }); + assert.equal(revokedAgain.revokedAtMs, revoked.revokedAtMs); + await expectDomainError( + h.service.authorize({ secret: rotated.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); +} + +{ + const h = makeHarness({ auditThrows: true }); + const created = await h.service.create({ + subjectId: 'user-1', projectId: 'project-1', name: 'Ops', expiresAtMs: 1_800_000_100_000, + }); + assert.ok(h.rows.has(created.subscriptionId)); + const principal = await h.service.authorize({ secret: created.secret, projectId: 'project-1' }); + assert.equal(principal.subjectId, 'user-1'); + const rotated = await h.service.rotate({ + subjectId: 'user-1', projectId: 'project-1', subscriptionId: created.subscriptionId, expiresAtMs: 1_800_000_200_000, + }); + assert.ok(rotated.secret); + const revoked = await h.service.revoke({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: created.subscriptionId }); + assert.equal(revoked.status, 'revoked'); +} + +console.log('calendar subscription domain behavior tests passed'); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index d4fe580b..9ebfdb83 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -39,6 +39,11 @@ assert.match( /--include=server\/access_grant_domain\.mjs/, 'the short-lived access-grant domain is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/calendar_subscription_domain\.mjs/, + 'the durable calendar-subscription domain is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/access-grant-domain\.test\.mjs/, @@ -49,6 +54,16 @@ assert.match( /tests\/unit\/access-grant-domain-edge\.test\.mjs/, 'the access-grant edge cases execute under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/calendar-subscription-domain\.test\.mjs/, + 'the calendar-subscription lifecycle contract executes under c8', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/calendar-subscription-domain-edge\.test\.mjs/, + 'the calendar-subscription failure boundaries execute under c8', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/,