From e4adf1085612f1b77ccbecdede5e45c16cd0b7c0 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 21 Jul 2026 13:38:22 -0400 Subject: [PATCH 01/14] docs: add storage provider migration design doc Captures the OSF dependency audit, provider evaluation (Box/S3/Dropbox/ OneDrive, GitHub, Dataverse ecosystem/ICPSR/Dryad/Databrary), and the pluggable-provider architecture targeting Google Drive, Figshare, and Dataverse, with OSF retained as a legacy adapter. Co-Authored-By: Claude Sonnet 5 --- docs/provider-migration-design.md | 250 ++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/provider-migration-design.md diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md new file mode 100644 index 0000000..c50d6f9 --- /dev/null +++ b/docs/provider-migration-design.md @@ -0,0 +1,250 @@ +# Storage Provider Migration — Design Doc + +## Background + +DataPipe currently depends on the Open Science Framework (OSF) as its sole storage +backend: researchers OAuth2-link (or paste a PAT for) their own OSF account, and +DataPipe writes experiment session data and a Psych-DS metadata file directly into +an OSF project/component on their behalf. DataPipe never retains a copy of +submitted data itself. + +This migration is driven by **deprecation/shutdown risk at OSF**, not cost or a +policy dispute. The goal is to decouple DataPipe from any single storage vendor by +introducing a pluggable storage-provider abstraction, and to ship initial support +for three concrete providers: **Google Drive, Figshare, and Dataverse**, alongside +OSF kept in place as a legacy adapter for existing connected users. + +## Current OSF dependency (summary) + +A full code-level audit found DataPipe's OSF dependency concentrated in a +identifiable set of modules, with a large amount of surrounding logic +(condition assignment, Psych-DS metadata generation/merge, token encryption, +CSRF/OAuth-state handling, and the GCS/Firestore retry-queue durability system) +already provider-agnostic and requiring no change. The OSF-specific surface: + +- **OAuth2 flow**: `oauth2-callback.ts`, `oauth2-regenerate.ts`, `refresh-token.ts`, + `resolve-token.ts`, `generate-oauth-state.ts`, plus a parallel static + Personal-Access-Token path (`save-osf-token.ts`, `get-osf-token.ts`). +- **File writes**: `put-file-osf.ts`, `update-file-osf.ts`, `subfolder.ts` — built + on OSF's Waterbutler API, and explicitly relying on OSF's `409 Conflict` response + as the sole collision-detection mechanism for per-session data files. +- **Metadata handling**: `metadata-block.ts`, `metadata-process.ts` — reconciles a + mutable `dataset_description.json` file against a live provider-side folder + listing on every update. +- **OSF concepts baked into the data model**: project + child "component", + `osfstorage` as an implicit provider, 4 hardcoded OSF storage regions, OSF file + IDs with an `osfstorage/` prefix. +- **Frontend**: `pages/admin/new.js` (OSF-shaped experiment creation form), + `ExperimentInfo.js` (hardcoded `osf.io` links), `QueuePanel.js` (OSF status-code + copy), all of `components/account/*`. +- **Config/rules**: OSF client ID/secret/redirect env vars, and `firestore.rules` + hardcoding OSF-shaped field whitelists. + +## Requirements for any replacement + +1. OAuth2 delegated authorization with refresh-token rotation — researcher owns + the account, DataPipe never holds a password, access must survive unattended + for the life of a study (months). +2. A file-write API (atomic create-if-absent is a nice-to-have, not a hard + requirement — see "Collision detection" below for why). +3. Support for updating one mutable file per project (the metadata file). +4. Folder/path support, or a documented fallback when absent. +5. A "project + child container" shape, or a flat namespace DataPipe's model can + be mapped onto. +6. Burst tolerance (a class of 30–100 students submitting within a minute). +7. Free-tier economics realistic for typical academic use, and — where possible — + DOI/citation support, since that's part of DataPipe's pitch to researchers, + not just plumbing. + +## Providers evaluated and ruled out + +- **GitHub** — technically the strongest fit (sha-gated Contents API naturally + solves collision detection and mutable updates), but using a source-control + platform as a silent, continuous data-dump backend runs against the spirit of + the service even though no explicit ToS clause forbids it, and there's no + precedent either way for this exact usage pattern. Ruled out on those grounds + rather than a technical one. +- **Zenodo, Figshare (as archival), Harvard Dataverse, ICPSR, Dryad, Databrary, + DANS** — the entire "research-data-specific repository" category is built + around a curate-once-publish-once-mint-a-DOI workflow, structurally mismatched + to DataPipe's hundreds-of-small-incremental-writes-over-months pattern. This + turned out to be a category-wide limitation, not specific to any one vendor — + confirmed across every Dataverse-software installation (Harvard, Borealis, + DataverseNL, DataverseNO, DANS all share the same open-source codebase, same + static-token-only auth, same silent-rename-on-duplicate-filename behavior). + ICPSR has no automated API at all. Dryad supports incremental draft writes but + only via a shared service-account grant (no per-researcher OAuth consent) and + charges a $150/dataset publishing fee. Databrary is architecturally a gated, + human-reviewed video library, not a general write target. +- **Box, Amazon S3, Dropbox, Microsoft OneDrive/Graph** — all technically solid, + generic-storage options with no platform-fit ambiguity. Box has the strongest + native atomic-write guarantee of anything evaluated and is already common at + universities; S3 has the best region control and longest clean API history but + breaks OAuth-onboarding simplicity (self-provisioned IAM). These remain + reasonable fallback options but were not selected as the initial three. + +**Selected for initial implementation: Google Drive, Figshare, Dataverse.** +OSF is retained as a legacy adapter — existing connected users and in-flight +studies keep working unmodified; new experiments default to one of the three +new providers. + +## Architecture + +### Provider interface + +``` +StorageProvider { + id: 'osf' | 'gdrive' | 'figshare' | 'dataverse' + authMethod: 'oauth2' | 'static-token' + + // auth + getAuthUrl(state) / exchangeCode(code) / refreshToken(rt) // oauth2 + validateStaticToken(token) // static-token + + // one-time setup at experiment creation + createDataContainer(auth, researcherInput) -> containerRef // opaque, provider-shaped + + // ongoing writes + writeSessionFile(auth, containerRef, filename, data) -> WriteResult + updateFile(auth, containerRef, existingFileRef, data) -> WriteResult + + // needed for collision-cache rehydration (see below) and dashboard file counts + listFiles(auth, containerRef) -> FileRef[] + + capabilities: { nativeSubfolders: bool, supportsRegion: bool } +} +``` + +`capabilities` is descriptive (UI hints, subfolder fallback behavior), not a +correctness gate — provider-side atomicity is no longer load-bearing (see below). + +### Collision detection: a cache, not a new system of record + +None of the three selected providers offer OSF's atomic `409`-on-duplicate +behavior (Drive allows same-name files silently, Dataverse silently renames, +Figshare's behavior is unconfirmed). Rather than build three different +reliability models behind one interface, collision detection moves entirely into +Firestore, decoupled from the provider: + +- Before any provider write, atomically claim `(experimentId, filenameHash)` in a + Firestore transaction. A failed claim means "duplicate filename" — no + provider round-trip needed to know that. +- **Retention-safe by design**: the claim stores a *salted hash* of the filename + (salt is per-experiment, generated once, kept indefinitely — a nonce, not + "file information"), never the raw filename. This keeps the "we don't retain + your file information" promise intact. +- **Cost-bounded by design**: claim records carry a TTL and expire after an + experiment goes inactive (proposed: ~90 days with no new submissions), so + storage stays bounded to currently-active studies rather than growing forever + across DataPipe's entire history. +- **Nothing is actually lost on expiry.** The claim-set is a cache over the + provider's own file listing, which remains the durable source of truth. If a + researcher resumes data collection on an experiment whose claim-set has + expired, DataPipe detects the cold cache, calls the adapter's `listFiles` + against the live container, hashes each returned filename with the + experiment's (permanently retained) salt, bulk-writes fresh claims with a new + TTL, then proceeds with the normal claim-and-write for the incoming + submission. This only costs anything for experiments that actually get + reactivated — the common case (experiment finishes, goes cold, stays cold) + never pays the rehydration cost. +- Edge cases to handle explicitly: rehydration should fail loudly (prompt the + researcher to reconnect) if the provider container is missing or access was + revoked, rather than silently accepting duplicates; large containers need + paginated listing. + +This also incidentally resolves a latent bug in the current OSF code, where +`metadata-block.ts` checks for a success status code (`210`) that +`putFileOSF` never actually returns — that check disappears along with the +code path it lives in. + +### Metadata-file tracking + +Store the provider-returned file ref (id/path/rev) on the experiment's Firestore +`metadata/{experimentID}` doc after first creation. Every later update reads +that ref back directly — no more per-adapter "list the folder and look for a +matching name" logic, which today only exists because OSF is queried as the +live source of truth for this check. + +### Data model + +``` +experiments/{id}: { + storageProvider: 'gdrive' | 'figshare' | 'dataverse' | 'osf', // 'osf' = legacy + providerContainer: { ...shape varies by storageProvider... }, + metadataFileRef: {...} | null, + collisionCache: { salt, warmUntil: Timestamp }, +} + +users/{uid}: { + connectedAccounts: { + gdrive?: { authMethod: 'oauth2', encryptedToken, encryptedRefreshToken, tokenExpiresAt, providerAccountId }, + figshare?: { authMethod: 'oauth2', ...same shape... }, + dataverse?: { authMethod: 'static-token', encryptedToken, serverUrl }, + } +} +``` + +`crypto-utils.ts` (AES-256-GCM) and `generate-oauth-state.ts` (CSRF state +handling) carry over unchanged — already provider-agnostic. + +### Per-provider adapter notes + +| | Google Drive | Figshare | Dataverse | +|---|---|---|---| +| Auth | OAuth2, `drive.file` scope | OAuth2, `authorization_code` + `refresh_token` | Static API token — same shape as today's OSF PAT fallback (`usingPersonalToken`) | +| Container | Subfolder under a researcher-picked parent folder | Article inside a Project (two levels only) | Dataset inside a Collection | +| Subfolders | Native | **None** — filename-prefix fallback, surfaced in UI as a known limitation | Native via `directoryLabel` | +| Federation | Single global service | Single global service | **Federated** — Harvard, Borealis, DataverseNL, etc. are different servers; `serverUrl` must be stored per researcher | +| DOI/publish | N/A | Publishing an Article snapshots it | Dataset publish bumps a major version — dataset should stay in **draft indefinitely**; publish (and DOI mint) becomes a manual researcher action at study completion, not something DataPipe triggers | +| Needs a pre-build spike | No | Confirm actual upload conflict behavior empirically (docs are silent/unconfirmed) | Confirm and handle the silent-rename response explicitly, even though Firestore is the real collision gate | + +### OAuth generalization + +Replace today's 4–5 near-duplicated OSF-auth-URL-building blocks and single +OSF-hardcoded `oauth2-callback` function with a small provider registry +(`{authorizeUrl, tokenUrl, clientId, clientSecret, scope}` per provider) and one +generic callback function parameterized by a `provider` field carried in the +existing CSRF state payload. + +### Frontend changes + +- `admin/new.js`: provider selector + provider-specific sub-form. +- `account/*`: collapse per-provider components (`SignUpWithOSF`, `OSFToken`, + `OAuthTokenStatus`, etc.) into one generic connect-button + token-status + component, parameterized by a small per-provider config (name, icon, docs + link). +- `ExperimentInfo.js` / `QueuePanel.js`: replace hardcoded OSF links and OSF + status-code copy with a generic error taxonomy (`RATE_LIMITED`, + `AUTH_EXPIRED`, `NAME_CONFLICT`, `UNAVAILABLE`) that each adapter maps its own + provider's errors into. +- `firestore.rules`: generalize the field whitelist to the `connectedAccounts.*` + shape; the experiment `hasAll` check becomes conditional on `storageProvider`. + +## Build sequence + +1. Define the provider interface + registry + additive Firestore schema (no + behavior change yet). +2. Refactor existing OSF code into an OSF adapter implementing the new + interface — pure refactor, proves the abstraction before adding anything new. +3. Land the Firestore collision-cache (salted hash + TTL + lazy rehydration) and + metadata-ref tracking for the OSF adapter first, while there's still only one + provider to reason about. +4. Google Drive adapter (simplest OAuth2, most rate-limit headroom, proves the + multi-provider auth flow end-to-end). +5. Figshare adapter (after the upload-conflict-behavior spike). +6. Dataverse adapter (static-token path, "stays in draft" publish workflow). +7. Frontend: provider selection UI, generalized connect/status components, + generalized dashboard links and error copy. +8. FAQ/docs, `firestore.rules`, env config for new provider client + IDs/secrets. + +## Open questions / spikes before implementation + +- Empirically verify Figshare's upload behavior on a duplicate filename. +- Confirm Dataverse's exact response shape on a silent rename, so DataPipe can + detect and surface it rather than trust the returned filename blindly. +- Decide the exact collision-cache TTL window (90 days proposed, not yet + validated against real usage patterns). +- Decide the UX for Dataverse's federated `serverUrl` requirement (does + DataPipe maintain a picker of known installations, or require researchers to + paste their institution's Dataverse URL?). From ab334cda4450f29dc47e5afe4f1fbbc9367f2de8 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 21 Jul 2026 14:27:44 -0400 Subject: [PATCH 02/14] docs: harden provider-migration design after review - Make Figshare/Dataverse selection conditional on gating spikes, with Box as the pre-approved substitute - Scope base64/media support to all providers at launch; define WriteResult and size/quota capability hints - Add claim lifecycle (pending/confirmed) and rehydration locking to the collision cache; dual-run against OSF 409 for validation - Document auth-longevity constraints (Google verification lead time, Dataverse token expiry) and add QUOTA_EXCEEDED to the error taxonomy - Add testing strategy, build step 0 (OAuth app registration), and named disqualification criteria per spike Co-Authored-By: Claude Fable 5 --- docs/provider-migration-design.md | 173 +++++++++++++++++++++++++----- 1 file changed, 145 insertions(+), 28 deletions(-) diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index c50d6f9..0987307 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -14,6 +14,11 @@ introducing a pluggable storage-provider abstraction, and to ship initial suppor for three concrete providers: **Google Drive, Figshare, and Dataverse**, alongside OSF kept in place as a legacy adapter for existing connected users. +Migration tooling for moving existing OSF experiments or historical data to a +new provider is **deliberately out of scope**. If OSF shuts down, researchers +start new experiments on a new provider; the legacy adapter exists so nothing +breaks before that day, not as the first step of a rescue plan. + ## Current OSF dependency (summary) A full code-level audit found DataPipe's OSF dependency concentrated in a @@ -44,9 +49,16 @@ already provider-agnostic and requiring no change. The OSF-specific surface: 1. OAuth2 delegated authorization with refresh-token rotation — researcher owns the account, DataPipe never holds a password, access must survive unattended - for the life of a study (months). -2. A file-write API (atomic create-if-absent is a nice-to-have, not a hard - requirement — see "Collision detection" below for why). + for the life of a study (months). **Deliberately relaxed for Dataverse**, + which only offers static API tokens: accepted because DataPipe already + maintains an equivalent PAT path for OSF, but Dataverse tokens expire + (commonly yearly, installation-configurable), so the unattended-for-months + constraint requires expiry-warning UX, not just token storage. +2. A file-write API that handles **binary media as well as text** — the + `/api/base64` path (audio/video recordings) is in scope for all providers + from day one, so per-provider file-size caps and quota behavior are launch + constraints, not later work. (Atomic create-if-absent is a nice-to-have, not + a hard requirement — see "Collision detection" below for why.) 3. Support for updating one mutable file per project (the metadata file). 4. Folder/path support, or a documented fallback when absent. 5. A "project + child container" shape, or a flat namespace DataPipe's model can @@ -82,11 +94,23 @@ already provider-agnostic and requiring no change. The OSF-specific surface: universities; S3 has the best region control and longest clean API history but breaks OAuth-onboarding simplicity (self-provisioned IAM). These remain reasonable fallback options but were not selected as the initial three. + **Box is the pre-approved substitute**: if a conditional provider fails its + gating spike (below), Box takes its slot without re-opening the full + evaluation. + +**Selected for initial implementation: Google Drive, Figshare, Dataverse** — +but the three are not equally confident picks. Google Drive is a comfortable +technical fit. Figshare and Dataverse come from the repository category ruled +out above; they are selected *despite* that structural mismatch because +DOI/citation support and research-repository identity are part of DataPipe's +pitch to researchers (requirement 7), and their selection is **conditional**: +each must pass its gating spike (see "Gating spikes" below) before its adapter +is built, with Box as the named substitute if it fails. If both fail, the +initial lineup is Drive + Box. -**Selected for initial implementation: Google Drive, Figshare, Dataverse.** OSF is retained as a legacy adapter — existing connected users and in-flight -studies keep working unmodified; new experiments default to one of the three -new providers. +studies keep working unmodified; new experiments default to one of the new +providers. ## Architecture @@ -104,19 +128,33 @@ StorageProvider { // one-time setup at experiment creation createDataContainer(auth, researcherInput) -> containerRef // opaque, provider-shaped - // ongoing writes - writeSessionFile(auth, containerRef, filename, data) -> WriteResult - updateFile(auth, containerRef, existingFileRef, data) -> WriteResult + // ongoing writes — `data` may be binary (base64/media path), with declared + // size and content type so adapters can enforce provider size caps up front + writeSessionFile(auth, containerRef, filename, data, {size, contentType}) -> WriteResult + updateFile(auth, containerRef, existingFileRef, data, {size, contentType}) -> WriteResult // needed for collision-cache rehydration (see below) and dashboard file counts listFiles(auth, containerRef) -> FileRef[] - capabilities: { nativeSubfolders: bool, supportsRegion: bool } + capabilities: { nativeSubfolders: bool, supportsRegion: bool, + maxFileSizeBytes: number | null, quotaNote: string | null } +} + +WriteResult { + fileRef, // provider-shaped id/path/rev + storedFilename, // the filename the provider REPORTS having stored — not + // the one requested; detecting Dataverse's silent rename + // depends on comparing the two + bytesWritten, } ``` -`capabilities` is descriptive (UI hints, subfolder fallback behavior), not a -correctness gate — provider-side atomicity is no longer load-bearing (see below). +`capabilities` is descriptive (UI hints, subfolder fallback behavior, size-cap +warnings), not a correctness gate — provider-side atomicity is no longer +load-bearing (see below). Note one contract caveat: Figshare has no in-place +file update, so its `updateFile` is implemented as delete + re-upload and is +**non-atomic** — there is a brief window where the metadata file does not exist +in the article. Callers of `updateFile` must tolerate that. ### Collision detection: a cache, not a new system of record @@ -129,6 +167,15 @@ Firestore, decoupled from the provider: - Before any provider write, atomically claim `(experimentId, filenameHash)` in a Firestore transaction. A failed claim means "duplicate filename" — no provider round-trip needed to know that. +- **Claims have a lifecycle, not just existence.** A claim is written as + `pending` with an idempotency token owned by the submitting request, and + flipped to `confirmed` only after the provider write succeeds. The upload + retry queue (`queue-upload.ts` / `scheduled-upload-retry.ts`) re-enters its + own `pending` claim by token rather than being rejected as a duplicate of + itself; a terminally failed write releases its claim (or a new request + bearing no token may overwrite a stale `pending` claim past a timeout). + Without this, a failed provider write orphans the claim and a legitimate + resubmission of that filename is blocked until TTL expiry (~90 days). - **Retention-safe by design**: the claim stores a *salted hash* of the filename (salt is per-experiment, generated once, kept indefinitely — a nonce, not "file information"), never the raw filename. This keeps the "we don't retain @@ -147,10 +194,18 @@ Firestore, decoupled from the provider: submission. This only costs anything for experiments that actually get reactivated — the common case (experiment finishes, goes cold, stays cold) never pays the rehydration cost. -- Edge cases to handle explicitly: rehydration should fail loudly (prompt the +- Edge cases to handle explicitly: rehydration needs a **per-experiment lock** + — concurrent submissions arriving at a cold cache must wait/retry against a + single in-flight rehydration rather than each triggering their own + `listFiles` + bulk claim-write; rehydration should fail loudly (prompt the researcher to reconnect) if the provider container is missing or access was revoked, rather than silently accepting duplicates; large containers need paginated listing. +- **Validation before it matters**: while OSF is still the only provider + (build step 3), dual-run — keep OSF's `409` response as a backstop and log + any disagreement between it and the Firestore cache. That checks the cache + against production ground truth for free, before any provider that *has* no + backstop ships. This also incidentally resolves a latent bug in the current OSF code, where `metadata-block.ts` checks for a success status code (`210`) that @@ -179,7 +234,8 @@ users/{uid}: { connectedAccounts: { gdrive?: { authMethod: 'oauth2', encryptedToken, encryptedRefreshToken, tokenExpiresAt, providerAccountId }, figshare?: { authMethod: 'oauth2', ...same shape... }, - dataverse?: { authMethod: 'static-token', encryptedToken, serverUrl }, + dataverse?: { authMethod: 'static-token', encryptedToken, serverUrl, + tokenExpiresAt }, // Dataverse tokens expire (~yearly) — needed for expiry-warning UX } } ``` @@ -192,11 +248,13 @@ handling) carry over unchanged — already provider-agnostic. | | Google Drive | Figshare | Dataverse | |---|---|---|---| | Auth | OAuth2, `drive.file` scope | OAuth2, `authorization_code` + `refresh_token` | Static API token — same shape as today's OSF PAT fallback (`usingPersonalToken`) | -| Container | Subfolder under a researcher-picked parent folder | Article inside a Project (two levels only) | Dataset inside a Collection | +| Auth longevity | Refresh tokens are revoked after ~6 months of disuse — paused studies need reconnect UX. App must reach **published** OAuth verification status: testing mode means 7-day refresh tokens and a 100-user cap | Long-lived; confirm rotation/expiry behavior in the spike | Tokens **expire** (commonly yearly, installation-configurable) — needs expiry-warning UX, not just storage | +| Container | **App-created "DataPipe" folder at Drive root.** Under `drive.file` the app can only touch files it created or the user explicitly picked — a researcher-picked parent would force a Google Picker frontend integration for little gain. Revisit only if researchers demand placement control | Article inside a Project (two levels only) | Dataset inside a Collection | | Subfolders | Native | **None** — filename-prefix fallback, surfaced in UI as a known limitation | Native via `directoryLabel` | -| Federation | Single global service | Single global service | **Federated** — Harvard, Borealis, DataverseNL, etc. are different servers; `serverUrl` must be stored per researcher | +| Media / size limits | Free quota is 15 GB **shared with Gmail/Photos** — quota exhaustion is an expected support scenario for audio/video studies, not an edge case | Per-file and total-quota caps on the free tier; upload is a multi-step multipart flow (initiate → parts → complete) with correspondingly more failure modes; no in-place update (delete + re-upload) | Per-installation size caps (federation → varies); CSV uploads are **"ingested"** into archival `.tab` format unless suppressed, which transforms presentation and extends dataset locking — suppression support is version-dependent | +| Federation | Single global service | Single global service | **Federated** — Harvard, Borealis, DataverseNL, etc. are different servers; `serverUrl` must be stored per researcher, and DataPipe integrates whatever software version each installation runs (version drift is a permanent fact of this adapter) | | DOI/publish | N/A | Publishing an Article snapshots it | Dataset publish bumps a major version — dataset should stay in **draft indefinitely**; publish (and DOI mint) becomes a manual researcher action at study completion, not something DataPipe triggers | -| Needs a pre-build spike | No | Confirm actual upload conflict behavior empirically (docs are silent/unconfirmed) | Confirm and handle the silent-rename response explicitly, even though Firestore is the real collision gate | +| Gating spike | None (comfortable fit) — but OAuth app verification has **weeks of lead time**; start it at build step 0 | See "Gating spikes" below — duplicate-filename behavior, per-item **file-count cap** (historically ~500 files/item; a semester-long study can exceed it), multipart burst behavior | See "Gating spikes" below — **dataset locking under concurrent adds** (most likely disqualifier in the plan), tabular-ingest suppression, silent-rename response shape | ### OAuth generalization @@ -206,6 +264,15 @@ OSF-hardcoded `oauth2-callback` function with a small provider registry generic callback function parameterized by a `provider` field carried in the existing CSRF state payload. +The generalization also covers the OSF-shaped background jobs: + +- `scheduled-token-refresh.ts` becomes per-provider — each OAuth2 provider gets + its own refresh cadence, and Dataverse (no refresh token to rotate) gets an + **expiry-warning** job instead, emailing/flagging the researcher before the + static token lapses mid-study. +- `on-user-deleted.ts` cleanup iterates the `connectedAccounts.*` map rather + than assuming a single OSF token shape. + ### Frontend changes - `admin/new.js`: provider selector + provider-specific sub-form. @@ -215,36 +282,86 @@ existing CSRF state payload. link). - `ExperimentInfo.js` / `QueuePanel.js`: replace hardcoded OSF links and OSF status-code copy with a generic error taxonomy (`RATE_LIMITED`, - `AUTH_EXPIRED`, `NAME_CONFLICT`, `UNAVAILABLE`) that each adapter maps its own - provider's errors into. + `AUTH_EXPIRED`, `NAME_CONFLICT`, `QUOTA_EXCEEDED`, `UNAVAILABLE`) that each + adapter maps its own provider's errors into. With media in scope from day + one, `QUOTA_EXCEEDED` (storage full / file too large) is the most likely + researcher-visible failure and needs first-class copy, not a generic error. - `firestore.rules`: generalize the field whitelist to the `connectedAccounts.*` shape; the experiment `hasAll` check becomes conditional on `storageProvider`. ## Build sequence +0. **Register provider OAuth apps and start Google's verification process + immediately** — publication/brand verification has weeks of lead time, and + until it completes, Drive refresh tokens last 7 days and the app is capped + at 100 users. This runs in parallel with everything below. 1. Define the provider interface + registry + additive Firestore schema (no behavior change yet). 2. Refactor existing OSF code into an OSF adapter implementing the new interface — pure refactor, proves the abstraction before adding anything new. -3. Land the Firestore collision-cache (salted hash + TTL + lazy rehydration) and - metadata-ref tracking for the OSF adapter first, while there's still only one - provider to reason about. +3. Land the Firestore collision-cache (salted hash + claim lifecycle + TTL + + lazy rehydration) and metadata-ref tracking for the OSF adapter first, while + there's still only one provider to reason about. **Dual-run**: keep OSF's + `409` as a backstop and log cache/backstop disagreements as free production + validation. 4. Google Drive adapter (simplest OAuth2, most rate-limit headroom, proves the multi-provider auth flow end-to-end). -5. Figshare adapter (after the upload-conflict-behavior spike). -6. Dataverse adapter (static-token path, "stays in draft" publish workflow). +5. **Gate: Figshare spike** (see below). Pass → Figshare adapter. Fail → Box + adapter takes the slot. +6. **Gate: Dataverse spike** (see below). Pass → Dataverse adapter + (static-token path, "stays in draft" publish workflow). Fail → Box (or, if + Box already replaced Figshare, ship two providers and revisit). 7. Frontend: provider selection UI, generalized connect/status components, generalized dashboard links and error copy. 8. FAQ/docs, `firestore.rules`, env config for new provider client IDs/secrets. -## Open questions / spikes before implementation +## Testing strategy + +- Extend `mock-server.ts` per provider and mirror the existing emulator-based + test suite (`__tests__/*-emulator.test.js`) for each adapter — same coverage + bar as the OSF path has today, including the base64/media path. +- Live smoke checks: `demo.dataverse.org` for Dataverse; Figshare has no real + sandbox, so a dedicated throwaway account; a dedicated test Google account + for Drive. +- Spikes run against real services with throwaway accounts; their findings get + recorded back into this doc (adapter-notes table) when complete. + +## Gating spikes (go/no-go before the adapter is built) + +These are decision gates, not confirmations. Each has a named disqualification +criterion and Box is the pre-approved substitute — a failed spike swaps the +provider, it does not trigger a redesign. + +- **Dataverse — concurrent-write locking.** Dataverse locks a dataset during + file add/ingest, and concurrent adds to a locked dataset fail. The burst + requirement writes 30–100 files to *one* dataset within a minute; the retry + queue softens this, but if the spike shows writes serialize through a lock at + a rate that can't absorb a class section, that is disqualifying. This is the + single most likely spike to fail in the plan. +- **Dataverse — tabular ingest.** Confirm CSV ingest-into-`.tab` can be + suppressed on the installations researchers actually use (suppression is + version-dependent, and federation means DataPipe doesn't choose the version). +- **Dataverse — silent rename.** Confirm the exact response shape on a + duplicate filename so DataPipe compares `storedFilename` against the request + rather than trusting it blindly. +- **Figshare — duplicate filename behavior.** Docs are silent; verify + empirically. +- **Figshare — per-item file-count cap.** Historically ~500 files/item; a + semester of sessions can exceed it. Determine the real limit and whether + article-rollover (a new article per N files) is acceptable; if the cap is low + and rollover unacceptable, that is disqualifying. +- **Figshare — multipart upload under burst.** The initiate → parts → complete + flow has more failure modes than a single PUT; verify behavior under + concurrent submissions, including media-sized files. + +## Open questions -- Empirically verify Figshare's upload behavior on a duplicate filename. -- Confirm Dataverse's exact response shape on a silent rename, so DataPipe can - detect and surface it rather than trust the returned filename blindly. - Decide the exact collision-cache TTL window (90 days proposed, not yet validated against real usage patterns). - Decide the UX for Dataverse's federated `serverUrl` requirement (does DataPipe maintain a picker of known installations, or require researchers to paste their institution's Dataverse URL?). +- Do researchers need placement control for the Drive folder strongly enough + to justify a Google Picker integration, or is the app-created root folder + acceptable? (Default answer: root folder; revisit on demand.) From e7fc79292640b3085711c8dd274cc01e11252647 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 22 Jul 2026 17:17:31 -0400 Subject: [PATCH 03/14] feat: add storage-provider interface, registry, and additive schema types Build step 1 of the provider migration (docs/provider-migration-design.md): - StorageProvider interface with WriteResult union, generic error taxonomy, and capability hints - provider registry (register/get/list, test-only reset) - additive optional fields on ExperimentData and UserData; legacy OSF fields unchanged - promote typescript to a direct devDependency of functions/ (was only available transitively via @jspsych/metadata) No behavior change; nothing consumes the interface yet. Co-Authored-By: Claude Fable 5 --- functions/package-lock.json | 3 +- functions/package.json | 3 +- .../src/__tests__/providers-registry.test.js | 66 ++++++++ functions/src/interfaces.ts | 21 ++- functions/src/providers/registry.ts | 27 ++++ functions/src/providers/types.ts | 149 ++++++++++++++++++ package-lock.json | 20 +-- 7 files changed, 266 insertions(+), 23 deletions(-) create mode 100644 functions/src/__tests__/providers-registry.test.js create mode 100644 functions/src/providers/registry.ts create mode 100644 functions/src/providers/types.ts diff --git a/functions/package-lock.json b/functions/package-lock.json index 730d804..a89b634 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -20,7 +20,8 @@ "devDependencies": { "@types/archiver": "^7.0.0", "@types/is-base64": "^1.1.3", - "firebase-functions-test": "^3.4.1" + "firebase-functions-test": "^3.4.1", + "typescript": "^5.5.2" }, "engines": { "node": "22" diff --git a/functions/package.json b/functions/package.json index 812883a..55da299 100644 --- a/functions/package.json +++ b/functions/package.json @@ -31,7 +31,8 @@ "devDependencies": { "@types/archiver": "^7.0.0", "@types/is-base64": "^1.1.3", - "firebase-functions-test": "^3.4.1" + "firebase-functions-test": "^3.4.1", + "typescript": "^5.5.2" }, "private": true } diff --git a/functions/src/__tests__/providers-registry.test.js b/functions/src/__tests__/providers-registry.test.js new file mode 100644 index 0000000..a83c874 --- /dev/null +++ b/functions/src/__tests__/providers-registry.test.js @@ -0,0 +1,66 @@ +import { + registerProvider, + getProvider, + listProviders, + clearProvidersForTesting, +} from "../../lib/providers/registry.js"; + +function makeFakeProvider(id) { + return { + id, + authMethod: "static-token", + capabilities: { + nativeSubfolders: false, + supportsRegion: false, + maxFileSizeBytes: null, + quotaNote: null, + }, + createDataContainer: async () => ({ provider: id }), + writeSessionFile: async () => ({ + success: true, + fileRef: { name: "test" }, + storedFilename: "test", + }), + updateFile: async () => ({ + success: true, + fileRef: { name: "test" }, + storedFilename: "test", + }), + listFiles: async () => [], + }; +} + +describe("providers registry", () => { + beforeEach(() => { + clearProvidersForTesting(); + }); + + it("returns a registered provider from getProvider", () => { + const provider = makeFakeProvider("osf"); + + registerProvider(provider); + + expect(getProvider("osf")).toBe(provider); + }); + + it("throws when getProvider is called with an unknown id", () => { + expect(() => getProvider("gdrive")).toThrow("Unknown storage provider: gdrive"); + }); + + it("throws when registering a duplicate id", () => { + registerProvider(makeFakeProvider("figshare")); + + expect(() => registerProvider(makeFakeProvider("figshare"))).toThrow( + "Storage provider already registered: figshare" + ); + }); + + it("reflects registrations in listProviders", () => { + expect(listProviders()).toEqual([]); + + registerProvider(makeFakeProvider("osf")); + registerProvider(makeFakeProvider("dataverse")); + + expect(listProviders()).toEqual(["osf", "dataverse"]); + }); +}); diff --git a/functions/src/interfaces.ts b/functions/src/interfaces.ts index 5dfd2d5..0a85d7c 100644 --- a/functions/src/interfaces.ts +++ b/functions/src/interfaces.ts @@ -1,3 +1,11 @@ +import { + StorageProviderId, + ContainerRef, + FileRef, + CollisionCacheState, + ConnectedAccounts, + } from './providers/types'; + export interface ExperimentData { active: boolean; activeBase64: boolean; @@ -14,8 +22,13 @@ export interface ExperimentData { requiredFields: string[]; owner: string; osfFilesLink: string; + // Provider-migration fields (additive; absent = legacy OSF experiment). + storageProvider?: StorageProviderId; + providerContainer?: ContainerRef; + metadataFileRef?: FileRef | null; + collisionCache?: CollisionCacheState; } - + export interface UserData { email: string; uid: string; @@ -24,10 +37,12 @@ export interface ExperimentData { experiments: string[]; usingPersonalToken: boolean; refreshToken: string; - refreshTokenExpires: number; + refreshTokenExpires: number; authToken: string; authTokenExpires: number; - } + // Provider-migration field (additive; legacy OSF fields above stay as-is). + connectedAccounts?: ConnectedAccounts; + } export interface RequestBody { experimentID: string; diff --git a/functions/src/providers/registry.ts b/functions/src/providers/registry.ts new file mode 100644 index 0000000..af85e3c --- /dev/null +++ b/functions/src/providers/registry.ts @@ -0,0 +1,27 @@ +import { StorageProvider, StorageProviderId } from "./types.js"; + +const providers = new Map(); + +export function registerProvider(provider: StorageProvider): void { + if (providers.has(provider.id)) { + throw new Error(`Storage provider already registered: ${provider.id}`); + } + providers.set(provider.id, provider); +} + +export function getProvider(id: StorageProviderId): StorageProvider { + const provider = providers.get(id); + if (!provider) { + throw new Error(`Unknown storage provider: ${id}`); + } + return provider; +} + +export function listProviders(): StorageProviderId[] { + return [...providers.keys()]; +} + +// For tests only — production code never unregisters a provider. +export function clearProvidersForTesting(): void { + providers.clear(); +} diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts new file mode 100644 index 0000000..a649ad2 --- /dev/null +++ b/functions/src/providers/types.ts @@ -0,0 +1,149 @@ +// Storage-provider abstraction (docs/provider-migration-design.md). +// Nothing imports these types yet except the registry; adapters arrive in +// later build steps, starting with the OSF refactor. + +export type StorageProviderId = "osf" | "gdrive" | "figshare" | "dataverse"; + +export type AuthMethod = "oauth2" | "static-token"; + +// Generic error taxonomy that every adapter maps its provider's errors into. +// QUOTA_EXCEEDED covers both storage-full and file-too-large. +export type ProviderErrorCode = + | "RATE_LIMITED" + | "AUTH_EXPIRED" + | "NAME_CONFLICT" + | "QUOTA_EXCEEDED" + | "UNAVAILABLE"; + +// A resolved, decrypted credential handed to adapter calls. serverUrl is only +// present for federated providers (Dataverse). +export interface ResolvedAuth { + token: string; + serverUrl?: string; +} + +// Opaque, provider-shaped reference to the container an experiment writes +// into (OSF component, Drive folder, Figshare article, Dataverse dataset). +// Only the owning adapter interprets fields beyond `provider`. +export interface ContainerRef { + provider: StorageProviderId; + [key: string]: unknown; +} + +export interface FileRef { + name: string; + id?: string; + path?: string; + rev?: string; +} + +export interface FileMeta { + size: number; + contentType: string; +} + +export type WriteResult = + | { + success: true; + fileRef: FileRef; + // The filename the provider REPORTS having stored — callers compare it + // against the requested name to detect silent renames (Dataverse). + storedFilename: string; + } + | { + success: false; + error: ProviderErrorCode; + // Raw provider response, preserved for logs and the retry queue. + providerStatus: number | null; + providerMessage: string | null; + retryAfter?: number | null; + }; + +// Descriptive (UI hints, subfolder fallback, size-cap warnings) — never a +// correctness gate. Collision detection lives in Firestore, not here. +export interface ProviderCapabilities { + nativeSubfolders: boolean; + supportsRegion: boolean; + maxFileSizeBytes: number | null; + quotaNote: string | null; +} + +export interface OAuthEndpointConfig { + authorizeUrl: string; + tokenUrl: string; + clientId: string; + clientSecret: string; + scope: string; +} + +export interface StorageProvider { + id: StorageProviderId; + authMethod: AuthMethod; + capabilities: ProviderCapabilities; + + // oauth2 providers only + oauth?: OAuthEndpointConfig; + + // static-token providers only + validateStaticToken?(auth: ResolvedAuth): Promise; + + // One-time setup at experiment creation. researcherInput is provider-shaped + // (e.g. parent project for Figshare, collection + serverUrl for Dataverse). + createDataContainer( + auth: ResolvedAuth, + researcherInput: Record + ): Promise; + + writeSessionFile( + auth: ResolvedAuth, + container: ContainerRef, + filename: string, + data: string | Buffer, + meta: FileMeta + ): Promise; + + // Figshare has no in-place update: its adapter implements this as + // delete + re-upload, so callers must tolerate a non-atomic window. + updateFile( + auth: ResolvedAuth, + container: ContainerRef, + existingFileRef: FileRef, + data: string | Buffer, + meta: FileMeta + ): Promise; + + // Full listing (adapters paginate internally). Used for collision-cache + // rehydration and dashboard file counts. + listFiles(auth: ResolvedAuth, container: ContainerRef): Promise; +} + +// users/{uid}.connectedAccounts.* shapes (additive Firestore schema). +export interface OAuth2AccountConnection { + authMethod: "oauth2"; + encryptedToken: string; + encryptedRefreshToken: string; + tokenExpiresAt: number; + providerAccountId?: string; +} + +export interface StaticTokenAccountConnection { + authMethod: "static-token"; + encryptedToken: string; + serverUrl: string; + // Dataverse tokens expire (~yearly); drives the expiry-warning job. + tokenExpiresAt?: number; +} + +export interface ConnectedAccounts { + gdrive?: OAuth2AccountConnection; + figshare?: OAuth2AccountConnection; + dataverse?: StaticTokenAccountConnection; +} + +// experiments/{id}.collisionCache (additive Firestore schema). The salt is a +// per-experiment nonce retained indefinitely; claims themselves live in a +// subcollection keyed by salted filename hash and expire via TTL. +export interface CollisionCacheState { + salt: string; + warmUntil: FirebaseFirestore.Timestamp; +} diff --git a/package-lock.json b/package-lock.json index c6620b3..d7b7337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5180,7 +5180,6 @@ "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5220,7 +5219,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5241,7 +5239,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5262,7 +5259,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5283,7 +5279,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5304,7 +5299,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5325,7 +5319,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5346,7 +5339,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5367,7 +5359,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5388,7 +5379,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5409,7 +5399,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5430,7 +5419,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5451,7 +5439,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5472,7 +5459,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5490,7 +5476,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -13694,7 +13679,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13759,7 +13744,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -17788,7 +17773,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, "license": "MIT", "optional": true }, From f6304d8df678bea5854afcdfa80085696f7a0997 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 22 Jul 2026 17:33:49 -0400 Subject: [PATCH 04/14] refactor: route all OSF writes through the storage-provider interface Build step 2 of the provider migration: pure refactor, no behavior change. - OSF adapter (providers/osf.ts) implementing StorageProvider by delegating to the existing put-file-osf/update-file-osf modules; maps OSF statuses into the generic error taxonomy - getProviderForExperiment() resolves the adapter + container ref, with legacy experiments (no storageProvider field) defaulting to OSF - api-data, api-base64, metadata-block, scheduled-upload-retry switched to the interface; response codes, queue fields, and log strings unchanged - preserves the latent 210 status check in metadata-block verbatim (removed in build step 3 per the design doc) - 6 new adapter unit tests; full emulator suite green (18 suites, 114 tests) with no existing test modified Co-Authored-By: Claude Fable 5 --- functions/src/__tests__/providers-osf.test.js | 166 ++++++++++++++++++ functions/src/api-base64.ts | 28 +-- functions/src/api-data.ts | 28 +-- functions/src/metadata-block.ts | 47 ++--- functions/src/providers/index.ts | 29 +++ functions/src/providers/osf.ts | 117 ++++++++++++ functions/src/scheduled-upload-retry.ts | 15 +- 7 files changed, 381 insertions(+), 49 deletions(-) create mode 100644 functions/src/__tests__/providers-osf.test.js create mode 100644 functions/src/providers/index.ts create mode 100644 functions/src/providers/osf.ts diff --git a/functions/src/__tests__/providers-osf.test.js b/functions/src/__tests__/providers-osf.test.js new file mode 100644 index 0000000..1dd4476 --- /dev/null +++ b/functions/src/__tests__/providers-osf.test.js @@ -0,0 +1,166 @@ +// osfProvider delegates its writes to put-file-osf.js / update-file-osf.js, +// which both import their own `fetch` from the "node-fetch" package rather +// than using the global fetch. Mocking global.fetch (the pattern used by +// metadata-process.test.js) would have no effect on this code path, since +// node-fetch's fetch is a distinct implementation from globalThis.fetch. +// We mock the "node-fetch" module itself instead. +const mockFetch = jest.fn(); + +jest.mock("node-fetch", () => ({ + __esModule: true, + default: (...args) => mockFetch(...args), +})); + +import { osfProvider } from "../../lib/providers/osf.js"; + +function mockResponse({ status, statusText, retryAfter = null }) { + return { + status, + statusText, + headers: { + get: (header) => (header === "Retry-After" ? retryAfter : null), + }, + }; +} + +const auth = { token: "test-token" }; +const container = { provider: "osf", filesLink: "https://osf.io/abc123/" }; + +describe("osfProvider.writeSessionFile", () => { + beforeEach(() => { + mockFetch.mockClear(); + }); + + it("maps a 201 response to a success WriteResult", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 201, statusText: "Created" })); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: true, + fileRef: { name: "file.json" }, + storedFilename: "file.json", + }); + }); + + it("maps a 409 response to NAME_CONFLICT", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 409, statusText: "Conflict" })); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "NAME_CONFLICT", + providerStatus: 409, + providerMessage: "Conflict", + retryAfter: null, + }); + }); + + it("maps a 429 response to RATE_LIMITED and passes through retryAfter", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 429, statusText: "Too Many Requests", retryAfter: "30" }) + ); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "RATE_LIMITED", + providerStatus: 429, + providerMessage: "Too Many Requests", + retryAfter: 30, + }); + }); + + it("maps a 401 response to AUTH_EXPIRED", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 401, statusText: "Unauthorized" })); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 401, + providerMessage: "Unauthorized", + retryAfter: null, + }); + }); + + it("maps a 500 response to UNAVAILABLE and preserves providerStatus/providerMessage", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 500, statusText: "Internal Server Error" }) + ); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "UNAVAILABLE", + providerStatus: 500, + providerMessage: "Internal Server Error", + retryAfter: null, + }); + }); +}); + +describe("osfProvider.listFiles", () => { + beforeEach(() => { + mockFetch.mockClear(); + }); + + it("returns name/id pairs and filters out folder entries", async () => { + mockFetch.mockResolvedValueOnce({ + json: () => + Promise.resolve({ + data: [ + { attributes: { name: "data.json", kind: "file" }, id: "osfstorage/111" }, + { attributes: { name: "subfolder", kind: "folder" }, id: "osfstorage/222" }, + { attributes: { name: "dataset_description.json", kind: "file" }, id: "osfstorage/333" }, + ], + }), + }); + + const result = await osfProvider.listFiles(auth, container); + + expect(result).toEqual([ + { name: "data.json", id: "osfstorage/111" }, + { name: "dataset_description.json", id: "osfstorage/333" }, + ]); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + "https://osf.io/abc123/?meta=", + expect.objectContaining({ method: "GET" }) + ); + }); +}); diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index 46173d7..901c256 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -1,6 +1,5 @@ import { onRequest } from "firebase-functions/v2/https"; import { DocumentReference, DocumentData, DocumentSnapshot } from "firebase-admin/firestore"; -import putFileOSF from "./put-file-osf.js"; import { db } from "./app.js"; import writeLog from "./write-log.js"; import isBase64 from "is-base64"; @@ -8,7 +7,9 @@ import MESSAGES from "./api-messages.js"; import resolveToken from "./resolve-token.js"; import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; -import { ExperimentData, UserData, OSFResult } from './interfaces'; +import { getProviderForExperiment } from "./providers/index.js"; +import { WriteResult } from "./providers/types.js"; +import { ExperimentData, UserData } from './interfaces'; export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }, async (req, res) => { const { experimentID, data, filename } = req.body; @@ -112,13 +113,16 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: const token = tokenResult.token; - let result: OSFResult; + const { provider, container } = getProviderForExperiment(exp_data); + + let result: WriteResult; try { - result = await putFileOSF( - exp_data.osfFilesLink, - token, + result = await provider.writeSessionFile( + { token }, + container, + filename, buffer, - filename + { size: buffer.length, contentType: "application/octet-stream" } ); } catch (e) { // Network errors, timeouts, etc. — queue for retry @@ -142,7 +146,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: } if (!result.success) { - if (result.errorCode === 409 && result.errorText === "Conflict") { + if (result.error === "NAME_CONFLICT" && result.providerMessage === "Conflict") { res.status(400).json(MESSAGES.OSF_FILE_EXISTS); await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); return; @@ -152,16 +156,16 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, - errorCode: result.errorCode || 0, sessionIncremented: false, - failureReason: `OSF error ${result.errorCode}: ${result.errorText}`, + errorCode: result.providerStatus || 0, sessionIncremented: false, + failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, }); await cleanupPending(pendingPath); // queue-upload has its own copy res.status(202).json(MESSAGES.OSF_UPLOAD_QUEUED); - await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.errorCode, osfStatusText: result.errorText}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.providerStatus, osfStatusText: result.providerMessage}); return; } catch { res.status(400).json(MESSAGES.OSF_UPLOAD_ERROR); - await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.errorCode, osfStatusText: result.errorText}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.providerStatus, osfStatusText: result.providerMessage}); return; } } diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 3c14c3a..df7b357 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -2,7 +2,6 @@ import { onRequest } from "firebase-functions/v2/https"; import { FieldValue, DocumentReference, DocumentData, DocumentSnapshot } from "firebase-admin/firestore"; import validateJSON from "./validate-json.js"; import validateCSV from "./validate-csv.js"; -import putFileOSF from "./put-file-osf.js"; import { db } from "./app.js"; import writeLog from "./write-log.js"; import MESSAGES from "./api-messages.js"; @@ -10,7 +9,9 @@ import blockMetadata from "./metadata-block.js"; import resolveToken from "./resolve-token.js"; import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; -import { ExperimentData, UserData, MetadataResponse, OSFResult, RequestBody } from './interfaces'; +import { getProviderForExperiment } from "./providers/index.js"; +import { WriteResult } from "./providers/types.js"; +import { ExperimentData, UserData, MetadataResponse, RequestBody } from './interfaces'; export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }, async (req, res) => { const { experimentID, data, filename, metadataOptions }: RequestBody = req.body; @@ -145,13 +146,16 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 //METADATA BLOCK END - let result: OSFResult; + const { provider, container } = getProviderForExperiment(exp_data); + + let result: WriteResult; try { - result = await putFileOSF( - exp_data.osfFilesLink, - token, + result = await provider.writeSessionFile( + { token }, + container, + filename, data, - filename + { size: Buffer.byteLength(data), contentType: "application/json" } ); } catch (e) { // Network errors, timeouts, etc. — queue for retry @@ -176,7 +180,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 } if (!result.success) { - if (result.errorCode === 409 && result.errorText === "Conflict") { + if (result.error === "NAME_CONFLICT" && result.providerMessage === "Conflict") { res.status(400).json({...MESSAGES.OSF_FILE_EXISTS, metadataMessage}); await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); return; @@ -186,17 +190,17 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, - errorCode: result.errorCode || 0, sessionIncremented: true, - failureReason: `OSF error ${result.errorCode}: ${result.errorText}`, + errorCode: result.providerStatus || 0, sessionIncremented: true, + failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); - await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.errorCode, osfStatusText: result.errorText}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.providerStatus, osfStatusText: result.providerMessage}); return; } catch { res.status(400).json({...MESSAGES.OSF_UPLOAD_ERROR, metadataMessage}); - await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.errorCode, osfStatusText: result.errorText}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.providerStatus, osfStatusText: result.providerMessage}); return; } } diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index d607c3e..a57fff2 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -2,13 +2,12 @@ import MESSAGES from "./api-messages.js"; import processMetadata from "./metadata-process.js"; import updateMetadata from "./metadata-update.js"; import produceMetadata from "./metadata-production.js"; -import updateFileOSF from "./update-file-osf.js"; import downloadMetadata from "./metadata-download.js"; import { DocumentReference, DocumentData } from "firebase-admin/firestore"; -import putFileOSF from "./put-file-osf.js"; import { db } from "./app.js"; import { decrypt } from "./crypto-utils.js"; import { refreshAndUpdateUser } from "./refresh-token.js"; +import { osfProvider } from "./providers/osf.js"; import { ExperimentData, UserData, Metadata, MetadataResponse } from './interfaces'; @@ -77,28 +76,32 @@ try { t.update(metadata_doc_ref, {metadata: updatedMetadata}); - //If a metadata file exists in OSF, it is updated with the above metadata. + //If a metadata file exists in OSF, it is updated with the above metadata. if (osfMetadataId){ - await updateFileOSF( - exp_data.osfFilesLink, - decryptedOsfToken, + await osfProvider.updateFile( + { token: decryptedOsfToken }, + { provider: "osf", filesLink: exp_data.osfFilesLink }, + { id: osfMetadataId, name: "dataset_description.json" }, JSON.stringify(updatedMetadata, null, 2), - osfMetadataId + { size: Buffer.byteLength(JSON.stringify(updatedMetadata, null, 2)), contentType: "application/json" } ) } //If a metadata file does not exist in OSF, it is created with the above metadata. else { - const response = await putFileOSF( - exp_data.osfFilesLink, - decryptedOsfToken, + const response = await osfProvider.writeSessionFile( + { token: decryptedOsfToken }, + { provider: "osf", filesLink: exp_data.osfFilesLink }, + `dataset_description.json`, JSON.stringify(updatedMetadata, null, 2), - `dataset_description.json` + { size: Buffer.byteLength(JSON.stringify(updatedMetadata, null, 2)), contentType: "application/json" } ); - if (response.errorCode !== 210) throw new Error(MESSAGES.OSF_UPLOAD_ERROR.message); - + // Latent pre-existing bug preserved verbatim — removed in build step 3 (see design doc, "Collision detection") + const status = response.success ? null : response.providerStatus; + if (status !== 210) throw new Error(MESSAGES.OSF_UPLOAD_ERROR.message); + } } //When OSF has metadata but firestore does not, updating is done with respect to OSF. @@ -119,11 +122,12 @@ try { t.set(metadata_doc_ref, {metadata: updatedMetadata}, {merge: true}); //Since metadata exists in OSF, it is updated and not set. - await updateFileOSF( - exp_data.osfFilesLink, - decryptedOsfToken, + await osfProvider.updateFile( + { token: decryptedOsfToken }, + { provider: "osf", filesLink: exp_data.osfFilesLink }, + { id: osfMetadataId, name: "dataset_description.json" }, JSON.stringify(incomingMetadata, null, 2), - osfMetadataId + { size: Buffer.byteLength(JSON.stringify(incomingMetadata, null, 2)), contentType: "application/json" } ); } @@ -136,11 +140,12 @@ try { t.set(metadata_doc_ref, {metadata: incomingMetadata}, {merge: true}); - await putFileOSF( - exp_data.osfFilesLink, - decryptedOsfToken, + await osfProvider.writeSessionFile( + { token: decryptedOsfToken }, + { provider: "osf", filesLink: exp_data.osfFilesLink }, + `dataset_description.json`, JSON.stringify(incomingMetadata, null, 2), - `dataset_description.json` + { size: Buffer.byteLength(JSON.stringify(incomingMetadata, null, 2)), contentType: "application/json" } ); } diff --git a/functions/src/providers/index.ts b/functions/src/providers/index.ts new file mode 100644 index 0000000..901fafe --- /dev/null +++ b/functions/src/providers/index.ts @@ -0,0 +1,29 @@ +import { registerProvider, getProvider } from "./registry.js"; +import { osfProvider } from "./osf.js"; +import { StorageProvider, ContainerRef } from "./types.js"; +import { ExperimentData } from "../interfaces.js"; + +registerProvider(osfProvider); + +export function getProviderForExperiment(exp_data: ExperimentData): { + provider: StorageProvider; + container: ContainerRef; +} { + if (exp_data.storageProvider) { + return { + provider: getProvider(exp_data.storageProvider), + container: exp_data.providerContainer as ContainerRef, + }; + } + + // Legacy default: experiments created before the provider-migration schema + // have no storageProvider field and always write to OSF. + return { + provider: osfProvider, + container: { provider: "osf", filesLink: exp_data.osfFilesLink }, + }; +} + +export { registerProvider, getProvider } from "./registry.js"; +export { osfProvider } from "./osf.js"; +export * from "./types.js"; diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts new file mode 100644 index 0000000..fabcfad --- /dev/null +++ b/functions/src/providers/osf.ts @@ -0,0 +1,117 @@ +import fetch from "node-fetch"; +import putFileOSF from "../put-file-osf.js"; +import updateFileOSF from "../update-file-osf.js"; +import { OSFFile } from "../interfaces.js"; +import { + StorageProvider, + ResolvedAuth, + ContainerRef, + FileRef, + FileMeta, + WriteResult, + ProviderErrorCode, +} from "./types.js"; + +// The OSF container ref shape — only the filesLink is meaningful to this adapter. +export interface OSFContainerRef extends ContainerRef { + provider: "osf"; + filesLink: string; +} + +function mapStatus(errorCode: number | null): ProviderErrorCode { + switch (errorCode) { + case 409: + return "NAME_CONFLICT"; + case 401: + case 403: + return "AUTH_EXPIRED"; + case 429: + return "RATE_LIMITED"; + case 507: + return "QUOTA_EXCEEDED"; + default: + return "UNAVAILABLE"; + } +} + +export const osfProvider: StorageProvider = { + id: "osf", + authMethod: "oauth2", + capabilities: { + nativeSubfolders: true, + supportsRegion: true, + maxFileSizeBytes: null, + quotaNote: null, + }, + + async createDataContainer(): Promise { + throw new Error("osfProvider.createDataContainer is not implemented"); + }, + + async writeSessionFile( + auth: ResolvedAuth, + container: ContainerRef, + filename: string, + data: string | Buffer, + _meta: FileMeta + ): Promise { + const osfContainer = container as OSFContainerRef; + + const result = await putFileOSF(osfContainer.filesLink, auth.token, data, filename); + + if (result.success) { + return { + success: true, + fileRef: { name: filename }, + storedFilename: filename, + }; + } + + return { + success: false, + error: mapStatus(result.errorCode), + providerStatus: result.errorCode, + providerMessage: result.errorText, + retryAfter: result.retryAfter, + }; + }, + + async updateFile( + auth: ResolvedAuth, + container: ContainerRef, + existingFileRef: FileRef, + data: string | Buffer, + _meta: FileMeta + ): Promise { + const osfContainer = container as OSFContainerRef; + + // updateFileOSF throws on non-200 responses — let the throw propagate, + // callers rely on this behavior. + await updateFileOSF(osfContainer.filesLink, auth.token, data as string, existingFileRef.id as string); + + return { + success: true, + fileRef: existingFileRef, + storedFilename: existingFileRef.name, + }; + }, + + async listFiles(auth: ResolvedAuth, container: ContainerRef): Promise { + const osfContainer = container as OSFContainerRef; + + const osfResult = await fetch(`${osfContainer.filesLink}?meta=`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, + }); + + const folder = (await osfResult.json()) as { data: OSFFile[] }; + const listOfFiles: OSFFile[] = folder["data"]; + + return listOfFiles + .filter((file) => file.attributes.kind === "file") + .map((file) => ({ name: file.attributes.name, id: file.id })); + }, +}; diff --git a/functions/src/scheduled-upload-retry.ts b/functions/src/scheduled-upload-retry.ts index e26b97d..1aca84b 100644 --- a/functions/src/scheduled-upload-retry.ts +++ b/functions/src/scheduled-upload-retry.ts @@ -1,7 +1,7 @@ import { onSchedule } from "firebase-functions/v2/scheduler"; import { Timestamp } from "firebase-admin/firestore"; import { db, storage } from "./app.js"; -import putFileOSF from "./put-file-osf.js"; +import { osfProvider } from "./providers/osf.js"; import resolveToken from "./resolve-token.js"; import { ExperimentData, UserData } from "./interfaces.js"; @@ -135,7 +135,14 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho // Attempt the upload try { - const result = await putFileOSF(data.osfFilesLink, token, fileData, data.filename); + const container = { provider: "osf" as const, filesLink: data.osfFilesLink }; + const result = await osfProvider.writeSessionFile( + { token }, + container, + data.filename, + fileData, + { size: Buffer.byteLength(fileData), contentType: "application/json" } + ); if (result.success) { await markCompleted(docRef, data); @@ -143,14 +150,14 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho return; } - if (result.errorCode === 409) { + if (result.error === "NAME_CONFLICT") { // File already exists — treat as success (original upload may have worked) await markCompleted(docRef, data); console.log(`Upload ${queueDoc.id} marked complete — file already exists in OSF.`); return; } - await handleRetryFailure(docRef, data, `OSF error ${result.errorCode}: ${result.errorText}`, result.retryAfter); + await handleRetryFailure(docRef, data, `OSF error ${result.providerStatus}: ${result.providerMessage}`, result.retryAfter); } catch (e) { const detail = e instanceof Error ? e.message : "Unknown error"; await handleRetryFailure(docRef, data, `Upload exception: ${detail}`); From 0c336f873415b1f691808de06cebb743e9e4b185 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 22 Jul 2026 18:13:20 -0400 Subject: [PATCH 05/14] feat: Firestore collision cache with claim lifecycle and OSF dual-run Build step 3a of the provider migration. Duplicate-filename detection now lives in Firestore, decoupled from the provider: - collision-cache.ts: salted-hash claims (raw filenames never stored) at experiments/{id}/filenameClaims/{hash} with a pending->confirmed lifecycle, owner tokens for idempotent retry re-entry, stale-pending takeover (15 min), 90-day TTL via expiresAt, and lazy rehydration from provider.listFiles behind a 60s lease that fails loudly if listing fails - api-data/api-base64 claim before every provider write; duplicates are rejected without a provider round-trip; OSF's 409 stays as a dual-run backstop with disagreements logged (collisionCacheDisagreement) - retry queue carries claimToken; retries re-enter their own pending claim; legacy queue docs without a token skip the cache - confirmClaim skips the warmUntil bump when it's already >89 days out, avoiding a second experiment-doc write per submission under burst load - early-persist test's inline mock gains a GET route (rehydration lists the container on first write); its assertions are unchanged TDD: 12 unit + 5 integration tests written and reviewed red before implementation. Full emulator suite green. Deploy note: the Firestore TTL policy on filenameClaims.expiresAt must be enabled via console/gcloud when this ships; the emulator does not enforce TTL and nothing depends on it for correctness. Co-Authored-By: Claude Fable 5 --- .../src/__tests__/collision-cache.test.js | 430 ++++++++++++++++++ .../collision-integration-emulator.test.js | 294 ++++++++++++ .../__tests__/early-persist-emulator.test.js | 6 + functions/src/api-base64.ts | 83 +++- functions/src/api-data.ts | 85 +++- functions/src/collision-cache.ts | 305 +++++++++++++ functions/src/interfaces.ts | 4 + functions/src/providers/types.ts | 3 + functions/src/queue-upload.ts | 2 + functions/src/scheduled-upload-retry.ts | 41 +- 10 files changed, 1248 insertions(+), 5 deletions(-) create mode 100644 functions/src/__tests__/collision-cache.test.js create mode 100644 functions/src/__tests__/collision-integration-emulator.test.js create mode 100644 functions/src/collision-cache.ts diff --git a/functions/src/__tests__/collision-cache.test.js b/functions/src/__tests__/collision-cache.test.js new file mode 100644 index 0000000..e0d0257 --- /dev/null +++ b/functions/src/__tests__/collision-cache.test.js @@ -0,0 +1,430 @@ +/** + * @jest-environment node + */ + +// RED-phase unit tests for step 3a (docs/provider-migration-design.md, +// scratchpad/step3a-collision-cache-spec.md). collision-cache.ts does not +// exist yet — this whole file is expected to fail at module resolution +// until it is implemented. +// +// Style follows upload-queue.test.js: direct Firestore-emulator-backed calls +// against the module's public API, no HTTP layer involved. Every test uses +// its own freshly-generated experiment ID so tests never depend on one +// another's state or ordering. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore, Timestamp } from "firebase-admin/firestore"; +import { randomUUID, createHash } from "crypto"; + +import { + claimFilename, + confirmClaim, + releaseClaim, + CLAIM_TTL_MS, + STALE_PENDING_TAKEOVER_MS, + REHYDRATION_LEASE_MS, + CollisionCacheUnavailableError, +} from "../../lib/collision-cache.js"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; + +const config = { projectId: "datapipe-test" }; + +jest.setTimeout(30000); + +let db; + +beforeAll(async () => { + let app; + try { + app = getApp("collision-cache-test"); + } catch { + app = initializeApp(config, "collision-cache-test"); + } + db = getFirestore(app); +}); + +// A tolerance window for timestamp assertions — generous enough to absorb +// emulator round-trip latency without hiding a genuinely wrong duration. +const TOLERANCE_MS = 5000; + +function freshExperimentId(label) { + return `collision-${label}-${randomUUID()}`; +} + +// Mirrors the spec's hashing rule exactly: sha256hex(salt + ":" + filename). +// Recomputing it locally (rather than trusting the module) lets tests assert +// the doc ID *is* that specific value, not just "some opaque string". +function claimHash(salt, filename) { + return createHash("sha256").update(`${salt}:${filename}`).digest("hex"); +} + +async function createExperiment(experimentID, overrides = {}) { + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + owner: "collision-test-owner", + osfFilesLink: "https://example.invalid/files", + ...overrides, + }); +} + +// Pre-warms the cache with a known salt and a future warmUntil so tests that +// aren't *about* rehydration don't incidentally exercise it. Returns the salt +// so callers can compute expected claim-doc hashes. +async function warmExperiment(experimentID, { salt, warmUntilMs } = {}) { + const resolvedSalt = salt || randomUUID().replace(/-/g, ""); + const warmUntil = Timestamp.fromMillis(warmUntilMs ?? Date.now() + 24 * 60 * 60 * 1000); + await db + .collection("experiments") + .doc(experimentID) + .set({ collisionCache: { salt: resolvedSalt, warmUntil } }, { merge: true }); + return resolvedSalt; +} + +async function getExperimentData(experimentID) { + const snap = await db.collection("experiments").doc(experimentID).get(); + return snap.data(); +} + +function claimsCollection(experimentID) { + return db.collection("experiments").doc(experimentID).collection("filenameClaims"); +} + +async function getClaimDoc(experimentID, hash) { + return claimsCollection(experimentID).doc(hash).get(); +} + +describe("1. first claim on a cold cache", () => { + it("creates a salt, claims the filename, and never stores the raw filename anywhere in the claim doc", async () => { + const experimentID = freshExperimentId("first-claim"); + await createExperiment(experimentID); + const ownerToken = randomUUID(); + const listFilesFn = jest.fn().mockResolvedValue([]); + const filename = "data.csv"; + + const result = await claimFilename(experimentID, filename, ownerToken, listFilesFn); + + expect(result).toEqual({ claimed: true }); + + const expData = await getExperimentData(experimentID); + expect(expData.collisionCache).toBeDefined(); + expect(typeof expData.collisionCache.salt).toBe("string"); + expect(expData.collisionCache.salt.length).toBeGreaterThan(0); + + const claims = await claimsCollection(experimentID).get(); + expect(claims.docs).toHaveLength(1); + const claimDoc = claims.docs[0]; + + // The doc ID must be the salted hash, not the filename itself. + expect(claimDoc.id).not.toBe(filename); + expect(claimDoc.id).toBe(claimHash(expData.collisionCache.salt, filename)); + + // The raw filename string must not appear anywhere in the stored claim, + // including the doc ID — serialize everything and grep for it. + const serialized = JSON.stringify({ id: claimDoc.id, ...claimDoc.data() }); + expect(serialized).not.toContain(filename); + + const data = claimDoc.data(); + expect(data.status).toBe("pending"); + expect(data.ownerToken).toBe(ownerToken); + expect(data.createdAt).toBeDefined(); + expect(data.expiresAt).toBeDefined(); + }); +}); + +describe("2. duplicate claim from a different owner", () => { + it("rejects a second claim of an already-pending filename made by a different token", async () => { + const experimentID = freshExperimentId("dup-diff-token"); + await createExperiment(experimentID); + await warmExperiment(experimentID); + const listFilesFn = jest.fn().mockResolvedValue([]); + const filename = "dup.csv"; + + const first = await claimFilename(experimentID, filename, randomUUID(), listFilesFn); + expect(first).toEqual({ claimed: true }); + + const second = await claimFilename(experimentID, filename, randomUUID(), listFilesFn); + expect(second).toEqual({ claimed: false, reason: "duplicate" }); + + // Cache was already warm — rehydration must never have run. + expect(listFilesFn).not.toHaveBeenCalled(); + }); +}); + +describe("3. idempotent re-entry with the same owner token", () => { + it("allows the same owner token to re-claim its own pending filename without creating a second doc", async () => { + const experimentID = freshExperimentId("idempotent"); + await createExperiment(experimentID); + await warmExperiment(experimentID); + const listFilesFn = jest.fn().mockResolvedValue([]); + const ownerToken = randomUUID(); + const filename = "retry.csv"; + + const first = await claimFilename(experimentID, filename, ownerToken, listFilesFn); + expect(first).toEqual({ claimed: true }); + + const second = await claimFilename(experimentID, filename, ownerToken, listFilesFn); + expect(second).toEqual({ claimed: true }); + + const claims = await claimsCollection(experimentID).get(); + expect(claims.docs).toHaveLength(1); + }); +}); + +describe("4. confirmClaim", () => { + it("marks the claim confirmed, bumps collisionCache.warmUntil forward, and blocks future claims of that filename", async () => { + const experimentID = freshExperimentId("confirm"); + await createExperiment(experimentID); + // Warm, but only a little way out — far short of CLAIM_TTL_MS — so a bump + // from confirmClaim is unambiguous and can't be confused with rehydration + // (which would also set warmUntil, but only runs on a COLD cache). + const initialWarmUntilMs = Date.now() + 5 * 60 * 1000; + const salt = await warmExperiment(experimentID, { warmUntilMs: initialWarmUntilMs }); + const listFilesFn = jest.fn().mockResolvedValue([]); + const ownerToken = randomUUID(); + const filename = "confirmed.csv"; + + await claimFilename(experimentID, filename, ownerToken, listFilesFn); + expect(listFilesFn).not.toHaveBeenCalled(); // cache was warm + + const beforeConfirm = Date.now(); + await confirmClaim(experimentID, filename, ownerToken); + const afterConfirm = Date.now(); + + const expData = await getExperimentData(experimentID); + const warmUntilMs = expData.collisionCache.warmUntil.toMillis(); + expect(warmUntilMs).toBeGreaterThan(initialWarmUntilMs); + expect(warmUntilMs).toBeGreaterThanOrEqual(beforeConfirm + CLAIM_TTL_MS - TOLERANCE_MS); + expect(warmUntilMs).toBeLessThanOrEqual(afterConfirm + CLAIM_TTL_MS + TOLERANCE_MS); + + const hash = claimHash(salt, filename); + const claimSnap = await getClaimDoc(experimentID, hash); + expect(claimSnap.data().status).toBe("confirmed"); + + const later = await claimFilename(experimentID, filename, randomUUID(), listFilesFn); + expect(later).toEqual({ claimed: false, reason: "duplicate" }); + }); +}); + +describe("5. releaseClaim", () => { + it("deletes an owned pending claim so another token can claim it, and is a no-op with the wrong token", async () => { + const experimentID = freshExperimentId("release"); + await createExperiment(experimentID); + await warmExperiment(experimentID); + const listFilesFn = jest.fn().mockResolvedValue([]); + const ownerToken = randomUUID(); + const otherToken = randomUUID(); + const filename = "released.csv"; + + await claimFilename(experimentID, filename, ownerToken, listFilesFn); + + // Wrong-token release must not touch the claim. + await releaseClaim(experimentID, filename, otherToken); + const stillBlocked = await claimFilename(experimentID, filename, otherToken, listFilesFn); + expect(stillBlocked).toEqual({ claimed: false, reason: "duplicate" }); + + // Correct-token release removes it, freeing the filename. + await releaseClaim(experimentID, filename, ownerToken); + const reclaimed = await claimFilename(experimentID, filename, otherToken, listFilesFn); + expect(reclaimed).toEqual({ claimed: true }); + }); +}); + +describe("6. stale pending takeover", () => { + it("lets a new token take over a pending claim whose createdAt is older than STALE_PENDING_TAKEOVER_MS", async () => { + const experimentID = freshExperimentId("stale-takeover"); + await createExperiment(experimentID); + const salt = await warmExperiment(experimentID); + const staleOwnerToken = randomUUID(); + const filename = "stale.csv"; + const hash = claimHash(salt, filename); + + const staleCreatedAt = Timestamp.fromMillis(Date.now() - STALE_PENDING_TAKEOVER_MS - 60 * 1000); + await claimsCollection(experimentID) + .doc(hash) + .set({ + status: "pending", + ownerToken: staleOwnerToken, + createdAt: staleCreatedAt, + expiresAt: Timestamp.fromMillis(Date.now() + CLAIM_TTL_MS), + }); + + const newToken = randomUUID(); + const listFilesFn = jest.fn().mockResolvedValue([]); + const result = await claimFilename(experimentID, filename, newToken, listFilesFn); + + expect(result).toEqual({ claimed: true }); + + const claimSnap = await getClaimDoc(experimentID, hash); + expect(claimSnap.data().ownerToken).toBe(newToken); + expect(claimSnap.data().status).toBe("pending"); + expect(claimSnap.data().createdAt.toMillis()).toBeGreaterThan(staleCreatedAt.toMillis()); + }); +}); + +describe("7. filename and experiment isolation", () => { + it("claims different filenames independently within an experiment, and hashes an identical filename differently per experiment", async () => { + const expA = freshExperimentId("iso-a"); + const expB = freshExperimentId("iso-b"); + await createExperiment(expA); + await createExperiment(expB); + const saltA = await warmExperiment(expA); + const saltB = await warmExperiment(expB); + const listFilesFn = jest.fn().mockResolvedValue([]); + const sharedFilename = "shared-name.csv"; + + const resultOne = await claimFilename(expA, "one.csv", randomUUID(), listFilesFn); + const resultTwo = await claimFilename(expA, "two.csv", randomUUID(), listFilesFn); + expect(resultOne).toEqual({ claimed: true }); + expect(resultTwo).toEqual({ claimed: true }); + + const resultInB = await claimFilename(expB, sharedFilename, randomUUID(), listFilesFn); + const resultInA = await claimFilename(expA, sharedFilename, randomUUID(), listFilesFn); + expect(resultInB).toEqual({ claimed: true }); + expect(resultInA).toEqual({ claimed: true }); + + expect(saltA).not.toBe(saltB); + const hashInA = claimHash(saltA, sharedFilename); + const hashInB = claimHash(saltB, sharedFilename); + expect(hashInA).not.toBe(hashInB); + + expect((await getClaimDoc(expA, hashInA)).exists).toBe(true); + expect((await getClaimDoc(expB, hashInB)).exists).toBe(true); + }); +}); + +describe("8. expiresAt timing", () => { + it("sets expiresAt ~CLAIM_TTL_MS in the future on create, and refreshes it forward on confirm", async () => { + const experimentID = freshExperimentId("expires-at"); + await createExperiment(experimentID); + const salt = await warmExperiment(experimentID); + const listFilesFn = jest.fn().mockResolvedValue([]); + const ownerToken = randomUUID(); + const filename = "ttl.csv"; + const hash = claimHash(salt, filename); + + const beforeClaim = Date.now(); + await claimFilename(experimentID, filename, ownerToken, listFilesFn); + const afterClaim = Date.now(); + + let claimSnap = await getClaimDoc(experimentID, hash); + const createdExpiresAtMs = claimSnap.data().expiresAt.toMillis(); + expect(createdExpiresAtMs).toBeGreaterThanOrEqual(beforeClaim + CLAIM_TTL_MS - TOLERANCE_MS); + expect(createdExpiresAtMs).toBeLessThanOrEqual(afterClaim + CLAIM_TTL_MS + TOLERANCE_MS); + + // Small delay so a refreshed expiresAt is unambiguously later, not just + // equal due to millisecond truncation. + await new Promise((resolve) => setTimeout(resolve, 50)); + + const beforeConfirm = Date.now(); + await confirmClaim(experimentID, filename, ownerToken); + const afterConfirm = Date.now(); + + claimSnap = await getClaimDoc(experimentID, hash); + const confirmedExpiresAtMs = claimSnap.data().expiresAt.toMillis(); + expect(confirmedExpiresAtMs).toBeGreaterThanOrEqual(beforeConfirm + CLAIM_TTL_MS - TOLERANCE_MS); + expect(confirmedExpiresAtMs).toBeLessThanOrEqual(afterConfirm + CLAIM_TTL_MS + TOLERANCE_MS); + expect(confirmedExpiresAtMs).toBeGreaterThanOrEqual(createdExpiresAtMs); + }); +}); + +describe("9. rehydration on a cold cache", () => { + it("bulk-confirms every filename listFilesFn returns, calling it exactly once even across multiple claims", async () => { + const experimentID = freshExperimentId("rehydrate"); + await createExperiment(experimentID); // no collisionCache field at all — cold by construction + const listFilesFn = jest.fn().mockResolvedValue([{ name: "a.csv" }, { name: "b.csv" }]); + + const beforeRehydrate = Date.now(); + const result = await claimFilename(experimentID, "c.csv", randomUUID(), listFilesFn); + + expect(result).toEqual({ claimed: true }); // c.csv wasn't in the listing + expect(listFilesFn).toHaveBeenCalledTimes(1); + + const expData = await getExperimentData(experimentID); + expect(expData.collisionCache.warmUntil.toMillis()).toBeGreaterThanOrEqual( + beforeRehydrate + CLAIM_TTL_MS - TOLERANCE_MS + ); + + const salt = expData.collisionCache.salt; + const aSnap = await getClaimDoc(experimentID, claimHash(salt, "a.csv")); + const bSnap = await getClaimDoc(experimentID, claimHash(salt, "b.csv")); + expect(aSnap.data().status).toBe("confirmed"); + expect(aSnap.data().ownerToken).toBe("rehydration"); + expect(bSnap.data().status).toBe("confirmed"); + expect(bSnap.data().ownerToken).toBe("rehydration"); + + // a.csv is now provably taken via the bulk-written claim from rehydration. + const dup = await claimFilename(experimentID, "a.csv", randomUUID(), listFilesFn); + expect(dup).toEqual({ claimed: false, reason: "duplicate" }); + + // Neither the c.csv claim nor the a.csv duplicate check should have + // triggered a second rehydration pass. + expect(listFilesFn).toHaveBeenCalledTimes(1); + }); +}); + +describe("10. rehydration lease held by another request", () => { + it("returns reason 'rehydrating' without calling listFilesFn while another request's lease is active", async () => { + const experimentID = freshExperimentId("lease-held"); + await createExperiment(experimentID); + await db + .collection("experiments") + .doc(experimentID) + .set( + { + collisionCache: { + salt: randomUUID().replace(/-/g, ""), + // warmUntil deliberately absent/past — cache is cold, forcing the + // rehydration-lease check to run. + rehydratingUntil: Timestamp.fromMillis(Date.now() + REHYDRATION_LEASE_MS), + }, + }, + { merge: true } + ); + const listFilesFn = jest.fn().mockResolvedValue([]); + + const result = await claimFilename(experimentID, "leased.csv", randomUUID(), listFilesFn); + + expect(result).toEqual({ claimed: false, reason: "rehydrating" }); + expect(listFilesFn).not.toHaveBeenCalled(); + }); +}); + +describe("11. rehydration failure", () => { + it("throws CollisionCacheUnavailableError and clears the lease so a later claim retries rehydration", async () => { + const experimentID = freshExperimentId("rehydrate-fail"); + await createExperiment(experimentID); // cold cache, no salt yet + + const failingListFiles = jest.fn().mockRejectedValue(new Error("container access revoked")); + + await expect(claimFilename(experimentID, "x.csv", randomUUID(), failingListFiles)).rejects.toThrow( + CollisionCacheUnavailableError + ); + + const expDataAfterFailure = await getExperimentData(experimentID); + expect(expDataAfterFailure.collisionCache.rehydratingUntil).toBeFalsy(); + + const workingListFiles = jest.fn().mockResolvedValue([]); + const result = await claimFilename(experimentID, "x.csv", randomUUID(), workingListFiles); + + expect(result).toEqual({ claimed: true }); + expect(workingListFiles).toHaveBeenCalledTimes(1); // rehydration retried, not skipped + }); +}); + +describe("12. warm cache", () => { + it("never calls listFilesFn when warmUntil is already in the future", async () => { + const experimentID = freshExperimentId("warm"); + await createExperiment(experimentID); + await warmExperiment(experimentID); + const listFilesFn = jest.fn().mockResolvedValue([]); + + const result = await claimFilename(experimentID, "warm.csv", randomUUID(), listFilesFn); + + expect(result).toEqual({ claimed: true }); + expect(listFilesFn).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/src/__tests__/collision-integration-emulator.test.js b/functions/src/__tests__/collision-integration-emulator.test.js new file mode 100644 index 0000000..9de7a26 --- /dev/null +++ b/functions/src/__tests__/collision-integration-emulator.test.js @@ -0,0 +1,294 @@ +/** + * @jest-environment node + */ + +// RED-phase integration tests for step 3a (docs/provider-migration-design.md, +// scratchpad/step3a-collision-cache-spec.md), cases 13-17 of the test plan. +// +// These exercise the deployed-in-emulator apidata/apibase64 functions end to +// end against a mock OSF server, following the pattern in +// early-persist-emulator.test.js: a self-contained express server started on +// an OS-assigned port (`listen(0)`) rather than the shared, fixed-port +// mock-server.ts used by metadata-emulator.test.js. Two reasons for that +// choice instead of extending mock-server.ts: +// 1. mock-server.ts binds a hardcoded port (3000); a second test file +// binding the same port would race it when Jest runs files in parallel +// workers, causing spurious EADDRINUSE failures unrelated to collision +// logic. `listen(0)` sidesteps that entirely. +// 2. early-persist-emulator.test.js already establishes this inline-mock +// convention for tests that need per-test control over the OSF +// response, which is exactly what cases 14/15 need (forcing 409/500). +// mock-server.ts itself is untouched. +// +// Until api-data.ts / api-base64.ts / collision-cache.ts are implemented, +// these requests never claim a filename in Firestore at all, so most +// assertions here fail on missing behavior (no collisionCache field ever +// appears, no claim docs, no claimToken on the queue doc) rather than on any +// transport-level problem. + +import { initializeApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID, createHash } from "crypto"; +import express from "express"; +import MESSAGES from "../api-messages"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; + +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; + +const OWNER_ID = "collision-int-owner"; + +const sampleData = `[{"trial_type":"html-keyboard-response","trial_index":1,"time_elapsed":776}]`; +const sampleBase64 = Buffer.from("collision cache integration payload").toString("base64"); + +async function saveData(body) { + const response = await fetch("http://localhost:5001/datapipe-test/us-central1/apidata", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "*/*" }, + body: JSON.stringify(body), + }); + const message = await response.json(); + return { status: response.status, body: message }; +} + +async function saveBase64Data(body) { + const response = await fetch("http://localhost:5001/datapipe-test/us-central1/apibase64", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "*/*" }, + body: JSON.stringify(body), + }); + const message = await response.json(); + return { status: response.status, body: message }; +} + +// Mirrors collision-cache.ts's hashing rule (sha256hex(salt + ":" + filename)) +// so tests can look up the exact claim doc a request should have produced. +function claimHash(salt, filename) { + return createHash("sha256").update(`${salt}:${filename}`).digest("hex"); +} + +// A minimal, self-contained mock OSF "files" container: +// GET /files -> osfProvider.listFiles (?meta=) +// PUT /files?name=<...> -> osfProvider.writeSessionFile / putFileOSF +// with in-process (not HTTP) controls, since the test and the mock server +// share the same Node process. +function createMockOSFServer() { + const app = express(); + const uploadCountsByFilename = new Map(); + const forcedStatusByFilename = new Map(); + + app.get("/files", (req, res) => { + // Always an empty container: every experiment in this suite starts + // fresh, so rehydration (if implemented) should complete trivially. + res.json({ data: [] }); + }); + + app.put("/files", (req, res) => { + const filename = String(req.query.name || ""); + uploadCountsByFilename.set(filename, (uploadCountsByFilename.get(filename) || 0) + 1); + + const forcedStatus = forcedStatusByFilename.get(filename); + if (forcedStatus && forcedStatus !== 201) { + // Deliberately setting only the status (not a custom statusMessage) so + // Node's default HTTP reason phrase applies — e.g. 409 -> "Conflict", + // 500 -> "Internal Server Error" — matching what production code + // (api-data.ts's `result.providerMessage === "Conflict"` check) expects + // from a real OSF response. + res.status(forcedStatus).json({ errors: [{ detail: `mock-forced-status-${forcedStatus}` }] }); + return; + } + + res.status(201).json({ + data: { attributes: { name: filename, kind: "file" }, id: "osfstorage/mock-upload" }, + }); + }); + + return new Promise((resolve) => { + const server = app.listen(0, () => { + resolve({ + server, + port: server.address().port, + getUploadCount: (filename) => uploadCountsByFilename.get(filename) || 0, + forceStatus: (filename, status) => forcedStatusByFilename.set(filename, status), + reset: () => { + uploadCountsByFilename.clear(); + forcedStatusByFilename.clear(); + }, + }); + }); + }); +} + +let db; +let mockOSF; +let filesLink; + +beforeAll(async () => { + mockOSF = await createMockOSFServer(); + filesLink = `http://localhost:${mockOSF.port}/files`; + + initializeApp(config); + db = getFirestore(); + + await db.collection("users").doc(OWNER_ID).set({ + osfTokenValid: true, + osfToken: "valid", + usingPersonalToken: true, + }); +}); + +afterEach(() => { + mockOSF.reset(); +}); + +afterAll(async () => { + mockOSF.server.close(); +}); + +async function createDataExperiment(experimentID, overrides = {}) { + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + metadataActive: false, + owner: OWNER_ID, + osfFilesLink: filesLink, + ...overrides, + }); +} + +async function createBase64Experiment(experimentID, overrides = {}) { + await db + .collection("experiments") + .doc(experimentID) + .set({ + activeBase64: true, + owner: OWNER_ID, + osfFilesLink: filesLink, + ...overrides, + }); +} + +describe("13. apidata: duplicate filename is rejected without a second provider upload", () => { + it("first POST succeeds, second POST for the same filename gets OSF_FILE_EXISTS, and OSF received exactly one upload", async () => { + const experimentID = `collision-int13-${randomUUID()}`; + const filename = `dup-${randomUUID()}.json`; + await createDataExperiment(experimentID); + + const first = await saveData({ experimentID, data: sampleData, filename }); + expect(first.status).toBe(201); + + const second = await saveData({ experimentID, data: sampleData, filename }); + expect(second.status).toBe(400); + expect(second.body).toEqual({ ...MESSAGES.OSF_FILE_EXISTS, metadataMessage: "" }); + + expect(mockOSF.getUploadCount(filename)).toBe(1); + }); +}); + +describe("14. dual-run disagreement between an empty cache and a 409 from OSF", () => { + it("responds OSF_FILE_EXISTS, confirms the claim, and logs a collisionCacheDisagreement entry", async () => { + const experimentID = `collision-int14-${randomUUID()}`; + const filename = `disagree-${randomUUID()}.json`; + await createDataExperiment(experimentID); + + // The cache has no claim for this filename (fresh experiment, empty + // mock container) — but OSF itself reports the name is already taken. + mockOSF.forceStatus(filename, 409); + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ ...MESSAGES.OSF_FILE_EXISTS, metadataMessage: "" }); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(expDataAfter.collisionCache).toBeDefined(); + + const hash = claimHash(expDataAfter.collisionCache.salt, filename); + const claimSnap = await db + .collection("experiments") + .doc(experimentID) + .collection("filenameClaims") + .doc(hash) + .get(); + expect(claimSnap.exists).toBe(true); + expect(claimSnap.data().status).toBe("confirmed"); + + const logDoc = await db.collection("logs").doc(experimentID).get(); + const errors = logDoc.data()?.errors || []; + expect(errors.some((entry) => entry.collisionCacheDisagreement === true)).toBe(true); + }); +}); + +describe("15. provider failure queues the upload and preserves the claim", () => { + it("500 from OSF results in a 202 queued response with a claimToken, and the claim stays pending", async () => { + const experimentID = `collision-int15-${randomUUID()}`; + const filename = `queue-${randomUUID()}.json`; + await createDataExperiment(experimentID); + + mockOSF.forceStatus(filename, 500); + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(202); + expect(response.body).toEqual(expect.objectContaining({ ...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage: "" })); + + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + const queueDoc = await db.collection("uploadQueue").doc(docId).get(); + expect(queueDoc.exists).toBe(true); + expect(typeof queueDoc.data().claimToken).toBe("string"); + expect(queueDoc.data().claimToken.length).toBeGreaterThan(0); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(expDataAfter.collisionCache).toBeDefined(); + + const hash = claimHash(expDataAfter.collisionCache.salt, filename); + const claimSnap = await db + .collection("experiments") + .doc(experimentID) + .collection("filenameClaims") + .doc(hash) + .get(); + expect(claimSnap.exists).toBe(true); + expect(claimSnap.data().status).toBe("pending"); + expect(claimSnap.data().ownerToken).toBe(queueDoc.data().claimToken); + }); +}); + +describe("16. apibase64: duplicate filename is rejected without a second provider upload", () => { + it("first POST succeeds, second POST for the same filename gets OSF_FILE_EXISTS, and OSF received exactly one upload", async () => { + const experimentID = `collision-int16-${randomUUID()}`; + const filename = `dup-b64-${randomUUID()}.dat`; + await createBase64Experiment(experimentID); + + const first = await saveBase64Data({ experimentID, data: sampleBase64, filename }); + expect(first.status).toBe(201); + + const second = await saveBase64Data({ experimentID, data: sampleBase64, filename }); + expect(second.status).toBe(400); + expect(second.body).toEqual(MESSAGES.OSF_FILE_EXISTS); + + expect(mockOSF.getUploadCount(filename)).toBe(1); + }); +}); + +describe("17. successful upload stamps the experiment with a warm collision cache", () => { + it("after a 201, the experiment doc has collisionCache.salt and a warmUntil in the future", async () => { + const experimentID = `collision-int17-${randomUUID()}`; + const filename = `stamp-${randomUUID()}.json`; + await createDataExperiment(experimentID); + + const before = Date.now(); + const response = await saveData({ experimentID, data: sampleData, filename }); + expect(response.status).toBe(201); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(expDataAfter.collisionCache).toBeDefined(); + expect(typeof expDataAfter.collisionCache.salt).toBe("string"); + expect(expDataAfter.collisionCache.salt.length).toBeGreaterThan(0); + expect(expDataAfter.collisionCache.warmUntil.toMillis()).toBeGreaterThan(before); + }); +}); diff --git a/functions/src/__tests__/early-persist-emulator.test.js b/functions/src/__tests__/early-persist-emulator.test.js index 9c2ad09..c7aaeda 100644 --- a/functions/src/__tests__/early-persist-emulator.test.js +++ b/functions/src/__tests__/early-persist-emulator.test.js @@ -57,6 +57,12 @@ function createMockOSFServer() { app.put("/endpoint", (req, res) => { res.status(201).json({ data: { attributes: { name: req.query.name || "uploaded.json" } } }); }); + // Collision-cache rehydration lists the container on an experiment's first + // write (see collision-cache.ts); an empty listing keeps this suite's + // experiments cold-start-clean without changing what it tests. + app.get("/endpoint", (req, res) => { + res.json({ data: [] }); + }); return new Promise((resolve) => { const server = app.listen(0, () => { resolve(server); diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index 901c256..c0e1123 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -1,4 +1,5 @@ import { onRequest } from "firebase-functions/v2/https"; +import { randomUUID } from "crypto"; import { DocumentReference, DocumentData, DocumentSnapshot } from "firebase-admin/firestore"; import { db } from "./app.js"; import writeLog from "./write-log.js"; @@ -9,6 +10,7 @@ import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; import { getProviderForExperiment } from "./providers/index.js"; import { WriteResult } from "./providers/types.js"; +import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData } from './interfaces'; export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }, async (req, res) => { @@ -115,6 +117,67 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: const { provider, container } = getProviderForExperiment(exp_data); + // Collision detection: claim the filename in the Firestore cache + // immediately before the provider write. The provider's own conflict + // response (NAME_CONFLICT) stays wired up below as a dual-run backstop. + const claimToken = randomUUID(); + let claimResult: Awaited>; + try { + claimResult = await claimFilename(experimentID, filename, claimToken, () => + provider.listFiles({ token }, container) + ); + } catch (e) { + if (e instanceof CollisionCacheUnavailableError) { + const detail = e.message; + try { + await queueUpload({ + experimentID, owner: exp_data.owner, filename, data, + dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + errorCode: 0, sessionIncremented: false, + failureReason: `Collision cache rehydration failed: ${detail}`, + claimToken, + }); + await cleanupPending(pendingPath); // queue-upload has its own copy + res.status(202).json(MESSAGES.OSF_UPLOAD_QUEUED); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: `Collision cache rehydration failed: ${detail}`}); + return; + } catch { + res.status(500).json(MESSAGES.OSF_UPLOAD_EXCEPTION); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail}); + return; + } + } + throw e; + } + + if (!claimResult.claimed) { + if (claimResult.reason === "duplicate") { + res.status(400).json(MESSAGES.OSF_FILE_EXISTS); + await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); + return; + } + + // reason === "rehydrating" — another request holds the rehydration + // lease; queue this upload and let the retry land after it expires. + try { + await queueUpload({ + experimentID, owner: exp_data.owner, filename, data, + dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + errorCode: 0, sessionIncremented: false, + failureReason: "Collision cache rehydrating", + claimToken, + }); + await cleanupPending(pendingPath); // queue-upload has its own copy + res.status(202).json(MESSAGES.OSF_UPLOAD_QUEUED); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: "Collision cache rehydrating"}); + return; + } catch { + res.status(500).json(MESSAGES.OSF_UPLOAD_EXCEPTION); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: "Collision cache rehydrating"}); + return; + } + } + let result: WriteResult; try { result = await provider.writeSessionFile( @@ -125,7 +188,8 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: { size: buffer.length, contentType: "application/octet-stream" } ); } catch (e) { - // Network errors, timeouts, etc. — queue for retry + // Network errors, timeouts, etc. — queue for retry. The claim stays + // pending so the retry can re-enter it with the same token. const detail = e instanceof Error ? e.message : "Unknown error"; try { await queueUpload({ @@ -133,6 +197,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: dataType: "base64", osfFilesLink: exp_data.osfFilesLink, errorCode: 0, sessionIncremented: false, failureReason: `Upload exception: ${detail}`, + claimToken, }); await cleanupPending(pendingPath); // queue-upload has its own copy res.status(202).json(MESSAGES.OSF_UPLOAD_QUEUED); @@ -147,17 +212,27 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: if (!result.success) { if (result.error === "NAME_CONFLICT" && result.providerMessage === "Conflict") { + // Dual-run disagreement: the cache thought the name was free but OSF + // says it's taken. OSF is still the backstop — record the + // disagreement and confirm the claim (the name is now provably taken). + await confirmClaim(experimentID, filename, claimToken); res.status(400).json(MESSAGES.OSF_FILE_EXISTS); await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); + await writeLog(experimentID, "logError", { + collisionCacheDisagreement: true, + direction: "cache-free-provider-conflict", + }); return; } - // Queue all other failures for retry + // Queue all other failures for retry. The claim stays pending so the + // retry can re-enter it with the same token. try { await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, errorCode: result.providerStatus || 0, sessionIncremented: false, failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, + claimToken, }); await cleanupPending(pendingPath); // queue-upload has its own copy res.status(202).json(MESSAGES.OSF_UPLOAD_QUEUED); @@ -170,6 +245,10 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: } } + // Successful write — confirm the claim (best-effort; a confirm failure + // must not fail a request that already succeeded against the provider). + await confirmClaim(experimentID, filename, claimToken); + // Data successfully uploaded to OSF — clean up the pending copy. await cleanupPending(pendingPath); diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index df7b357..1440c3d 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -1,4 +1,5 @@ import { onRequest } from "firebase-functions/v2/https"; +import { randomUUID } from "crypto"; import { FieldValue, DocumentReference, DocumentData, DocumentSnapshot } from "firebase-admin/firestore"; import validateJSON from "./validate-json.js"; import validateCSV from "./validate-csv.js"; @@ -11,6 +12,7 @@ import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; import { getProviderForExperiment } from "./providers/index.js"; import { WriteResult } from "./providers/types.js"; +import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData, MetadataResponse, RequestBody } from './interfaces'; export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }, async (req, res) => { @@ -148,6 +150,69 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 const { provider, container } = getProviderForExperiment(exp_data); + // Collision detection: claim the filename in the Firestore cache + // immediately before the provider write. The provider's own conflict + // response (NAME_CONFLICT) stays wired up below as a dual-run backstop. + const claimToken = randomUUID(); + let claimResult: Awaited>; + try { + claimResult = await claimFilename(experimentID, filename, claimToken, () => + provider.listFiles({ token }, container) + ); + } catch (e) { + if (e instanceof CollisionCacheUnavailableError) { + const detail = e.message; + try { + await queueUpload({ + experimentID, owner: exp_data.owner, filename, data, + dataType: "data", osfFilesLink: exp_data.osfFilesLink, + errorCode: 0, sessionIncremented: true, + failureReason: `Collision cache rehydration failed: ${detail}`, + claimToken, + }); + await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); + await cleanupPending(pendingPath); // queue-upload has its own copy + res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: `Collision cache rehydration failed: ${detail}`}); + return; + } catch { + res.status(500).json({...MESSAGES.OSF_UPLOAD_EXCEPTION, metadataMessage}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail}); + return; + } + } + throw e; + } + + if (!claimResult.claimed) { + if (claimResult.reason === "duplicate") { + res.status(400).json({...MESSAGES.OSF_FILE_EXISTS, metadataMessage}); + await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); + return; + } + + // reason === "rehydrating" — another request holds the rehydration + // lease; queue this upload and let the retry land after it expires. + try { + await queueUpload({ + experimentID, owner: exp_data.owner, filename, data, + dataType: "data", osfFilesLink: exp_data.osfFilesLink, + errorCode: 0, sessionIncremented: true, + failureReason: "Collision cache rehydrating", + claimToken, + }); + await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); + await cleanupPending(pendingPath); // queue-upload has its own copy + res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: "Collision cache rehydrating"}); + return; + } catch { + res.status(500).json({...MESSAGES.OSF_UPLOAD_EXCEPTION, metadataMessage}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: "Collision cache rehydrating"}); + return; + } + } + let result: WriteResult; try { result = await provider.writeSessionFile( @@ -158,7 +223,8 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 { size: Buffer.byteLength(data), contentType: "application/json" } ); } catch (e) { - // Network errors, timeouts, etc. — queue for retry + // Network errors, timeouts, etc. — queue for retry. The claim stays + // pending so the retry can re-enter it with the same token. const detail = e instanceof Error ? e.message : "Unknown error"; try { await queueUpload({ @@ -166,6 +232,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 dataType: "data", osfFilesLink: exp_data.osfFilesLink, errorCode: 0, sessionIncremented: true, failureReason: `Upload exception: ${detail}`, + claimToken, }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy @@ -181,17 +248,27 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 if (!result.success) { if (result.error === "NAME_CONFLICT" && result.providerMessage === "Conflict") { + // Dual-run disagreement: the cache thought the name was free but OSF + // says it's taken. OSF is still the backstop — record the + // disagreement and confirm the claim (the name is now provably taken). + await confirmClaim(experimentID, filename, claimToken); res.status(400).json({...MESSAGES.OSF_FILE_EXISTS, metadataMessage}); await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); + await writeLog(experimentID, "logError", { + collisionCacheDisagreement: true, + direction: "cache-free-provider-conflict", + }); return; } - // Queue all other failures for retry + // Queue all other failures for retry. The claim stays pending so the + // retry can re-enter it with the same token. try { await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, errorCode: result.providerStatus || 0, sessionIncremented: true, failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, + claimToken, }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy @@ -205,6 +282,10 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 } } + // Successful write — confirm the claim (best-effort; a confirm failure + // must not fail a request that already succeeded against the provider). + await confirmClaim(experimentID, filename, claimToken); + await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); // Data successfully uploaded to OSF — clean up the pending copy. diff --git a/functions/src/collision-cache.ts b/functions/src/collision-cache.ts new file mode 100644 index 0000000..b9d999f --- /dev/null +++ b/functions/src/collision-cache.ts @@ -0,0 +1,305 @@ +// Firestore-backed filename collision cache (docs/provider-migration-design.md, +// scratchpad/step3a-collision-cache-spec.md). +// +// Duplicate-filename detection moves out of "ask the provider and interpret +// its 409" and into a per-experiment Firestore cache of salted filename +// hashes. The provider's own conflict response stays wired up as a dual-run +// backstop (see api-data.ts / api-base64.ts): callers keep reacting to +// NAME_CONFLICT, but only to reconcile with what the cache already believes. +// +// Firestore layout: +// experiments/{id}.collisionCache: { +// salt: string, +// warmUntil: Timestamp, +// rehydratingUntil?: Timestamp, +// } +// experiments/{id}/filenameClaims/{sha256hex(salt + ":" + filename)}: { +// status: "pending" | "confirmed", +// ownerToken: string, +// createdAt: Timestamp, +// expiresAt: Timestamp, +// } +// +// The raw filename is never stored anywhere — only its salted hash, used as +// the claim document's ID. + +import { randomBytes, createHash } from "crypto"; +import { Timestamp, FieldValue } from "firebase-admin/firestore"; +import { db } from "./app.js"; +import { FileRef } from "./providers/types.js"; + +export const CLAIM_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days +export const STALE_PENDING_TAKEOVER_MS = 15 * 60 * 1000; // 15 minutes +export const REHYDRATION_LEASE_MS = 60 * 1000; // 60 seconds + +// Thrown when a cold cache needs to rehydrate but the caller-supplied +// listFilesFn fails (e.g. container missing, access revoked). Callers must +// treat this as "we don't know" and fail loudly rather than silently +// accepting a possible duplicate. +export class CollisionCacheUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "CollisionCacheUnavailableError"; + } +} + +export type ClaimResult = + | { claimed: true } + | { claimed: false; reason: "duplicate" } + | { claimed: false; reason: "rehydrating" }; + +function hashFilename(salt: string, filename: string): string { + return createHash("sha256").update(`${salt}:${filename}`).digest("hex"); +} + +function experimentRef(experimentID: string) { + return db.collection("experiments").doc(experimentID); +} + +function claimsCollection(experimentID: string) { + return experimentRef(experimentID).collection("filenameClaims"); +} + +// Reads (or lazily creates) the per-experiment salt used to hash filenames. +// The salt is retained indefinitely and never rotated. +async function ensureSalt(experimentID: string): Promise { + const expRef = experimentRef(experimentID); + return db.runTransaction(async (tx) => { + const snap = await tx.get(expRef); + const existingSalt = snap.data()?.collisionCache?.salt as string | undefined; + if (existingSalt) { + return existingSalt; + } + const salt = randomBytes(32).toString("hex"); + tx.update(expRef, { "collisionCache.salt": salt }); + return salt; + }); +} + +async function isCacheWarm(experimentID: string): Promise { + const snap = await experimentRef(experimentID).get(); + const warmUntil = snap.data()?.collisionCache?.warmUntil as FirebaseFirestore.Timestamp | undefined; + return !!warmUntil && warmUntil.toMillis() > Date.now(); +} + +// Rehydrates a cold cache: acquires a short lease, lists every file the +// provider currently has, and bulk-writes them as confirmed claims. Returns +// true once the cache is warm, or false if another in-flight request already +// holds the rehydration lease (caller should surface "rehydrating" rather +// than block). Throws CollisionCacheUnavailableError if listFilesFn fails — +// the lease is cleared first so a later request can retry. +async function rehydrate( + experimentID: string, + salt: string, + listFilesFn: () => Promise +): Promise { + const expRef = experimentRef(experimentID); + + let acquiredLease = false; + await db.runTransaction(async (tx) => { + const snap = await tx.get(expRef); + const rehydratingUntil = snap.data()?.collisionCache?.rehydratingUntil as + | FirebaseFirestore.Timestamp + | undefined; + if (rehydratingUntil && rehydratingUntil.toMillis() > Date.now()) { + acquiredLease = false; + return; + } + const leaseUntil = Timestamp.fromMillis(Date.now() + REHYDRATION_LEASE_MS); + tx.update(expRef, { "collisionCache.rehydratingUntil": leaseUntil }); + acquiredLease = true; + }); + + if (!acquiredLease) { + return false; + } + + let files: FileRef[]; + try { + files = await listFilesFn(); + } catch (e) { + // Clear the lease so a subsequent claim attempts rehydration again + // rather than being locked out until the lease naturally expires. + await expRef.update({ "collisionCache.rehydratingUntil": FieldValue.delete() }); + const detail = e instanceof Error ? e.message : String(e); + throw new CollisionCacheUnavailableError( + `Rehydration failed for experiment ${experimentID}: ${detail}` + ); + } + + const now = Timestamp.now(); + const expiresAt = Timestamp.fromMillis(now.toMillis() + CLAIM_TTL_MS); + const claims = claimsCollection(experimentID); + + for (let i = 0; i < files.length; i += 500) { + const chunk = files.slice(i, i + 500); + const batch = db.batch(); + for (const file of chunk) { + const hash = hashFilename(salt, file.name); + batch.set(claims.doc(hash), { + status: "confirmed", + ownerToken: "rehydration", + createdAt: now, + expiresAt, + }); + } + await batch.commit(); + } + + const warmUntil = Timestamp.fromMillis(Date.now() + CLAIM_TTL_MS); + await expRef.update({ + "collisionCache.warmUntil": warmUntil, + "collisionCache.rehydratingUntil": FieldValue.delete(), + }); + + return true; +} + +async function attemptClaim( + experimentID: string, + filename: string, + ownerToken: string, + salt: string +): Promise { + const hash = hashFilename(salt, filename); + const claimRef = claimsCollection(experimentID).doc(hash); + + return db.runTransaction(async (tx): Promise => { + const snap = await tx.get(claimRef); + const now = Timestamp.now(); + const expiresAt = Timestamp.fromMillis(now.toMillis() + CLAIM_TTL_MS); + + if (!snap.exists) { + tx.set(claimRef, { status: "pending", ownerToken, createdAt: now, expiresAt }); + return { claimed: true }; + } + + const data = snap.data()!; + + if (data.status === "pending") { + if (data.ownerToken === ownerToken) { + // Idempotent re-entry — the retry queue re-claiming its own filename. + return { claimed: true }; + } + + const createdAtMs = (data.createdAt as FirebaseFirestore.Timestamp).toMillis(); + if (Date.now() - createdAtMs > STALE_PENDING_TAKEOVER_MS) { + tx.set(claimRef, { status: "pending", ownerToken, createdAt: now, expiresAt }); + return { claimed: true }; + } + } + + // confirmed, or a fresh pending claim owned by someone else. + return { claimed: false, reason: "duplicate" }; + }); +} + +export async function claimFilename( + experimentID: string, + filename: string, + ownerToken: string, + listFilesFn: () => Promise +): Promise { + const salt = await ensureSalt(experimentID); + + const warm = await isCacheWarm(experimentID); + if (!warm) { + const nowWarm = await rehydrate(experimentID, salt, listFilesFn); + if (!nowWarm) { + return { claimed: false, reason: "rehydrating" }; + } + } + + return attemptClaim(experimentID, filename, ownerToken, salt); +} + +// Best-effort: marks a claim confirmed and bumps the cache's warmUntil. +// Never throws — a confirm failure must not fail the request that already +// succeeded (or was provably a name conflict) against the provider. Missing +// claims / salt / owner mismatches are logged, not thrown. +export async function confirmClaim( + experimentID: string, + filename: string, + ownerToken: string +): Promise { + try { + const expRef = experimentRef(experimentID); + const expSnap = await expRef.get(); + const salt = expSnap.data()?.collisionCache?.salt as string | undefined; + if (!salt) { + console.error( + `confirmClaim: no collisionCache.salt for experiment ${experimentID}; cannot confirm claim` + ); + return; + } + + const hash = hashFilename(salt, filename); + const claimRef = claimsCollection(experimentID).doc(hash); + + // The experiment doc already takes one write per submission (the sessions + // increment); bumping warmUntil on every confirm would double that under + // burst load. Skip the bump while warmUntil is still comfortably in the + // future — a day of drift is irrelevant against a 90-day window. + const currentWarmUntil = expSnap.data()?.collisionCache?.warmUntil as + | FirebaseFirestore.Timestamp + | undefined; + + await db.runTransaction(async (tx) => { + const claimSnap = await tx.get(claimRef); + if (!claimSnap.exists) { + console.error(`confirmClaim: no claim doc found for experiment ${experimentID}`); + return; + } + + const data = claimSnap.data()!; + if (data.ownerToken !== ownerToken) { + console.error(`confirmClaim: owner token mismatch for experiment ${experimentID}`); + return; + } + + const now = Timestamp.now(); + const expiresAt = Timestamp.fromMillis(now.toMillis() + CLAIM_TTL_MS); + tx.update(claimRef, { status: "confirmed", expiresAt }); + const warmEnough = + currentWarmUntil && + currentWarmUntil.toMillis() > now.toMillis() + CLAIM_TTL_MS - 24 * 60 * 60 * 1000; + if (!warmEnough) { + tx.update(expRef, { "collisionCache.warmUntil": expiresAt }); + } + }); + } catch (e) { + console.error( + `confirmClaim failed for experiment ${experimentID}, filename ${filename}:`, + e instanceof Error ? e.message : e + ); + } +} + +// Deletes a claim only if it is still pending and owned by the given token — +// a no-op with the wrong token or for a confirmed claim. +export async function releaseClaim( + experimentID: string, + filename: string, + ownerToken: string +): Promise { + const expRef = experimentRef(experimentID); + const expSnap = await expRef.get(); + const salt = expSnap.data()?.collisionCache?.salt as string | undefined; + if (!salt) { + return; + } + + const hash = hashFilename(salt, filename); + const claimRef = claimsCollection(experimentID).doc(hash); + + await db.runTransaction(async (tx) => { + const snap = await tx.get(claimRef); + if (!snap.exists) { + return; + } + const data = snap.data()!; + if (data.status === "pending" && data.ownerToken === ownerToken) { + tx.delete(claimRef); + } + }); +} diff --git a/functions/src/interfaces.ts b/functions/src/interfaces.ts index 0a85d7c..6e5d9df 100644 --- a/functions/src/interfaces.ts +++ b/functions/src/interfaces.ts @@ -108,6 +108,10 @@ export interface ExperimentData { failureReason: string | null; deduplicationKey: string; sessionIncremented: boolean; + // Collision-cache claim owned by this queue entry (additive; absent for + // entries queued before the collision cache existed — those skip the + // cache entirely on retry). + claimToken?: string; } export interface OSFFile{ diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index a649ad2..ba2b275 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -146,4 +146,7 @@ export interface ConnectedAccounts { export interface CollisionCacheState { salt: string; warmUntil: FirebaseFirestore.Timestamp; + // Set while a rehydration pass is in flight (leases the rehydration work + // to one request at a time); cleared on completion or failure. + rehydratingUntil?: FirebaseFirestore.Timestamp; } diff --git a/functions/src/queue-upload.ts b/functions/src/queue-upload.ts index 286de8c..818e582 100644 --- a/functions/src/queue-upload.ts +++ b/functions/src/queue-upload.ts @@ -11,6 +11,7 @@ interface QueueUploadParams { errorCode: number; sessionIncremented: boolean; failureReason?: string; + claimToken?: string; } const MAX_RETRIES = 5; @@ -60,6 +61,7 @@ export default async function queueUpload(params: QueueUploadParams): Promise>; + try { + claimResult = await claimFilename(data.experimentID, data.filename, data.claimToken, () => + osfProvider.listFiles({ token }, container) + ); + } catch (e) { + if (e instanceof CollisionCacheUnavailableError) { + await handleRetryFailure(docRef, data, `Collision cache rehydration failed: ${e.message}`); + return; + } + throw e; + } + + if (!claimResult.claimed) { + if (claimResult.reason === "duplicate") { + // Someone else confirmed this name while we were queued — mirrors + // today's 409-on-retry-means-done semantics. + await markCompleted(docRef, data); + console.log(`Upload ${queueDoc.id} marked complete — file already exists in OSF.`); + return; + } + // reason === "rehydrating" + await handleRetryFailure(docRef, data, "Collision cache rehydrating"); + return; + } + } + // Attempt the upload try { - const container = { provider: "osf" as const, filesLink: data.osfFilesLink }; const result = await osfProvider.writeSessionFile( { token }, container, @@ -145,12 +178,18 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho ); if (result.success) { + if (data.claimToken) { + await confirmClaim(data.experimentID, data.filename, data.claimToken); + } await markCompleted(docRef, data); console.log(`Successfully retried upload ${queueDoc.id} (${data.filename})`); return; } if (result.error === "NAME_CONFLICT") { + if (data.claimToken) { + await confirmClaim(data.experimentID, data.filename, data.claimToken); + } // File already exists — treat as success (original upload may have worked) await markCompleted(docRef, data); console.log(`Upload ${queueDoc.id} marked complete — file already exists in OSF.`); From 4e889f34157b4bf8df4bef7ac1f8bb2361a60b4a Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 22 Jul 2026 18:42:09 -0400 Subject: [PATCH 06/14] feat: track metadata file by provider ref instead of listing on every write Build step 3b of the provider migration; closes out build step 3. - putFileOSF parses the 201 response body; WriteResult.fileRef now carries the provider file id - metadata-block reads/stores metadataFileRef on metadata/{id} instead of listing the provider folder per submission. Three-state semantics: undefined = pre-migration doc, triggers one-time legacy discovery via listFiles; null = known absent, create without listing; ref = update directly. Stale refs self-heal (recreate + store new ref). Refs without a usable id are not stored, degrading to re-discovery instead of persisting an un-updatable ref - the latent 210 status check is removed: the firestore-has-metadata / provider-doesn't branch now succeeds instead of always throwing (the old emulator test masked this by asserting only metadataMessage, which was populated before the throw) - metadata-process.ts deleted (no remaining callers) along with its unit test; metadata-emulator's runTransaction block rewritten from the token-flip mock trick to direct structural seeding of the four firestore x provider metadata scenarios TDD: 9 unit/integration contract tests written and reviewed red first. Full emulator suite green (21 suites, 142 tests). Co-Authored-By: Claude Fable 5 --- .../src/__tests__/metadata-emulator.test.js | 264 ++++++++++--- .../src/__tests__/metadata-process.test.js | 41 -- .../__tests__/metadata-ref-emulator.test.js | 366 ++++++++++++++++++ .../src/__tests__/put-file-osf-ref.test.js | 194 ++++++++++ functions/src/metadata-block.ts | 143 ++++--- functions/src/metadata-process.ts | 48 --- functions/src/providers/osf.ts | 5 +- functions/src/put-file-osf.ts | 16 +- 8 files changed, 881 insertions(+), 196 deletions(-) delete mode 100644 functions/src/__tests__/metadata-process.test.js create mode 100644 functions/src/__tests__/metadata-ref-emulator.test.js create mode 100644 functions/src/__tests__/put-file-osf-ref.test.js delete mode 100644 functions/src/metadata-process.ts diff --git a/functions/src/__tests__/metadata-emulator.test.js b/functions/src/__tests__/metadata-emulator.test.js index 7cb6a73..9b47f35 100644 --- a/functions/src/__tests__/metadata-emulator.test.js +++ b/functions/src/__tests__/metadata-emulator.test.js @@ -1,10 +1,40 @@ /** * @jest-environment node */ -import { startServer } from '../../lib/mock-server.js' + +// Rewritten for step 3b (docs/provider-migration-design.md, +// scratchpad/step3b-metadata-ref-spec.md). +// +// The original `runTransaction` block simulated OSF metadata-file +// presence/absence by flipping a single shared test user's osfToken +// validity, which flipped mock-server.ts's fixed GET /endpoint response +// between "has dataset_description.json" and "doesn't" (see +// mock-server.ts's `bearerInvalid` branch). metadata-block.ts no longer +// lists the provider folder on every request -- presence/absence is now +// read from the metadataFileRef stored on the metadata doc -- so that +// token-validity trick no longer produces the scenario it used to. +// +// This rewrite seeds metadataFileRef directly (a ref object, or explicit +// null) instead of toggling token validity, and replaces mock-server.ts +// (which defines no PUT route at all -- see below) with a real inline +// mock OSF server, following the pattern already established in +// metadata-ref-emulator.test.js. +// +// Notably, mock-server.ts's complete absence of a PUT route means the two +// "not in OSF" scenarios in the original file (METADATA_NOT_IN_FIRESTORE_OR_OSF +// and METADATA_IN_FIRESTORE_NOT_IN_OSF) could only reach putFileOSF via a 404 +// from the mock. In the METADATA_IN_FIRESTORE_NOT_IN_OSF case this fed +// straight into the (now-removed) `status !== 210` bug in metadata-block.ts: +// the create branch always threw, and the request as a whole FAILED -- +// but the old test only asserted `response.metadataMessage`, never +// `response.success` or the HTTP status, so the failure was invisible. That +// gap is exactly how the 210 bug survived undetected; each rewritten test +// below asserts the HTTP status alongside metadataMessage to close it. import { initializeApp } from "firebase-admin/app"; import { getFirestore } from "firebase-admin/firestore"; -import MESSAGES from '../../lib/api-messages.js'; +import { randomUUID } from "crypto"; +import express from "express"; +import MESSAGES from "../../lib/api-messages.js"; process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; @@ -14,6 +44,8 @@ const config = { projectId: "datapipe-test", }; +const OWNER_ID = "metadata-matrix-owner"; + async function saveData(body) { const response = await fetch( "http://localhost:5001/datapipe-test/us-central1/apidata", @@ -27,102 +59,224 @@ async function saveData(body) { } ); const message = await response.json(); - return message; + return { status: response.status, body: message }; } const sampleData = `[{ "trial_type": "html-keyboard-response", "trial_index": 1, "time_elapsed": 776 -}]` +}]`; + +// A valid Psych-DS-shaped metadata object. metadata-update.ts's +// updateMetadata (real, unmocked) requires `variableMeasured`, and +// metadata-download.ts's downloadMetadata additionally requires the full +// Psych-DS field set -- both throw "Invalid ..." otherwise, which would fail +// these tests for the wrong reason (an unrelated throw, not the scenario +// under test). +const existingMetadata = { variableMeasured: [{ name: "existing_var" }] }; +const downloadedMetadata = { + name: "test-dataset", + schemaVersion: "Psych-DS 0.4.0", + "@context": "https://schema.org", + "@type": "Dataset", + description: "test dataset for download", + author: [{ name: "tester" }], + variableMeasured: [{ name: "downloaded_var" }], +}; -let mockServerInstance; +// A minimal, self-contained mock OSF "files" container, following the +// pattern established in metadata-ref-emulator.test.js: +// GET /files -> osfProvider.listFiles (collision-cache rehydration +// only in this file -- metadataFileRef is always +// seeded explicitly below, so metadata-block.ts's +// legacy-discovery listing never fires) +// GET /files/:id -> metadata-download.ts's downloadMetadata +// PUT /files -> create (putFileOSF / writeSessionFile) +// PUT /files/:id -> update (updateFileOSF / updateFile) +// mock-server.ts defines none of the PUT routes this needs, and is left +// untouched -- nothing else in the repo still imports it after this file +// stops doing so. +function createMockOSFServer() { + const app = express(); + let nextId = 1; + const createCallsByFilename = new Map(); + const updateCallsById = new Map(); + + app.get("/files", (req, res) => { + res.json({ data: [] }); + }); -beforeAll(async () => { + app.get("/files/:id", (req, res) => { + res.json(downloadedMetadata); + }); + + app.put("/files", (req, res) => { + const filename = String(req.query.name || ""); + createCallsByFilename.set(filename, (createCallsByFilename.get(filename) || 0) + 1); + const id = `mock-file-${nextId++}`; + res.status(201).json({ data: { id, attributes: { name: filename, kind: "file" } } }); + }); - mockServerInstance = await startServer(); + app.put("/files/:id", (req, res) => { + const id = req.params.id; + updateCallsById.set(id, (updateCallsById.get(id) || 0) + 1); + res.status(200).json({}); + }); + + return new Promise((resolve) => { + const server = app.listen(0, () => { + resolve({ + server, + port: server.address().port, + getCreateCount: (filename) => createCallsByFilename.get(filename) || 0, + getUpdateCount: (id) => updateCallsById.get(id) || 0, + // Every scenario below creates/updates a file literally named + // "dataset_description.json", so counts must be reset between + // tests -- otherwise a later test's assertion would include + // creates/updates left over from an earlier one. + resetCounts: () => { + createCallsByFilename.clear(); + updateCallsById.clear(); + }, + }); + }); + }); +} + +let db; +let mockOSF; +let filesLink; + +beforeAll(async () => { + mockOSF = await createMockOSFServer(); + filesLink = `http://localhost:${mockOSF.port}/files/`; initializeApp(config); - const db = getFirestore(); + db = getFirestore(); - await db.collection("experiments").doc('metadata-testexp').set({active: true, metadataActive: true, owner: 'test-user', osfFilesLink: "http://localhost:3000/endpoint"}); - await db.collection('users').doc('test-user').set({osfTokenValid: true, osfToken: 'valid', usingPersonalToken: true}); - await db.collection("metadata").doc('metadata-testexp').set({}); + await db.collection("users").doc(OWNER_ID).set({ + osfTokenValid: true, + osfToken: "valid", + usingPersonalToken: true, + }); +}); + +afterEach(() => { + mockOSF.resetCounts(); }); afterAll(async () => { - mockServerInstance.close(); - console.log('Server closed'); + mockOSF.server.close(); }); -describe('runTransaction', () => { +async function createExperiment(experimentID) { + await db.collection("experiments").doc(experimentID).set({ + active: true, + metadataActive: true, + owner: OWNER_ID, + osfFilesLink: filesLink, + }); +} - it('should handle the case when metadata is present in OSF but not in firestore', async () => { +describe("runTransaction", () => { + // Was: "should handle the case when metadata is present in OSF but not in + // firestore", driven by an invalid osfToken flipping mock-server.ts's fixed + // GET response to include a dataset_description.json entry. Now: a ref is + // seeded directly and no firestore `metadata` field exists, which is the + // same (metadataFileRef present, firestoreMetadata absent) pairing the old + // test intended. Newly asserted: status/success -- the old test only + // checked metadataMessage. + it("should handle the case when metadata is present in OSF but not in firestore", async () => { + const experimentID = `metadata-matrix1-${randomUUID()}`; + const refId = `ref1-${randomUUID()}`; + await createExperiment(experimentID); + await db.collection("metadata").doc(experimentID).set({ + metadataFileRef: { id: refId, name: "dataset_description.json" }, + }); const response = await saveData({ - experimentID: "metadata-testexp", + experimentID, data: sampleData, - filename: "test", + filename: `matrix1-${randomUUID()}.json`, }); - expect(response.metadataMessage).toEqual(MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE.metadataMessage); + expect(response.status).toBe(201); + expect(response.body.metadataMessage).toEqual(MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE.metadataMessage); + expect(mockOSF.getUpdateCount(refId)).toBe(1); }); - it('should handle the case when metadata is neither in firestore nor OSF', async () => { - - const db = getFirestore(); - await db.collection("users").doc("test-user").set({osfToken: 'invalid'}, {merge: true}); - - await db.collection("experiments").doc("metadata-testexp").get() - await db.collection("users").doc('test-user').get() - await db.collection("metadata").doc("metadata-testexp").get() + // Was: "should handle the case when metadata is neither in firestore nor + // OSF", driven by an invalid osfToken. Now: metadataFileRef is explicitly + // null (known absent -- this experiment's metadata doc was never + // populated) and there's no firestore `metadata` field either. Newly + // asserted: status/success, and that a create actually occurred. + it("should handle the case when metadata is neither in firestore nor OSF", async () => { + const experimentID = `metadata-matrix2-${randomUUID()}`; + await createExperiment(experimentID); + await db.collection("metadata").doc(experimentID).set({ metadataFileRef: null }); const response = await saveData({ - experimentID: "metadata-testexp", + experimentID, data: sampleData, - filename: "test", + filename: `matrix2-${randomUUID()}.json`, }); - console.log(response); - - // console.log(response); - - expect(response.metadataMessage).toEqual(MESSAGES.METADATA_NOT_IN_FIRESTORE_OR_OSF.metadataMessage); + expect(response.status).toBe(201); + expect(response.body.metadataMessage).toEqual(MESSAGES.METADATA_NOT_IN_FIRESTORE_OR_OSF.metadataMessage); + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(1); }); - it('should handle the case when metadata is in OSF and in firestore', async () => { - - const db = getFirestore(); - await db.collection("users").doc("test-user").set({osfToken: 'valid'}, {merge: true}); - await db.collection("metadata").doc("metadata-testexp").set({metadata: "test-metadata"}, {merge: true}); + // Was: "should handle the case when metadata is in OSF and in firestore", + // driven by a valid osfToken. Now: a ref plus firestore `metadata` are + // both seeded directly. Newly asserted: status/success, and that the + // ref'd file (not a fresh one) received the update. + it("should handle the case when metadata is in OSF and in firestore", async () => { + const experimentID = `metadata-matrix3-${randomUUID()}`; + const refId = `ref3-${randomUUID()}`; + await createExperiment(experimentID); + await db.collection("metadata").doc(experimentID).set({ + metadata: existingMetadata, + metadataFileRef: { id: refId, name: "dataset_description.json" }, + }); - // Call your function const response = await saveData({ - experimentID: "metadata-testexp", + experimentID, data: sampleData, - filename: "test", + filename: `matrix3-${randomUUID()}.json`, }); - // console.log(response); - - expect(response.metadataMessage).toEqual(MESSAGES.METADATA_IN_OSF_AND_FIRESTORE.metadataMessage); + expect(response.status).toBe(201); + expect(response.body.metadataMessage).toEqual(MESSAGES.METADATA_IN_OSF_AND_FIRESTORE.metadataMessage); + expect(mockOSF.getUpdateCount(refId)).toBe(1); }); - it('should handle the case when metadata is not in OSF but is in firestore', async () => { - const db = getFirestore(); - await db.collection("users").doc("test-user").set({osfToken: 'invalid'}, {merge: true}); - await db.collection("metadata").doc("metadata-testexp").set({metadata: "test-metadata"}, {merge: true}) + // Was: "should handle the case when metadata is not in OSF but is in + // firestore", driven by an invalid osfToken. This is the scenario the + // now-removed `status !== 210` bug always threw on: the old mock-server.ts + // has no PUT route at all, so the create attempt 404'd, the buggy check + // threw regardless, and the request FAILED end-to-end -- invisible to the + // old test because it asserted only `metadataMessage`, which the catch + // block still populated from the already-set-before-the-throw local + // variable. With the bug removed and a real create route in place, this + // now genuinely succeeds. Newly asserted: status 201 (was silently 400) + // and that a create occurred. + it("should handle the case when metadata is not in OSF but is in firestore", async () => { + const experimentID = `metadata-matrix4-${randomUUID()}`; + await createExperiment(experimentID); + await db.collection("metadata").doc(experimentID).set({ + metadata: existingMetadata, + metadataFileRef: null, + }); const response = await saveData({ - experimentID: "metadata-testexp", + experimentID, data: sampleData, - filename: "test", + filename: `matrix4-${randomUUID()}.json`, }); - // console.log(response); - - expect(response.metadataMessage).toEqual(MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF.metadataMessage); + expect(response.status).toBe(201); + expect(response.body.metadataMessage).toEqual(MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF.metadataMessage); + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(1); }); - }); - diff --git a/functions/src/__tests__/metadata-process.test.js b/functions/src/__tests__/metadata-process.test.js deleted file mode 100644 index 398ff8e..0000000 --- a/functions/src/__tests__/metadata-process.test.js +++ /dev/null @@ -1,41 +0,0 @@ -// Import the function to test -import processMetadata from "../../lib/metadata-process.js"; - -// Mock the fetch function -global.fetch = jest.fn(() => - Promise.resolve({ - json: () => Promise.resolve({ data: [{ attributes: { name: 'dataset_description.json' }, id: 'osfstorage/123' }] }), - }) -); - -describe('processMetadata', () => { - beforeEach(() => { - fetch.mockClear(); - }); - - it('returns metadataId when default metadata file is found', async () => { - const osfComponent = 'testComponent'; - const osfToken = 'testToken'; - - - - const result = await processMetadata(osfComponent, osfToken); - - expect(result).toEqual({ success: true, errorCode: null, errorText: null, metadataId: '123' }); - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it('returns error when metadata file is not found', async () => { - fetch.mockImplementationOnce(() => Promise.resolve({ - json: () => Promise.resolve({ data: [{ attributes: { name: 'other-file.json' }, id: 'osfstorage/123' }] }), - })); - - const osfComponent = 'testComponent'; - const osfToken = 'testToken'; - - const result = await processMetadata(osfComponent, osfToken); - - expect(result).toEqual({ success: false, errorCode: 404, errorText: 'Metadata file not found', metadataString: null }); - expect(fetch).toHaveBeenCalledTimes(1); - }); -}); \ No newline at end of file diff --git a/functions/src/__tests__/metadata-ref-emulator.test.js b/functions/src/__tests__/metadata-ref-emulator.test.js new file mode 100644 index 0000000..05ae5d2 --- /dev/null +++ b/functions/src/__tests__/metadata-ref-emulator.test.js @@ -0,0 +1,366 @@ +/** + * @jest-environment node + */ + +// RED-phase integration tests for step 3b (docs/provider-migration-design.md, +// scratchpad/step3b-metadata-ref-spec.md), cases 3-8 of the test plan. +// +// Follows the inline-mock-OSF-server pattern established by +// collision-integration-emulator.test.js: a self-contained express server +// started on an OS-assigned port (`listen(0)`), with in-process (not HTTP) +// controls for call counts and forced statuses, rather than extending the +// shared fixed-port mock-server.ts used by metadata-emulator.test.js. That +// server can only express a single fixed GET/PUT pair; this test plan needs +// a controllable listing body (case 5), an update route keyed by file id +// (cases 4/5/7), and forced statuses on specific ids (case 7's 404), none of +// which mock-server.ts supports. mock-server.ts itself is untouched. +// +// Until metadata-block.ts is rewritten to read/store metadataFileRef, most +// assertions below fail on missing/incorrect behavior -- metadataFileRef +// never appears on the metadata doc, the 210-bug branch always throws +// (case 6), stale refs are never retried via self-heal (case 7) -- rather +// than on any transport-level or setup problem. + +import { initializeApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import express from "express"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; + +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; +const OWNER_ID = "metadata-ref-owner"; + +const sampleData = `[{"trial_type":"html-keyboard-response","trial_index":1,"time_elapsed":776}]`; + +// A valid Psych-DS-shaped metadata object. metadata-update.ts's updateMetadata +// (the real, unmocked production function) reads `variableMeasured` and +// throws "Invalid metadata format" on anything else, so seeded firestore +// metadata must have this shape or these tests would fail for the wrong +// reason (an unrelated throw, not the behavior under test). +const existingMetadata = { variableMeasured: [{ name: "existing_var" }] }; + +async function saveData(body) { + const response = await fetch("http://localhost:5001/datapipe-test/us-central1/apidata", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "*/*" }, + body: JSON.stringify(body), + }); + const message = await response.json(); + return { status: response.status, body: message }; +} + +// A minimal, self-contained mock OSF "files" container: +// GET /files -> osfProvider.listFiles (?meta=) +// PUT /files -> osfProvider.writeSessionFile / putFileOSF (create) +// PUT /files/:id -> osfProvider.updateFile / updateFileOSF (update) +// filesLink is handed out with a trailing slash so that update-file-osf.ts's +// `${osfComponent}${fileId}` concatenation produces `/files/` rather than +// `/files`. +function createMockOSFServer() { + const app = express(); + + let listingContents = []; + let nextId = 1; + const createCallsByFilename = new Map(); + const updateCallsById = new Map(); + let listingCallCount = 0; + const forcedCreateStatus = new Map(); + const forcedUpdateStatus = new Map(); + + app.get("/files", (req, res) => { + listingCallCount += 1; + res.json({ data: listingContents }); + }); + + app.put("/files", (req, res) => { + const filename = String(req.query.name || ""); + createCallsByFilename.set(filename, (createCallsByFilename.get(filename) || 0) + 1); + + const forced = forcedCreateStatus.get(filename); + if (forced && forced !== 201) { + res.status(forced).json({ errors: [{ detail: `mock-forced-create-${forced}` }] }); + return; + } + + const id = `mock-file-${nextId++}`; + res.status(201).json({ data: { id, attributes: { name: filename, kind: "file" } } }); + }); + + app.put("/files/:id", (req, res) => { + const id = req.params.id; + updateCallsById.set(id, (updateCallsById.get(id) || 0) + 1); + + const forced = forcedUpdateStatus.get(id); + if (forced && forced !== 200) { + res.status(forced).json({ errors: [{ detail: `mock-forced-update-${forced}` }] }); + return; + } + + res.status(200).json({}); + }); + + return new Promise((resolve) => { + const server = app.listen(0, () => { + resolve({ + server, + port: server.address().port, + setListing: (files) => { + listingContents = files; + }, + getCreateCount: (filename) => createCallsByFilename.get(filename) || 0, + getUpdateCount: (id) => updateCallsById.get(id) || 0, + getTotalUpdateCount: () => + Array.from(updateCallsById.values()).reduce((a, b) => a + b, 0), + getListingCallCount: () => listingCallCount, + forceCreateStatus: (filename, status) => forcedCreateStatus.set(filename, status), + forceUpdateStatus: (id, status) => forcedUpdateStatus.set(id, status), + // Clears call counters only, keeping listing contents and forced + // statuses -- used mid-test to isolate a "second call" assertion + // from setup calls that came before it (see case 4). + resetCounts: () => { + createCallsByFilename.clear(); + updateCallsById.clear(); + listingCallCount = 0; + }, + // Full reset between tests: counters, listing contents, and forced + // statuses. + reset: () => { + listingContents = []; + createCallsByFilename.clear(); + updateCallsById.clear(); + listingCallCount = 0; + forcedCreateStatus.clear(); + forcedUpdateStatus.clear(); + }, + }); + }); + }); +} + +let db; +let mockOSF; +let filesLink; + +beforeAll(async () => { + mockOSF = await createMockOSFServer(); + filesLink = `http://localhost:${mockOSF.port}/files/`; + + initializeApp(config); + db = getFirestore(); + + await db.collection("users").doc(OWNER_ID).set({ + osfTokenValid: true, + osfToken: "valid", + usingPersonalToken: true, + }); +}); + +afterEach(() => { + mockOSF.reset(); +}); + +afterAll(async () => { + mockOSF.server.close(); +}); + +async function createExperiment(experimentID, overrides = {}) { + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + metadataActive: true, + owner: OWNER_ID, + osfFilesLink: filesLink, + ...overrides, + }); +} + +describe("3. first metadata-active submission (no metadata anywhere)", () => { + it("succeeds and stores metadata AND metadataFileRef with id + name", async () => { + const experimentID = `metadata-ref3-${randomUUID()}`; + const filename = `case3-${randomUUID()}.json`; + await createExperiment(experimentID); + // No metadata doc created at all -- firestoreMetadata and + // metadataFileRef are both genuinely absent. + mockOSF.setListing([]); // provider has no dataset_description.json either + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(201); + + const metadataDoc = (await db.collection("metadata").doc(experimentID).get()).data(); + expect(metadataDoc.metadata).toBeDefined(); + expect(metadataDoc.metadataFileRef).toBeDefined(); + expect(metadataDoc.metadataFileRef).not.toBeNull(); + expect(typeof metadataDoc.metadataFileRef.id).toBe("string"); + expect(metadataDoc.metadataFileRef.id.length).toBeGreaterThan(0); + expect(metadataDoc.metadataFileRef.name).toBe("dataset_description.json"); + + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(1); + }); +}); + +describe("4. second submission updates the existing metadata file via its stored ref", () => { + it("PUTs to the ref'd file id and makes no discovery listing call", async () => { + const experimentID = `metadata-ref4-${randomUUID()}`; + const existingMetaId = `preexisting-meta-${randomUUID()}`; + await createExperiment(experimentID); + + // Pre-seed a steady-state metadata doc: metadata AND metadataFileRef + // already established, as if a prior submission had already created + // them. This isolates "second submission" behavior without depending on + // case 3's flow. + await db.collection("metadata").doc(experimentID).set({ + metadata: existingMetadata, + metadataFileRef: { id: existingMetaId, name: "dataset_description.json" }, + }); + + // Warm the collision cache first: this experiment's very first + // submission triggers collision-cache rehydration (collision-cache.ts), + // which calls provider.listFiles once -- a GET entirely unrelated to + // metadata discovery, but indistinguishable from one on the wire (same + // endpoint). Run it here, then reset counters, so the assertion below + // about "no discovery listing" isn't polluted by this unrelated GET. + const warmResponse = await saveData({ + experimentID, + data: sampleData, + filename: `case4-warm-${randomUUID()}.json`, + }); + expect(warmResponse.status).toBe(201); + mockOSF.resetCounts(); + + const response = await saveData({ + experimentID, + data: sampleData, + filename: `case4-${randomUUID()}.json`, + }); + + expect(response.status).toBe(201); + expect(mockOSF.getUpdateCount(existingMetaId)).toBe(1); + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(0); + expect(mockOSF.getListingCallCount()).toBe(0); + }); +}); + +describe("5. legacy discovery: metadata doc has metadata but no metadataFileRef field", () => { + it("finds the existing dataset_description.json via listing, updates it (not create), and stores the discovered ref", async () => { + const experimentID = `metadata-ref5-${randomUUID()}`; + const legacyId = `legacy-meta-${randomUUID()}`; + await createExperiment(experimentID); + + // Pre-seed metadata WITHOUT a metadataFileRef field at all -- distinct + // from explicit null (case 6). This is the shape a pre-migration, + // still-active legacy experiment would have. + await db.collection("metadata").doc(experimentID).set({ metadata: existingMetadata }); + + mockOSF.setListing([ + { attributes: { name: "dataset_description.json", kind: "file" }, id: legacyId }, + { attributes: { name: "some-session-data.json", kind: "file" }, id: `${legacyId}-other` }, + ]); + + const response = await saveData({ + experimentID, + data: sampleData, + filename: `case5-${randomUUID()}.json`, + }); + + expect(response.status).toBe(201); + expect(mockOSF.getUpdateCount(legacyId)).toBe(1); + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(0); + + const metadataDoc = (await db.collection("metadata").doc(experimentID).get()).data(); + expect(metadataDoc.metadataFileRef).toEqual({ id: legacyId, name: "dataset_description.json" }); + }); +}); + +describe("6. 210-bug resurrection: metadataFileRef explicitly null, no metadata file on the provider", () => { + it("creates the metadata file and succeeds (previously always failed with METADATA_ERROR)", async () => { + const experimentID = `metadata-ref6-${randomUUID()}`; + await createExperiment(experimentID); + + // metadataFileRef is explicitly null here -- distinct from case 5's + // "field absent" -- meaning "we already looked, it's known absent; + // don't list again." + await db.collection("metadata").doc(experimentID).set({ + metadata: existingMetadata, + metadataFileRef: null, + }); + + mockOSF.setListing([]); // provider genuinely has no metadata file + + const response = await saveData({ + experimentID, + data: sampleData, + filename: `case6-${randomUUID()}.json`, + }); + + // Today, this always fails: metadata-block.ts's create branch checks + // `status !== 210` on a response that never returns 210, so it throws + // MESSAGES.OSF_UPLOAD_ERROR unconditionally, even though the upload + // itself succeeded. + expect(response.status).toBe(201); + + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(1); + + const metadataDoc = (await db.collection("metadata").doc(experimentID).get()).data(); + expect(metadataDoc.metadataFileRef).toBeDefined(); + expect(metadataDoc.metadataFileRef).not.toBeNull(); + expect(metadataDoc.metadataFileRef.name).toBe("dataset_description.json"); + }); +}); + +describe("7. self-heal: stored ref points at a file the mock 404s on update", () => { + it("creates a fresh metadata file and stores the new ref without failing the request", async () => { + const experimentID = `metadata-ref7-${randomUUID()}`; + const staleId = `stale-meta-${randomUUID()}`; + await createExperiment(experimentID); + + await db.collection("metadata").doc(experimentID).set({ + metadata: existingMetadata, + metadataFileRef: { id: staleId, name: "dataset_description.json" }, + }); + + mockOSF.forceUpdateStatus(staleId, 404); // provider-side file was deleted + + const response = await saveData({ + experimentID, + data: sampleData, + filename: `case7-${randomUUID()}.json`, + }); + + expect(response.status).toBe(201); + expect(mockOSF.getUpdateCount(staleId)).toBe(1); // the failed update attempt + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(1); // the self-heal create + + const metadataDoc = (await db.collection("metadata").doc(experimentID).get()).data(); + expect(metadataDoc.metadataFileRef).toBeDefined(); + expect(metadataDoc.metadataFileRef.id).not.toBe(staleId); + expect(metadataDoc.metadataFileRef.name).toBe("dataset_description.json"); + }); +}); + +describe("8. skip-metadata behavior (metadataActive false) is unchanged", () => { + it("does not create a metadata document or a metadataFileRef when metadataActive is false", async () => { + const experimentID = `metadata-ref8-${randomUUID()}`; + await createExperiment(experimentID, { metadataActive: false }); + + const response = await saveData({ + experimentID, + data: sampleData, + filename: `case8-${randomUUID()}.json`, + }); + + expect(response.status).toBe(201); + expect(response.body.metadataMessage).toBeFalsy(); + + const metadataDoc = await db.collection("metadata").doc(experimentID).get(); + expect(metadataDoc.exists).toBe(false); + + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(0); + expect(mockOSF.getTotalUpdateCount()).toBe(0); + }); +}); diff --git a/functions/src/__tests__/put-file-osf-ref.test.js b/functions/src/__tests__/put-file-osf-ref.test.js new file mode 100644 index 0000000..c59c295 --- /dev/null +++ b/functions/src/__tests__/put-file-osf-ref.test.js @@ -0,0 +1,194 @@ +// RED-phase unit tests for step 3b (docs/provider-migration-design.md, +// scratchpad/step3b-metadata-ref-spec.md), cases 1-2 of the test plan. +// +// putFileOSF currently discards the 201 response body entirely (see +// put-file-osf.ts: it never calls `.json()` on a successful upload). This +// step extends it to parse `{ data: { id, attributes: { name } } }` and +// surface `fileId`/`fileName`, and extends osfProvider.writeSessionFile to +// pass those through as `fileRef`/`storedFilename`. Written as a new file +// (providers-osf.test.js is left untouched per the RED-phase instructions) +// but following its mocking style exactly: put-file-osf.ts/update-file-osf.ts +// import their own `fetch` from "node-fetch" rather than using globalThis +// fetch, so "node-fetch" is mocked at the module level, and the compiled +// lib/ output is imported (same convention providers-osf.test.js and +// metadata-process.test.js use) rather than the .ts source. +const mockFetch = jest.fn(); + +jest.mock("node-fetch", () => ({ + __esModule: true, + default: (...args) => mockFetch(...args), +})); + +import putFileOSF from "../../lib/put-file-osf.js"; +import { osfProvider } from "../../lib/providers/osf.js"; + +function mockResponse({ status, statusText, retryAfter = null, jsonBody, jsonError }) { + return { + status, + statusText, + headers: { + get: (header) => (header === "Retry-After" ? retryAfter : null), + }, + json: () => (jsonError ? Promise.reject(jsonError) : Promise.resolve(jsonBody)), + }; +} + +describe("putFileOSF response parsing (case 1)", () => { + beforeEach(() => { + mockFetch.mockClear(); + }); + + it("parses fileId and fileName from a well-formed 201 body", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonBody: { data: { id: "osfstorage/abc123", attributes: { name: "file.json" } } }, + }) + ); + + const result = await putFileOSF("https://osf.io/abc123/", "test-token", "data", "file.json"); + + expect(result).toEqual({ + success: true, + errorCode: null, + errorText: null, + fileId: "osfstorage/abc123", + fileName: "file.json", + }); + }); + + it("tolerates a 201 body containing only attributes.name (no id)", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonBody: { data: { attributes: { name: "file.json" } } }, + }) + ); + + const result = await putFileOSF("https://osf.io/abc123/", "test-token", "data", "file.json"); + + expect(result.success).toBe(true); + expect(result.fileName).toBe("file.json"); + expect(result.fileId).toBeUndefined(); + }); + + it("tolerates a missing/unparseable body on a successful upload without throwing", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonError: new SyntaxError("Unexpected end of JSON input"), + }) + ); + + const result = await putFileOSF("https://osf.io/abc123/", "test-token", "data", "file.json"); + + expect(result.success).toBe(true); + expect(result.fileId).toBeUndefined(); + expect(result.fileName).toBeUndefined(); + }); + + it("does not attempt to parse a body on a non-201 (failure) response", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 409, + statusText: "Conflict", + // If implementation code ever called .json() on a failure response, + // this rejection would surface as an unhandled/uncaught test failure. + jsonError: new Error("json() should not be called on a failed upload"), + }) + ); + + const result = await putFileOSF("https://osf.io/abc123/", "test-token", "data", "file.json"); + + expect(result).toEqual({ + success: false, + errorCode: 409, + errorText: "Conflict", + retryAfter: null, + }); + }); +}); + +describe("osfProvider.writeSessionFile fileRef surfacing (case 2)", () => { + const auth = { token: "test-token" }; + const container = { provider: "osf", filesLink: "https://osf.io/abc123/" }; + + beforeEach(() => { + mockFetch.mockClear(); + }); + + it("surfaces fileRef.id/name parsed from the 201 body, and storedFilename from the parsed name", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonBody: { data: { id: "osfstorage/xyz789", attributes: { name: "renamed.json" } } }, + }) + ); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "requested.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: true, + fileRef: { name: "renamed.json", id: "osfstorage/xyz789" }, + storedFilename: "renamed.json", + }); + }); + + it("falls back to the requested filename when the body has no name, but keeps the parsed id", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonBody: { data: { id: "osfstorage/xyz789" } }, + }) + ); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "requested.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: true, + fileRef: { name: "requested.json", id: "osfstorage/xyz789" }, + storedFilename: "requested.json", + }); + }); + + it("falls back to the requested filename and an undefined id when the body is unparseable", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonError: new SyntaxError("Unexpected end of JSON input"), + }) + ); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "requested.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: true, + fileRef: { name: "requested.json", id: undefined }, + storedFilename: "requested.json", + }); + }); +}); diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index a57fff2..b87f4dc 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -1,5 +1,4 @@ import MESSAGES from "./api-messages.js"; -import processMetadata from "./metadata-process.js"; import updateMetadata from "./metadata-update.js"; import produceMetadata from "./metadata-production.js"; import downloadMetadata from "./metadata-download.js"; @@ -8,6 +7,7 @@ import { db } from "./app.js"; import { decrypt } from "./crypto-utils.js"; import { refreshAndUpdateUser } from "./refresh-token.js"; import { osfProvider } from "./providers/osf.js"; +import { FileRef } from "./providers/types.js"; import { ExperimentData, UserData, Metadata, MetadataResponse } from './interfaces'; @@ -56,63 +56,101 @@ try { //Retrieves the metadata from the Firestore metadata document. const firestoreMetadataObj: DocumentData | undefined = (await t.get(metadata_doc_ref)).data(); - + const firestoreMetadata: Metadata | undefined = firestoreMetadataObj ? firestoreMetadataObj.metadata : undefined; - - //Retrieves the metadata ID from the OSF metadata file. If an ID exists, then a metadata file with name: - //dataset_description.json exists in the OSF project. - const osfMetadataId: string | undefined = (await processMetadata(exp_data.osfFilesLink, decryptedOsfToken)).metadataId; - - //When firestore and OSF both have metadata, updating is done with respect to firestore. - //When firestore has metadata but OSF does not, updating is done with respect to firestore. - if ( (osfMetadataId && firestoreMetadata) || (!osfMetadataId && firestoreMetadata) ) { - - // Sets the metadata message. - if (osfMetadataId) metadataMessage = MESSAGES.METADATA_IN_OSF_AND_FIRESTORE; - else metadataMessage = MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF; - // Incoming metadata is used to update firestore metadata. - const updatedMetadata = await updateMetadata(firestoreMetadata, incomingMetadata); + // The metadata file's provider ref, tracked on the metadata doc. + // - undefined: the field has never been written (pre-migration doc, or + // no doc at all) — distinct from explicit null. + // - null: known absent — a prior request already looked and found + // nothing, so we must not list the provider folder again. + // - FileRef: a metadata file is known to exist at this id/name. + let metadataFileRef: FileRef | null | undefined = firestoreMetadataObj + ? (firestoreMetadataObj.metadataFileRef as FileRef | null | undefined) + : undefined; + + // Legacy discovery fallback: only runs once, for docs that predate ref + // tracking. Afterward the ref (possibly null) is stored so this never + // runs again for this experiment. + if (metadataFileRef === undefined) { + const providerFiles = await osfProvider.listFiles( + { token: decryptedOsfToken }, + { provider: "osf", filesLink: exp_data.osfFilesLink } + ); - t.update(metadata_doc_ref, {metadata: updatedMetadata}); + const found = providerFiles.find((file) => file.name === "dataset_description.json"); - //If a metadata file exists in OSF, it is updated with the above metadata. - if (osfMetadataId){ - await osfProvider.updateFile( + metadataFileRef = found ?? null; + + t.set(metadata_doc_ref, { metadataFileRef }, { merge: true }); + } + + // Creates a fresh dataset_description.json and stores the returned ref + // on the metadata doc. Used both for the "no ref" branches below and + // for self-healing a stale ref whose provider-side file is gone. + async function createMetadataFile(payload: object) { + const serialized = JSON.stringify(payload, null, 2); + + const response = await osfProvider.writeSessionFile( { token: decryptedOsfToken }, { provider: "osf", filesLink: exp_data.osfFilesLink }, - { id: osfMetadataId, name: "dataset_description.json" }, - JSON.stringify(updatedMetadata, null, 2), - { size: Buffer.byteLength(JSON.stringify(updatedMetadata, null, 2)), contentType: "application/json" } - ) + `dataset_description.json`, + serialized, + { size: Buffer.byteLength(serialized), contentType: "application/json" } + ); + if (!response.success) { + throw new Error(MESSAGES.OSF_UPLOAD_ERROR.message); } - //If a metadata file does not exist in OSF, it is created with the above metadata. - else { - const response = await osfProvider.writeSessionFile( + // Only track a ref we can actually use later. If the provider's 201 + // body was unparseable, fileRef.id is undefined — storing that would + // either fail the Firestore write or persist an un-updatable ref + // (whose self-heal re-create would then 409 forever). Leaving the + // field unset instead lets the next submission's legacy-discovery + // listing find the file and store a complete ref. + if (response.fileRef.id) { + t.set(metadata_doc_ref, { metadataFileRef: response.fileRef }, { merge: true }); + } + } + + //When a ref and firestore metadata both exist, updating is done with respect to firestore. + if (metadataFileRef && firestoreMetadata) { + + metadataMessage = MESSAGES.METADATA_IN_OSF_AND_FIRESTORE; + + // Incoming metadata is used to update firestore metadata. + const updatedMetadata = await updateMetadata(firestoreMetadata, incomingMetadata); + + t.update(metadata_doc_ref, {metadata: updatedMetadata}); + + const serialized = JSON.stringify(updatedMetadata, null, 2); + + try { + //The ref'd metadata file is updated with the above metadata. + await osfProvider.updateFile( { token: decryptedOsfToken }, { provider: "osf", filesLink: exp_data.osfFilesLink }, - `dataset_description.json`, - JSON.stringify(updatedMetadata, null, 2), - { size: Buffer.byteLength(JSON.stringify(updatedMetadata, null, 2)), contentType: "application/json" } + metadataFileRef, + serialized, + { size: Buffer.byteLength(serialized), contentType: "application/json" } ); - - // Latent pre-existing bug preserved verbatim — removed in build step 3 (see design doc, "Collision detection") - const status = response.success ? null : response.providerStatus; - if (status !== 210) throw new Error(MESSAGES.OSF_UPLOAD_ERROR.message); - + } catch (e) { + // Self-heal: the ref is stale (the file was deleted provider-side). + // Create a fresh metadata file and store its new ref rather than + // failing the whole request. + await createMetadataFile(updatedMetadata); } } - //When OSF has metadata but firestore does not, updating is done with respect to OSF. - if (osfMetadataId && !firestoreMetadata) { + //When a ref exists but firestore does not have metadata, updating is done with respect to OSF. + else if (metadataFileRef && !firestoreMetadata) { metadataMessage = MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE; //Metadata is downloaded from OSF, and is compared to incoming metadata to produce an updated version. // ********[IMPORTANT]*********** - // Since Metadata is in OSF as evidenced by the metadata ID, it is downloaded, and the type is asserted. - const downloadResponse = await downloadMetadata(exp_data.osfFilesLink, decryptedOsfToken, osfMetadataId); + // Since Metadata is in OSF as evidenced by the ref, it is downloaded, and the type is asserted. + const downloadResponse = await downloadMetadata(exp_data.osfFilesLink, decryptedOsfToken, metadataFileRef.id as string); const osfMetadata: Metadata = downloadResponse.metadata; @@ -125,14 +163,27 @@ try { await osfProvider.updateFile( { token: decryptedOsfToken }, { provider: "osf", filesLink: exp_data.osfFilesLink }, - { id: osfMetadataId, name: "dataset_description.json" }, + metadataFileRef, JSON.stringify(incomingMetadata, null, 2), { size: Buffer.byteLength(JSON.stringify(incomingMetadata, null, 2)), contentType: "application/json" } ); } - // When neither OSF nor firestore have metadata, the metadata is created in OSF and firestore. - if (!osfMetadataId && !firestoreMetadata) { + // When no ref exists but firestore has metadata, the metadata file is (re)created in OSF. + else if (!metadataFileRef && firestoreMetadata) { + + metadataMessage = MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF; + + // Incoming metadata is used to update firestore metadata. + const updatedMetadata = await updateMetadata(firestoreMetadata, incomingMetadata); + + t.update(metadata_doc_ref, {metadata: updatedMetadata}); + + //If a metadata file does not exist in OSF, it is created with the above metadata. + await createMetadataFile(updatedMetadata); + } + // When neither a ref nor firestore metadata exist, the metadata is created in OSF and firestore. + else { metadataMessage = MESSAGES.METADATA_NOT_IN_FIRESTORE_OR_OSF; @@ -140,13 +191,7 @@ try { t.set(metadata_doc_ref, {metadata: incomingMetadata}, {merge: true}); - await osfProvider.writeSessionFile( - { token: decryptedOsfToken }, - { provider: "osf", filesLink: exp_data.osfFilesLink }, - `dataset_description.json`, - JSON.stringify(incomingMetadata, null, 2), - { size: Buffer.byteLength(JSON.stringify(incomingMetadata, null, 2)), contentType: "application/json" } - ); + await createMetadataFile(incomingMetadata); } }); diff --git a/functions/src/metadata-process.ts b/functions/src/metadata-process.ts deleted file mode 100644 index 9839d21..0000000 --- a/functions/src/metadata-process.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { OSFFile } from './interfaces'; - -export default async function processMetadata( - osfComponent: string, - osfToken: string, -) { - //Gets the metadata of the data storage element in the OSF project. - try { - const osfResult = await fetch(`${osfComponent}?meta=`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${osfToken}`, - } - }); - - const folder = await osfResult.json(); //Gets the json portion of the response - - // The JSON portion has a property called 'data' that contains an array of objects, each of which - // corresponds to a data file in the OSF project. We access this array. - const listOfFiles: OSFFile[]= folder['data']; - - - // Every file object has an 'attributes' property which contains an object of information about the file, - // including a name property. We use this to find the file object of the metadata file. - const metadataFile: OSFFile[] = listOfFiles.filter((file) => file.attributes.name === `dataset_description.json`); - - // Return error if no file with the name 'dataset-description.json' is found. - if (metadataFile.length === 0) { - return { success: false, errorCode: 404, errorText: 'Metadata file not found', metadataString: null}; - } - - // Since filter returns a list, we access the first object and access the id property, which contains the - // unique id needed to access the file. The string comes with an osfstorage/ prefix that we remove. - const metadataId: string = metadataFile[0].id.replace('osfstorage/', ''); - - return { success: true, errorCode: null, errorText: null, metadataId: metadataId}; -} -catch (error) { - let errorMessage: string; - - if (error instanceof Error) errorMessage = error.message; - - else errorMessage = 'An unknown error occurred'; - - throw Error(`Error processing metadata with code: 400, and message: ${errorMessage}`)} -} - diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts index fabcfad..ad587fd 100644 --- a/functions/src/providers/osf.ts +++ b/functions/src/providers/osf.ts @@ -60,10 +60,11 @@ export const osfProvider: StorageProvider = { const result = await putFileOSF(osfContainer.filesLink, auth.token, data, filename); if (result.success) { + const storedFilename = result.fileName ?? filename; return { success: true, - fileRef: { name: filename }, - storedFilename: filename, + fileRef: { name: storedFilename, id: result.fileId }, + storedFilename, }; } diff --git a/functions/src/put-file-osf.ts b/functions/src/put-file-osf.ts index df87782..536fa26 100644 --- a/functions/src/put-file-osf.ts +++ b/functions/src/put-file-osf.ts @@ -51,5 +51,19 @@ export default async function putFileOSF( return { success: false, errorCode: osfResult.status, errorText: osfResult.statusText, retryAfter }; } - return { success: true, errorCode: null, errorText: null }; + // Parse the created-file reference out of the response body. Tolerate a + // missing/unparseable body — the upload itself already succeeded (status + // 201), so a body we can't read must never turn this into a failure. + let fileId: string | undefined; + let fileName: string | undefined; + try { + const body = (await osfResult.json()) as { data?: { id?: string; attributes?: { name?: string } } }; + fileId = body?.data?.id; + fileName = body?.data?.attributes?.name; + } catch { + fileId = undefined; + fileName = undefined; + } + + return { success: true, errorCode: null, errorText: null, fileId, fileName }; } From bc3c8a37310cf837ebb07cf046f5d52be84020e1 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 22 Jul 2026 19:31:41 -0400 Subject: [PATCH 07/14] feat: Google Drive adapter and full provider generalization of the write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build step 4a of the provider migration — the first non-OSF provider, proving the abstraction end-to-end for writes, collision cache, metadata, and the retry queue. - providers/gdrive.ts: app-created DataPipe root folder + per-experiment subfolders, hand-built multipart uploads, media-PATCH updates, paginated listFiles (fails loudly on listing errors — Drive has no 409 backstop, so rehydration completeness is load-bearing), alt=media downloads, and reason-based Drive error mapping (never NAME_CONFLICT: the collision cache is the only duplicate gate) - StorageProvider gains downloadFile; OSF adapter implements it and metadata-download.ts is deleted - resolveToken dispatches on storageProvider: gdrive path reads connectedAccounts.gdrive, refreshes via GDRIVE_TOKEN_URL with rotation, persists tokens encrypted; PROVIDER_NOT_CONNECTED added to api-messages - metadata-block fully provider-generalized (shared resolveToken replaces its duplicated inline OSF token logic); self-heal handles both provider failure styles but refuses to self-heal on AUTH_EXPIRED / RATE_LIMITED / QUOTA_EXCEEDED - queue docs carry storageProvider/providerContainer (legacy OSF fallback preserved); scheduled-upload-retry parameterized accordingly - GDRIVE_API_BASE/GDRIVE_TOKEN_URL env overrides (call-time reads); functions/.env.datapipe-test wires the emulator to the tests' mock Drive server TDD: 17 unit + 5 integration contract tests reviewed red first. Full emulator suite green (24 suites, 174 tests). Co-Authored-By: Claude Fable 5 --- functions/.env.datapipe-test | 2 + .../src/__tests__/gdrive-emulator.test.js | 533 +++++++++++++++ .../src/__tests__/providers-gdrive.test.js | 640 ++++++++++++++++++ .../__tests__/resolve-token-gdrive.test.js | 286 ++++++++ functions/src/api-base64.ts | 4 + functions/src/api-data.ts | 4 + functions/src/api-messages.ts | 4 + functions/src/interfaces.ts | 8 +- functions/src/metadata-block.ts | 147 ++-- functions/src/metadata-download.ts | 59 -- functions/src/providers/gdrive.ts | 370 ++++++++++ functions/src/providers/index.ts | 3 + functions/src/providers/osf.ts | 28 + functions/src/providers/types.ts | 21 + functions/src/queue-upload.ts | 32 +- functions/src/resolve-token.ts | 89 ++- functions/src/scheduled-upload-retry.ts | 22 +- 17 files changed, 2118 insertions(+), 134 deletions(-) create mode 100644 functions/.env.datapipe-test create mode 100644 functions/src/__tests__/gdrive-emulator.test.js create mode 100644 functions/src/__tests__/providers-gdrive.test.js create mode 100644 functions/src/__tests__/resolve-token-gdrive.test.js delete mode 100644 functions/src/metadata-download.ts create mode 100644 functions/src/providers/gdrive.ts diff --git a/functions/.env.datapipe-test b/functions/.env.datapipe-test new file mode 100644 index 0000000..aad45d6 --- /dev/null +++ b/functions/.env.datapipe-test @@ -0,0 +1,2 @@ +GDRIVE_API_BASE=http://127.0.0.1:3579 +GDRIVE_TOKEN_URL=http://127.0.0.1:3579/token diff --git a/functions/src/__tests__/gdrive-emulator.test.js b/functions/src/__tests__/gdrive-emulator.test.js new file mode 100644 index 0000000..1487e25 --- /dev/null +++ b/functions/src/__tests__/gdrive-emulator.test.js @@ -0,0 +1,533 @@ +/** + * @jest-environment node + */ + +// RED-phase integration tests for step 4a (docs/provider-migration-design.md, +// scratchpad/step4a-gdrive-adapter-spec.md), cases 12-16 of the test plan. +// +// These exercise the deployed-in-emulator apidata function end to end +// against a mock Google Drive server, following the fixed-port convention +// documented in the spec: a self-contained express server bound to +// 127.0.0.1:3579 (not an OS-assigned port), because functions/.env.datapipe- +// test (created alongside this file -- see the build-step instructions) sets +// GDRIVE_API_BASE=http://127.0.0.1:3579 for the *functions emulator process* +// to read. Verified empirically: starting `firebase emulators:exec --project +// datapipe-test` logs "functions: Loaded environment variables from .env, +// .env.datapipe-test." -- confirming firebase-tools' documented +// .env. loading (functions/node_modules/firebase-tools/lib/ +// functions/env.js's findEnvfiles: [".env", `.env.${projectId}`, ...]) picks +// this file up with no further plumbing needed. This is a genuinely fixed +// port (unlike collision-integration-emulator.test.js's listen(0) pattern) +// because the emulator-hosted gdrive adapter has no other way to discover +// where the mock server lives. +// +// None of cases 12-16 exercise real token encryption: the seeded +// connectedAccounts.gdrive.encryptedToken values below are bare plaintext +// strings, relying on crypto-utils.ts's decrypt() plaintext fallback (no +// "v1:" prefix -> returned unchanged) -- exactly like the existing OSF +// emulator tests seed osfToken: "valid". This sidesteps needing the jest +// process and the functions-emulator child process to agree on +// TOKEN_ENCRYPTION_KEY (they're different processes; only the latter loads +// functions/.env). The real encrypt/decrypt round-trip through a refresh is +// covered directly, in-process, by resolve-token-gdrive.test.js's case 9. +// +// Until gdrive.ts exists, is registered in providers/index.ts, and +// resolve-token.ts dispatches on storageProvider === "gdrive", every gdrive +// experiment request in cases 12-15 fails long before it reaches the mock +// server: getProviderForExperiment throws "Unknown storage provider: +// gdrive" (registry.ts already throws that for any unregistered id), so +// apidata's response is never the expected 201/202 -- a missing-behavior +// failure, not a mock-server/transport bug. Case 15 additionally depends on +// queue-upload.ts/api-data.ts passing storageProvider/providerContainer +// through to the queue doc (the "Queue generalization" section of the spec), +// which also doesn't exist yet. +// +// Case 16 is a regression guard: a legacy (storageProvider-less) OSF +// experiment, using the same OS-assigned-port inline-mock-OSF-server pattern +// as collision-integration-emulator.test.js. It is expected to PASS today -- +// the gdrive generalization must not be able to break it. + +import { initializeApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import express from "express"; +import MESSAGES from "../api-messages"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; + +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; +const GDRIVE_OWNER_ID = "gdrive-emulator-owner"; +const OSF_OWNER_ID = "gdrive-emulator-osf-owner"; +const FOLDER_MIME = "application/vnd.google-apps.folder"; +const DRIVE_PORT = 3579; + +const sampleData = `[{"trial_type":"html-keyboard-response","trial_index":1,"time_elapsed":776}]`; + +async function saveData(body) { + const response = await fetch("http://localhost:5001/datapipe-test/us-central1/apidata", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "*/*" }, + body: JSON.stringify(body), + }); + // An uncaught exception in the function (e.g. today's "Unknown storage + // provider: gdrive") produces a plain-text "Internal Server Error" body, + // not JSON. Parsing defensively keeps assertions on `status` failing + // cleanly (e.g. "Expected 201, Received 500") instead of the test itself + // throwing a SyntaxError out of response.json() -- that would obscure a + // real missing-behavior red as an apparent test-harness bug. + const text = await response.text(); + let message; + try { + message = JSON.parse(text); + } catch { + message = { rawBody: text }; + } + return { status: response.status, body: message }; +} + +// Parses a Drive `q` filter string just far enough to support the shapes +// this adapter is spec'd to send: a parent clause ('' in parents), an +// exact-name clause (name='...'), and an optional folder-mimeType clause. +function parseQuery(q) { + const nameMatch = /name\s*=\s*'([^']*)'/.exec(q || ""); + const parentMatch = /'([^']*)'\s+in\s+parents/.exec(q || ""); + const folderOnly = /mimeType\s*=\s*'application\/vnd\.google-apps\.folder'/.test(q || ""); + return { + name: nameMatch ? nameMatch[1] : null, + parent: parentMatch ? parentMatch[1] : null, + folderOnly, + }; +} + +// "regex or simple string split on the boundary is fine" per the spec -- +// this splits on the boundary and picks out whichever part declares itself +// application/json as the metadata part. +function parseMultipartMetadata(bodyBuffer, contentTypeHeader) { + const boundaryMatch = /boundary=("?)([^;"]+)\1/.exec(contentTypeHeader || ""); + if (!boundaryMatch) return null; + const boundary = boundaryMatch[2]; + const parts = bodyBuffer.toString("utf8").split(`--${boundary}`); + for (const rawPart of parts) { + const part = rawPart.replace(/^\r\n/, ""); + if (!part || part.startsWith("--")) continue; + if (!/Content-Type:\s*application\/json/i.test(part)) continue; + const sep = part.indexOf("\r\n\r\n"); + if (sep === -1) continue; + try { + return JSON.parse(part.slice(sep + 4).trim()); + } catch { + return null; + } + } + return null; +} + +// Companion to parseMultipartMetadata: extracts the *other* part (the +// payload being uploaded), so the mock can actually store it and serve it +// back via alt=media. +function parseMultipartDataPart(bodyBuffer, contentTypeHeader) { + const boundaryMatch = /boundary=("?)([^;"]+)\1/.exec(contentTypeHeader || ""); + if (!boundaryMatch) return { content: "", contentType: "application/octet-stream" }; + const boundary = boundaryMatch[2]; + const parts = bodyBuffer.toString("utf8").split(`--${boundary}`); + for (const rawPart of parts) { + const part = rawPart.replace(/^\r\n/, ""); + if (!part || part.startsWith("--")) continue; + if (/Content-Type:\s*application\/json/i.test(part)) continue; // that's the metadata part + const sep = part.indexOf("\r\n\r\n"); + if (sep === -1) continue; + const headerBlock = part.slice(0, sep); + const content = part.slice(sep + 4).replace(/\r\n$/, ""); + const ctMatch = /Content-Type:\s*([^\r\n]+)/i.exec(headerBlock); + return { content, contentType: ctMatch ? ctMatch[1].trim() : "application/octet-stream" }; + } + return { content: "", contentType: "application/octet-stream" }; +} + +// A self-contained mock Google Drive: a fixed-id filesById store, plus the +// counters/controls the build-step spec calls for (per-name upload +// counters, forceStatus(name-or-id, status), reset()). +function createMockDriveServer() { + const app = express(); + // A single raw-body parser regardless of Content-Type -- multipart/related + // and the media-PATCH's raw bytes both need the untouched body; JSON + // routes parse it themselves. + app.use(express.raw({ type: () => true, limit: "20mb" })); + + const filesById = new Map(); + const uploadCountsByName = new Map(); + const updateCountsById = new Map(); + const forcedStatus = new Map(); + let nextSeq = 1; + + function forcedFor(key) { + const status = forcedStatus.get(key); + return status && status !== 200 && status !== 201 ? status : null; + } + + app.get("/drive/v3/files", (req, res) => { + const { name, parent, folderOnly } = parseQuery(req.query.q); + let matches = Array.from(filesById.values()).filter((f) => { + if (parent && !f.parents.includes(parent)) return false; + if (name && f.name !== name) return false; + if (folderOnly && f.mimeType !== FOLDER_MIME) return false; + return true; + }); + matches.sort((a, b) => a.__seq - b.__seq); + + const pageSize = parseInt(req.query.pageSize, 10) || matches.length || 1; + const offset = req.query.pageToken ? parseInt(req.query.pageToken, 10) : 0; + const page = matches.slice(offset, offset + pageSize); + const nextPageToken = offset + pageSize < matches.length ? String(offset + pageSize) : undefined; + + const body = { files: page.map((f) => ({ id: f.id, name: f.name, mimeType: f.mimeType })) }; + if (nextPageToken) body.nextPageToken = nextPageToken; + res.status(200).json(body); + }); + + app.post("/drive/v3/files", (req, res) => { + let payload; + try { + payload = JSON.parse(req.body.toString("utf8")); + } catch { + res.status(400).json({ errors: [{ reason: "badRequest", message: "invalid JSON body" }] }); + return; + } + const forced = forcedFor(payload.name); + if (forced) { + res.status(forced).json({ errors: [{ reason: "mockForced", message: `mock-forced-status-${forced}` }] }); + return; + } + const id = `mock-folder-${nextSeq}`; + filesById.set(id, { + id, + name: payload.name, + mimeType: payload.mimeType, + parents: payload.parents || [], + content: "", + contentType: payload.mimeType, + __seq: nextSeq++, + }); + res.status(200).json({ id, name: payload.name }); + }); + + app.post("/upload/drive/v3/files", (req, res) => { + if (req.query.uploadType !== "multipart") { + res.status(400).json({ errors: [{ reason: "badRequest", message: "unsupported uploadType" }] }); + return; + } + const contentTypeHeader = req.headers["content-type"]; + const metadata = parseMultipartMetadata(req.body, contentTypeHeader); + if (!metadata || !metadata.name) { + res.status(400).json({ errors: [{ reason: "badRequest", message: "could not parse multipart metadata" }] }); + return; + } + uploadCountsByName.set(metadata.name, (uploadCountsByName.get(metadata.name) || 0) + 1); + + const forced = forcedFor(metadata.name); + if (forced) { + res.status(forced).json({ errors: [{ reason: "mockForced", message: `mock-forced-status-${forced}` }] }); + return; + } + + const { content, contentType } = parseMultipartDataPart(req.body, contentTypeHeader); + const id = `mock-file-${nextSeq}`; + filesById.set(id, { + id, + name: metadata.name, + mimeType: contentType, + parents: metadata.parents || [], + content, + contentType, + __seq: nextSeq++, + }); + res.status(200).json({ id, name: metadata.name }); + }); + + app.patch("/upload/drive/v3/files/:id", (req, res) => { + if (req.query.uploadType !== "media") { + res.status(400).json({ errors: [{ reason: "badRequest", message: "unsupported uploadType" }] }); + return; + } + const id = req.params.id; + updateCountsById.set(id, (updateCountsById.get(id) || 0) + 1); + + const forced = forcedFor(id); + if (forced) { + res.status(forced).json({ errors: [{ reason: "mockForced", message: `mock-forced-status-${forced}` }] }); + return; + } + + const contentType = req.headers["content-type"] || "application/octet-stream"; + const content = req.body.toString("utf8"); + const existing = filesById.get(id); + if (existing) { + existing.content = content; + existing.contentType = contentType; + } else { + filesById.set(id, { id, name: id, mimeType: contentType, parents: [], content, contentType, __seq: nextSeq++ }); + } + res.status(200).json({ id }); + }); + + app.get("/drive/v3/files/:id", (req, res) => { + if (req.query.alt !== "media") { + res.status(400).json({ errors: [{ reason: "badRequest", message: "unsupported alt" }] }); + return; + } + const id = req.params.id; + const forced = forcedFor(id); + if (forced) { + res.status(forced).json({ errors: [{ reason: "mockForced", message: `mock-forced-status-${forced}` }] }); + return; + } + const file = filesById.get(id); + if (!file) { + res.status(404).json({ errors: [{ reason: "notFound", message: "no such file" }] }); + return; + } + res.status(200).type(file.contentType || "text/plain").send(file.content); + }); + + // "For completeness" per the spec -- not exercised by cases 12-16 (every + // seeded gdrive token is unexpired), but GDRIVE_TOKEN_URL points here. + app.post("/token", (req, res) => { + res.status(200).json({ access_token: "mock-refreshed-token", expires_in: 3600, token_type: "Bearer" }); + }); + + return new Promise((resolve) => { + const server = app.listen(DRIVE_PORT, () => { + resolve({ + server, + port: DRIVE_PORT, + getUploadCount: (name) => uploadCountsByName.get(name) || 0, + getUpdateCount: (id) => updateCountsById.get(id) || 0, + forceStatus: (nameOrId, status) => forcedStatus.set(nameOrId, status), + reset: () => { + filesById.clear(); + uploadCountsByName.clear(); + updateCountsById.clear(); + forcedStatus.clear(); + nextSeq = 1; + }, + }); + }); + }); +} + +// Minimal inline mock OSF server for case 16's regression guard, matching +// the OS-assigned-port pattern established by +// collision-integration-emulator.test.js (listen(0), in-process counters). +function createMockOSFServer() { + const app = express(); + const uploadCountsByFilename = new Map(); + + app.get("/files", (req, res) => { + res.json({ data: [] }); + }); + + app.put("/files", (req, res) => { + const filename = String(req.query.name || ""); + uploadCountsByFilename.set(filename, (uploadCountsByFilename.get(filename) || 0) + 1); + res.status(201).json({ + data: { attributes: { name: filename, kind: "file" }, id: "osfstorage/mock-upload" }, + }); + }); + + return new Promise((resolve) => { + const server = app.listen(0, () => { + resolve({ + server, + port: server.address().port, + getUploadCount: (filename) => uploadCountsByFilename.get(filename) || 0, + reset: () => uploadCountsByFilename.clear(), + }); + }); + }); +} + +let db; +let mockDrive; + +beforeAll(async () => { + mockDrive = await createMockDriveServer(); + + initializeApp(config); + db = getFirestore(); + + await db.collection("users").doc(GDRIVE_OWNER_ID).set({ + connectedAccounts: { + gdrive: { + authMethod: "oauth2", + encryptedToken: "gdrive-integration-token", // plaintext fallback, see header comment + encryptedRefreshToken: "gdrive-integration-refresh", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + providerAccountId: "gdrive-integration-acct", + }, + }, + }); +}); + +afterEach(() => { + mockDrive.reset(); +}); + +afterAll(() => { + mockDrive.server.close(); +}); + +async function createGdriveExperiment(experimentID, folderId, overrides = {}) { + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + metadataActive: false, + owner: GDRIVE_OWNER_ID, + storageProvider: "gdrive", + providerContainer: { provider: "gdrive", folderId }, + ...overrides, + }); +} + +describe("12. gdrive experiment: apidata POST succeeds and warms the collision cache", () => { + it("returns 201, records the upload in the mock, and leaves the experiment's collisionCache warm", async () => { + const experimentID = `gdrive-int12-${randomUUID()}`; + const folderId = `folder-${randomUUID()}`; + const filename = `case12-${randomUUID()}.json`; + await createGdriveExperiment(experimentID, folderId); + + const before = Date.now(); + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(201); + expect(mockDrive.getUploadCount(filename)).toBe(1); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(expDataAfter.collisionCache).toBeDefined(); + expect(typeof expDataAfter.collisionCache.salt).toBe("string"); + expect(expDataAfter.collisionCache.warmUntil.toMillis()).toBeGreaterThan(before); + }); +}); + +describe("13. gdrive experiment: duplicate filename is rejected without a second provider upload", () => { + it("first POST succeeds, second POST for the same filename gets OSF_FILE_EXISTS, and the mock received exactly one upload", async () => { + const experimentID = `gdrive-int13-${randomUUID()}`; + const folderId = `folder-${randomUUID()}`; + const filename = `dup-${randomUUID()}.json`; + await createGdriveExperiment(experimentID, folderId); + + const first = await saveData({ experimentID, data: sampleData, filename }); + expect(first.status).toBe(201); + + const second = await saveData({ experimentID, data: sampleData, filename }); + expect(second.status).toBe(400); + // Message key is a historical "OSF_FILE_EXISTS" name (unchanged: the + // collision-cache duplicate check is entirely provider-agnostic in + // api-data.ts), not a claim that gdrive itself returned an OSF error. + expect(second.body).toEqual({ ...MESSAGES.OSF_FILE_EXISTS, metadataMessage: "" }); + + expect(mockDrive.getUploadCount(filename)).toBe(1); + }); +}); + +describe("14. metadata on gdrive", () => { + it("first submission creates dataset_description.json via multipart and stores the ref; second submission media-PATCHes that ref'd id with no second create", async () => { + const experimentID = `gdrive-int14-${randomUUID()}`; + const folderId = `folder-${randomUUID()}`; + await createGdriveExperiment(experimentID, folderId, { metadataActive: true }); + + const first = await saveData({ + experimentID, + data: sampleData, + filename: `case14-a-${randomUUID()}.json`, + }); + expect(first.status).toBe(201); + expect(mockDrive.getUploadCount("dataset_description.json")).toBe(1); + + const metadataDocAfterFirst = (await db.collection("metadata").doc(experimentID).get()).data(); + expect(metadataDocAfterFirst.metadataFileRef).toBeDefined(); + expect(metadataDocAfterFirst.metadataFileRef).not.toBeNull(); + const refId = metadataDocAfterFirst.metadataFileRef.id; + expect(typeof refId).toBe("string"); + expect(refId.length).toBeGreaterThan(0); + + const second = await saveData({ + experimentID, + data: sampleData, + filename: `case14-b-${randomUUID()}.json`, + }); + expect(second.status).toBe(201); + + expect(mockDrive.getUploadCount("dataset_description.json")).toBe(1); // still just the one create + expect(mockDrive.getUpdateCount(refId)).toBe(1); // the media PATCH from the second submission + }); +}); + +describe("15. provider failure queues the upload and tags it with the gdrive container", () => { + it("a mock-forced 500 on upload results in a 202 queued response, with the queue doc carrying claimToken + storageProvider + providerContainer", async () => { + const experimentID = `gdrive-int15-${randomUUID()}`; + const folderId = `folder-${randomUUID()}`; + const filename = `case15-${randomUUID()}.json`; + await createGdriveExperiment(experimentID, folderId); + + mockDrive.forceStatus(filename, 500); + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(202); + expect(response.body).toEqual(expect.objectContaining({ ...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage: "" })); + + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + const queueDoc = await db.collection("uploadQueue").doc(docId).get(); + expect(queueDoc.exists).toBe(true); + + const queueData = queueDoc.data(); + expect(typeof queueData.claimToken).toBe("string"); + expect(queueData.claimToken.length).toBeGreaterThan(0); + expect(queueData.storageProvider).toBe("gdrive"); + expect(queueData.providerContainer).toEqual({ provider: "gdrive", folderId }); + }); +}); + +describe("16. OSF experiment in the same run still works (legacy dispatch untouched, regression guard)", () => { + let mockOSF; + + beforeAll(async () => { + mockOSF = await createMockOSFServer(); + await db.collection("users").doc(OSF_OWNER_ID).set({ + osfTokenValid: true, + osfToken: "valid", + usingPersonalToken: true, + }); + }); + + afterEach(() => { + mockOSF.reset(); + }); + + afterAll(() => { + mockOSF.server.close(); + }); + + it("apidata POST for a legacy (storageProvider-less) OSF experiment succeeds normally -- expected to PASS today", async () => { + const experimentID = `gdrive-int16-${randomUUID()}`; + const filename = `case16-${randomUUID()}.json`; + const filesLink = `http://localhost:${mockOSF.port}/files/`; + + await db.collection("experiments").doc(experimentID).set({ + active: true, + metadataActive: false, + owner: OSF_OWNER_ID, + osfFilesLink: filesLink, + // storageProvider deliberately absent -- legacy dispatch. + }); + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(201); + expect(mockOSF.getUploadCount(filename)).toBe(1); + }); +}); diff --git a/functions/src/__tests__/providers-gdrive.test.js b/functions/src/__tests__/providers-gdrive.test.js new file mode 100644 index 0000000..499a127 --- /dev/null +++ b/functions/src/__tests__/providers-gdrive.test.js @@ -0,0 +1,640 @@ +// RED-phase unit tests for step 4a (docs/provider-migration-design.md, +// scratchpad/step4a-gdrive-adapter-spec.md), cases 1-7 of the test plan. +// +// functions/src/providers/gdrive.ts does not exist yet, so the import below +// fails at module resolution -- EVERY test in this file (including the osf +// sub-test inside case 7) fails as a collateral "Cannot find module +// .../lib/providers/gdrive.js" error until the adapter is implemented. Once +// gdrive.ts exists, the osf.downloadFile sub-test in case 7 is expected to +// keep failing on its own, different (and correct) ground: osf.ts has no +// downloadFile method yet either (that's also new in this step -- see the +// "downloadFile" interface addition in the spec) -- so it fails with +// "osfProvider.downloadFile is not a function", a missing-behavior failure +// distinct from the module-not-found failures affecting cases 1-6. +// +// Style follows providers-osf.test.js: node-fetch is imported by name inside +// the (future) gdrive.ts adapter module, same as osf.ts's put-file-osf.ts / +// update-file-osf.ts, so "node-fetch" is mocked at the module level and the +// compiled lib/ output is imported rather than the .ts source. +// +// GDRIVE_API_BASE is read at CALL time (not module load, per the spec), so +// this file pins it to a distinctive, non-default sentinel value in +// beforeAll/afterAll -- this both keeps assertions independent of whatever +// the real default happens to be, and forces the implementation to actually +// read process.env.GDRIVE_API_BASE rather than hardcoding +// https://www.googleapis.com. +const mockFetch = jest.fn(); + +jest.mock("node-fetch", () => ({ + __esModule: true, + default: (...args) => mockFetch(...args), +})); + +import { gdriveProvider } from "../../lib/providers/gdrive.js"; +import { osfProvider } from "../../lib/providers/osf.js"; + +const API_BASE = "https://gdrive.mock.test"; + +const ORIGINAL_GDRIVE_API_BASE = process.env.GDRIVE_API_BASE; + +beforeAll(() => { + process.env.GDRIVE_API_BASE = API_BASE; +}); + +afterAll(() => { + process.env.GDRIVE_API_BASE = ORIGINAL_GDRIVE_API_BASE; +}); + +beforeEach(() => { + mockFetch.mockClear(); +}); + +const auth = { token: "test-token" }; + +function mockResponse({ status, statusText, retryAfter = null, jsonBody, textBody }) { + return { + status, + statusText, + headers: { + get: (header) => (header === "Retry-After" ? retryAfter : null), + }, + json: () => Promise.resolve(jsonBody), + text: () => Promise.resolve(textBody), + }; +} + +// Case-insensitive header lookup -- the exact casing of the headers object +// gdrive.ts builds isn't spec'd beyond "Authorization"/"Content-Type" style +// (mirrored from osf.ts), so tests look up by name rather than assume a key. +function header(headers, name) { + if (!headers) return undefined; + const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase()); + return key ? headers[key] : undefined; +} + +function extractBoundary(contentType) { + const match = /boundary=("?)([^;"]+)\1/.exec(contentType || ""); + return match ? match[2] : null; +} + +function callArgs(index = 0) { + const [url, options] = mockFetch.mock.calls[index]; + return { url, options }; +} + +describe("1. writeSessionFile success", () => { + it("POSTs a multipart/related upload containing both the JSON metadata and the payload, and parses the returned fileRef", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { id: "gdrive-file-1", name: "file.json" }, + }) + ); + + const container = { provider: "gdrive", folderId: "folder-abc" }; + const result = await gdriveProvider.writeSessionFile( + auth, + container, + "file.json", + "a,b,c\n1,2,3", + { size: 11, contentType: "text/csv" } + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + + expect(url).toBe(`${API_BASE}/upload/drive/v3/files?uploadType=multipart`); + expect(options.method).toBe("POST"); + expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); + + const contentType = header(options.headers, "Content-Type"); + expect(contentType).toMatch(/^multipart\/related; boundary=/); + const boundary = extractBoundary(contentType); + expect(boundary).toBeTruthy(); + + const body = options.body.toString(); + expect(body).toContain(`--${boundary}`); + expect(body).toContain('"name":"file.json"'); + expect(body).toContain('"parents":["folder-abc"]'); + expect(body).toContain("application/json; charset=UTF-8"); + expect(body).toContain("text/csv"); + expect(body).toContain("a,b,c\n1,2,3"); + // Metadata part must precede the data part. + expect(body.indexOf('"name":"file.json"')).toBeLessThan(body.indexOf("a,b,c\n1,2,3")); + + expect(result).toEqual({ + success: true, + fileRef: { id: "gdrive-file-1", name: "file.json" }, + storedFilename: "file.json", + }); + }); + + it("also treats a 201 response as success (per the '200/201' contract)", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonBody: { id: "gdrive-file-2", name: "file2.json" }, + }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file2.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: true, + fileRef: { id: "gdrive-file-2", name: "file2.json" }, + storedFilename: "file2.json", + }); + }); + + it("falls back to the requested filename as storedFilename when the response omits name", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { id: "gdrive-file-3" }, + }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file3.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: true, + fileRef: { id: "gdrive-file-3", name: undefined }, + storedFilename: "file3.json", + }); + }); +}); + +describe("2. writeSessionFile subfolder", () => { + it("finds-or-creates the subfolder by name under the container, then uploads parented to it", async () => { + // 1) folder query -- absent + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { files: [] } })); + // 2) folder create + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "sub-folder-id", name: "sub" } }) + ); + // 3) upload into the new subfolder + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "gdrive-file-4", name: "file.csv" } }) + ); + + const container = { provider: "gdrive", folderId: "folder-abc" }; + const result = await gdriveProvider.writeSessionFile(auth, container, "sub/file.csv", "csv-data", { + size: 8, + contentType: "text/csv", + }); + + expect(mockFetch).toHaveBeenCalledTimes(3); + + const findCall = callArgs(0); + const findUrl = new URL(findCall.url); + expect(findCall.options.method).toBe("GET"); + expect(findUrl.searchParams.get("q")).toContain("name='sub'"); + expect(findUrl.searchParams.get("q")).toContain("'folder-abc' in parents"); + expect(findUrl.searchParams.get("q")).toContain("mimeType='application/vnd.google-apps.folder'"); + expect(findUrl.searchParams.get("q")).toContain("trashed=false"); + + const createCall = callArgs(1); + expect(createCall.options.method).toBe("POST"); + expect(JSON.parse(createCall.options.body)).toEqual({ + name: "sub", + mimeType: "application/vnd.google-apps.folder", + parents: ["folder-abc"], + }); + + const uploadCall = callArgs(2); + expect(uploadCall.url).toBe(`${API_BASE}/upload/drive/v3/files?uploadType=multipart`); + const uploadBody = uploadCall.options.body.toString(); + expect(uploadBody).toContain('"name":"file.csv"'); + expect(uploadBody).toContain('"parents":["sub-folder-id"]'); + expect(uploadBody).toContain("csv-data"); + + expect(result).toEqual({ + success: true, + fileRef: { id: "gdrive-file-4", name: "file.csv" }, + storedFilename: "file.csv", + }); + }); + + it("uploads directly into an existing subfolder without creating a duplicate", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { files: [{ id: "existing-sub-id", name: "sub", mimeType: "application/vnd.google-apps.folder" }] }, + }) + ); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "gdrive-file-5", name: "file2.csv" } }) + ); + + const container = { provider: "gdrive", folderId: "folder-abc" }; + await gdriveProvider.writeSessionFile(auth, container, "sub/file2.csv", "csv-data-2", { + size: 10, + contentType: "text/csv", + }); + + // Exactly 2 calls: the find query, then the upload. No create call. + expect(mockFetch).toHaveBeenCalledTimes(2); + const uploadBody = callArgs(1).options.body.toString(); + expect(uploadBody).toContain('"parents":["existing-sub-id"]'); + }); +}); + +describe("3. error mapping", () => { + it("maps 401 to AUTH_EXPIRED", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 401, statusText: "Unauthorized" })); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 401, + providerMessage: "Unauthorized", + retryAfter: null, + }); + }); + + it("maps 403 storageQuotaExceeded to QUOTA_EXCEEDED", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 403, + statusText: "Forbidden", + jsonBody: { errors: [{ reason: "storageQuotaExceeded", message: "quota" }] }, + }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "QUOTA_EXCEEDED", + providerStatus: 403, + providerMessage: "Forbidden", + retryAfter: null, + }); + }); + + it("maps 403 userRateLimitExceeded to RATE_LIMITED", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 403, + statusText: "Forbidden", + jsonBody: { errors: [{ reason: "userRateLimitExceeded", message: "slow down" }] }, + }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "RATE_LIMITED", + providerStatus: 403, + providerMessage: "Forbidden", + retryAfter: null, + }); + }); + + it("maps any other 403 reason to AUTH_EXPIRED", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 403, + statusText: "Forbidden", + jsonBody: { errors: [{ reason: "insufficientFilePermissions", message: "nope" }] }, + }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 403, + providerMessage: "Forbidden", + retryAfter: null, + }); + }); + + it("maps 429 to RATE_LIMITED and passes through Retry-After", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 429, statusText: "Too Many Requests", retryAfter: "15" }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "RATE_LIMITED", + providerStatus: 429, + providerMessage: "Too Many Requests", + retryAfter: 15, + }); + }); + + it("maps 500 to UNAVAILABLE", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 500, statusText: "Internal Server Error" })); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "UNAVAILABLE", + providerStatus: 500, + providerMessage: "Internal Server Error", + retryAfter: null, + }); + }); +}); + +describe("4. listFiles pagination", () => { + it("follows nextPageToken until exhausted, concatenates results, filters out folders, and both requests carry the same q filter", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { + nextPageToken: "page2tok", + files: [ + { id: "f1", name: "a.csv", mimeType: "text/csv" }, + { id: "folder1", name: "subdir", mimeType: "application/vnd.google-apps.folder" }, + ], + }, + }) + ); + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { files: [{ id: "f2", name: "b.csv", mimeType: "text/csv" }] }, + }) + ); + + const container = { provider: "gdrive", folderId: "folder-xyz" }; + const result = await gdriveProvider.listFiles(auth, container); + + expect(result).toEqual([ + { name: "a.csv", id: "f1" }, + { name: "b.csv", id: "f2" }, + ]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + const url1 = new URL(callArgs(0).url); + const url2 = new URL(callArgs(1).url); + + expect(url1.searchParams.get("q")).toBe("'folder-xyz' in parents and trashed=false"); + expect(url2.searchParams.get("q")).toBe(url1.searchParams.get("q")); + expect(url1.searchParams.get("fields")).toBe("nextPageToken,files(id,name,mimeType)"); + expect(url1.searchParams.get("pageSize")).toBe("1000"); + + // The defining pagination assertion: only the second request carries the + // page token from the first response. + expect(url1.searchParams.get("pageToken")).toBeNull(); + expect(url2.searchParams.get("pageToken")).toBe("page2tok"); + }); + + it("stops after a single page when no nextPageToken is returned", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { files: [{ id: "f1", name: "only.csv", mimeType: "text/csv" }] }, + }) + ); + + const result = await gdriveProvider.listFiles(auth, { provider: "gdrive", folderId: "folder-solo" }); + + expect(result).toEqual([{ name: "only.csv", id: "f1" }]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws (never returns a partial/empty list) when the listing request fails", async () => { + // Load-bearing for collision-cache rehydration: Drive has no 409 + // backstop, so a swallowed listing failure would warm the cache empty + // and silently accept duplicate filenames. The throw is what surfaces + // as CollisionCacheUnavailableError upstream. + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 401, + statusText: "Unauthorized", + jsonBody: { error: { message: "Invalid Credentials" } }, + }) + ); + + await expect( + gdriveProvider.listFiles(auth, { provider: "gdrive", folderId: "folder-err" }) + ).rejects.toThrow(/listing failed/i); + }); +}); + +describe("5. updateFile", () => { + it("PATCHes the media upload endpoint keyed by the ref id and returns a WriteResult on success", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK" })); + + const existingFileRef = { id: "gdrive-existing-1", name: "data.json" }; + const result = await gdriveProvider.updateFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + existingFileRef, + "updated-data", + { size: 12, contentType: "application/json" } + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + expect(url).toBe(`${API_BASE}/upload/drive/v3/files/gdrive-existing-1?uploadType=media`); + expect(options.method).toBe("PATCH"); + expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); + expect(options.body).toBe("updated-data"); + + expect(result).toEqual({ + success: true, + fileRef: existingFileRef, + storedFilename: "data.json", + }); + }); + + it("returns a failure WriteResult (does not throw) when the PATCH fails", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 404, statusText: "Not Found" })); + + const existingFileRef = { id: "gdrive-stale-1", name: "data.json" }; + + await expect( + gdriveProvider.updateFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + existingFileRef, + "updated-data", + { size: 12, contentType: "application/json" } + ) + ).resolves.toEqual({ + success: false, + error: "UNAVAILABLE", + providerStatus: 404, + providerMessage: "Not Found", + retryAfter: null, + }); + }); +}); + +describe("6. createDataContainer", () => { + it("only creates the child folder when the DataPipe root already exists", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { + files: [{ id: "root-existing-id", name: "DataPipe", mimeType: "application/vnd.google-apps.folder" }], + }, + }) + ); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "child-id-A", name: "My Experiment" } }) + ); + + const result = await gdriveProvider.createDataContainer(auth, { name: "My Experiment" }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + const findUrl = new URL(callArgs(0).url); + expect(findUrl.searchParams.get("q")).toContain("name='DataPipe'"); + expect(findUrl.searchParams.get("q")).toContain("'root' in parents"); + expect(findUrl.searchParams.get("q")).toContain("mimeType='application/vnd.google-apps.folder'"); + expect(findUrl.searchParams.get("q")).toContain("trashed=false"); + + expect(JSON.parse(callArgs(1).options.body)).toEqual({ + name: "My Experiment", + mimeType: "application/vnd.google-apps.folder", + parents: ["root-existing-id"], + }); + + expect(result).toEqual({ provider: "gdrive", folderId: "child-id-A" }); + }); + + it("creates the DataPipe root first, then the child, when the root is absent", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { files: [] } })); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "root-new-id", name: "DataPipe" } }) + ); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "child-id-B", name: "My Experiment 2" } }) + ); + + const result = await gdriveProvider.createDataContainer(auth, { name: "My Experiment 2" }); + + expect(mockFetch).toHaveBeenCalledTimes(3); + + expect(JSON.parse(callArgs(1).options.body)).toEqual({ + name: "DataPipe", + mimeType: "application/vnd.google-apps.folder", + parents: ["root"], + }); + expect(JSON.parse(callArgs(2).options.body)).toEqual({ + name: "My Experiment 2", + mimeType: "application/vnd.google-apps.folder", + parents: ["root-new-id"], + }); + + expect(result).toEqual({ provider: "gdrive", folderId: "child-id-B" }); + }); +}); + +describe("7. downloadFile", () => { + it("gdrive: GETs the alt=media endpoint and returns the body text as content", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", textBody: "file body text" })); + + const container = { provider: "gdrive", folderId: "folder-abc" }; + const result = await gdriveProvider.downloadFile(auth, container, { id: "gdrive-file-9", name: "data.json" }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + expect(url).toBe(`${API_BASE}/drive/v3/files/gdrive-file-9?alt=media`); + expect(options.method).toBe("GET"); + expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); + + expect(result).toEqual({ success: true, content: "file body text" }); + }); + + it("gdrive: maps a 401 on download the same way as writes (shared mapDriveError)", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 401, statusText: "Unauthorized" })); + + const result = await gdriveProvider.downloadFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + { id: "gdrive-file-10", name: "data.json" } + ); + + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 401, + providerMessage: "Unauthorized", + }); + }); + + it("osf: GETs filesLink+id and returns the body text as content", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", textBody: "osf file content" })); + + const container = { provider: "osf", filesLink: "https://osf.io/abc123/" }; + const result = await osfProvider.downloadFile(auth, container, { id: "osfstorage/111", name: "data.json" }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + expect(url).toBe("https://osf.io/abc123/osfstorage/111"); + expect(options.method).toBe("GET"); + expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); + + expect(result).toEqual({ success: true, content: "osf file content" }); + }); +}); diff --git a/functions/src/__tests__/resolve-token-gdrive.test.js b/functions/src/__tests__/resolve-token-gdrive.test.js new file mode 100644 index 0000000..4803c1c --- /dev/null +++ b/functions/src/__tests__/resolve-token-gdrive.test.js @@ -0,0 +1,286 @@ +/** + * @jest-environment node + */ + +// RED-phase unit tests for step 4a (docs/provider-migration-design.md, +// scratchpad/step4a-gdrive-adapter-spec.md), cases 8-11 of the test plan. +// +// resolve-token.ts currently only understands the OSF/PAT dispatch (see +// resolveToken's source: it branches on user_data.usingPersonalToken and +// user_data.authTokenExpires with no awareness of exp_data.storageProvider +// at all). Cases 8-10 below exercise a gdrive branch that does not exist yet: +// - case 8 fails because resolveToken falls through to the OAuth/OSF path, +// which reads user_data.authToken (undefined for a gdrive-only user) and +// returns { success: true, token: undefined } instead of dispatching on +// exp_data.storageProvider === "gdrive". +// - case 9/10 fail for the same reason: no refresh-POST is ever made (the +// OSF path calls refreshAndUpdateUser against the OSF token endpoint, not +// GDRIVE_TOKEN_URL), so global.fetch is never called and nothing is +// persisted to connectedAccounts.gdrive. +// - case 11 is a regression guard: it exercises the existing +// usingPersonalToken branch, completely untouched by the gdrive +// generalization, and is expected to PASS today. If a future edit to +// resolve-token.ts ever breaks it, that's a real regression, not an +// artifact of this RED phase. +// +// Persistence style follows collision-cache.test.js / upload-queue.test.js: +// a Firestore-emulator-backed app imported by its compiled lib/ output, one +// freshly-generated uid per test. +// +// Token encoding: per crypto-utils.ts, decrypt() has a plaintext fallback -- +// any value not prefixed with "v1:" is returned unchanged. Seeded tokens that +// are never expected to be *re-encrypted and re-read back* by this file use +// that fallback directly (mirrors how existing OSF emulator tests seed +// osfToken: "valid"). Case 9 is the exception: it asserts that resolveToken +// itself calls encrypt() when persisting the refreshed token, so this file +// sets its own TOKEN_ENCRYPTION_KEY and decrypts the persisted value back -- +// entirely self-consistent within this one process, and unrelated to +// whatever key the Functions emulator loads from functions/.env for the +// separate gdrive-emulator.test.js integration run (which never exercises +// real encryption -- see that file's header comment). +// +// resolve-token.ts's existing refresh call (refreshAndUpdateUser, in +// refresh-token.ts) uses the runtime's global `fetch`, not the "node-fetch" +// package -- only the OSF file-upload modules (put-file-osf.ts, +// update-file-osf.ts) import node-fetch explicitly, per providers-osf.test.js's +// header comment. The gdrive refresh branch is expected to follow its +// sibling module's convention, so this file mocks global.fetch rather than +// the "node-fetch" module. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import resolveToken from "../../lib/resolve-token.js"; +import { decrypt } from "../../lib/crypto-utils.js"; + +// TOKEN_ENCRYPTION_KEY is read lazily inside crypto-utils.ts's encrypt()/ +// decrypt() (via getKey(), called per-invocation, not cached at module +// load), so it's safe to import resolveToken/crypto-utils statically here +// and only set the key in beforeAll below, before any test actually calls +// them. +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; + +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; + +let db; + +const ORIGINAL_ENV = { + TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, + GDRIVE_TOKEN_URL: process.env.GDRIVE_TOKEN_URL, + GDRIVE_CLIENT_ID: process.env.GDRIVE_CLIENT_ID, + GDRIVE_CLIENT_SECRET: process.env.GDRIVE_CLIENT_SECRET, +}; +const ORIGINAL_FETCH = global.fetch; + +beforeAll(async () => { + let app; + try { + app = getApp("resolve-token-gdrive-test"); + } catch { + app = initializeApp(config, "resolve-token-gdrive-test"); + } + db = getFirestore(app); + + // A valid-shaped (64 hex char) key, scoped to this process only -- see + // header comment on why this doesn't need to match functions/.env. + process.env.TOKEN_ENCRYPTION_KEY = "11".repeat(32); + process.env.GDRIVE_TOKEN_URL = "https://gdrive-token.mock.test/token"; + process.env.GDRIVE_CLIENT_ID = "test-gdrive-client-id"; + process.env.GDRIVE_CLIENT_SECRET = "test-gdrive-client-secret"; +}); + +afterAll(() => { + process.env.TOKEN_ENCRYPTION_KEY = ORIGINAL_ENV.TOKEN_ENCRYPTION_KEY; + process.env.GDRIVE_TOKEN_URL = ORIGINAL_ENV.GDRIVE_TOKEN_URL; + process.env.GDRIVE_CLIENT_ID = ORIGINAL_ENV.GDRIVE_CLIENT_ID; + process.env.GDRIVE_CLIENT_SECRET = ORIGINAL_ENV.GDRIVE_CLIENT_SECRET; + global.fetch = ORIGINAL_FETCH; +}); + +beforeEach(() => { + global.fetch = jest.fn(); +}); + +// A tolerance window for timestamp assertions -- generous enough to absorb +// emulator/test round-trip latency without hiding a genuinely wrong duration +// (mirrors collision-cache.test.js's TOLERANCE_MS). +const TOLERANCE_MS = 5000; + +function header(headers, name) { + if (!headers) return undefined; + const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase()); + return key ? headers[key] : undefined; +} + +async function createGdriveUser(uid, overrides = {}) { + const gdrive = { + authMethod: "oauth2", + encryptedToken: "plain-access-token", + encryptedRefreshToken: "plain-refresh-token", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + providerAccountId: "acct-1", + ...overrides, + }; + await db.collection("users").doc(uid).set({ connectedAccounts: { gdrive } }); + return gdrive; +} + +async function getUserData(uid) { + const snap = await db.collection("users").doc(uid).get(); + return snap.data(); +} + +describe("8. unexpired gdrive token", () => { + it("returns the decrypted token directly, without calling the token endpoint", async () => { + const uid = `gdrive-resolve-8-${randomUUID()}`; + await createGdriveUser(uid, { encryptedToken: "unexpired-access-token-8" }); + const userData = await getUserData(uid); + const expData = { storageProvider: "gdrive", owner: uid }; + + const result = await resolveToken(userData, expData); + + expect(result).toEqual({ success: true, token: "unexpired-access-token-8" }); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); + +describe("9. expired gdrive token triggers a refresh", () => { + it("POSTs the refresh grant with client credentials, and persists the new token/expiry encrypted, including refresh-token rotation", async () => { + const uid = `gdrive-resolve-9-${randomUUID()}`; + await createGdriveUser(uid, { + encryptedToken: "stale-access-token-9", + encryptedRefreshToken: "refresh-token-9", + tokenExpiresAt: Date.now() - 1000, // already expired + }); + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-access-token-9", + expires_in: 3600, + refresh_token: "new-refresh-token-9", + }), + }); + + const userData = await getUserData(uid); + const expData = { storageProvider: "gdrive", owner: uid }; + + const before = Date.now(); + const result = await resolveToken(userData, expData); + const after = Date.now(); + + expect(result).toEqual({ success: true, token: "new-access-token-9" }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe(process.env.GDRIVE_TOKEN_URL); + expect(options.method).toBe("POST"); + expect(header(options.headers, "Content-Type")).toContain("application/x-www-form-urlencoded"); + + const bodyParams = new URLSearchParams(options.body); + expect(bodyParams.get("grant_type")).toBe("refresh_token"); + expect(bodyParams.get("refresh_token")).toBe("refresh-token-9"); + expect(bodyParams.get("client_id")).toBe("test-gdrive-client-id"); + expect(bodyParams.get("client_secret")).toBe("test-gdrive-client-secret"); + + const persisted = await getUserData(uid); + const gdrive = persisted.connectedAccounts.gdrive; + expect(decrypt(gdrive.encryptedToken)).toBe("new-access-token-9"); + expect(decrypt(gdrive.encryptedRefreshToken)).toBe("new-refresh-token-9"); // rotated + expect(gdrive.tokenExpiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000 - TOLERANCE_MS); + expect(gdrive.tokenExpiresAt).toBeLessThanOrEqual(after + 3600 * 1000 + TOLERANCE_MS); + }); + + it("keeps the existing refresh token when the response does not include a rotated one", async () => { + const uid = `gdrive-resolve-9b-${randomUUID()}`; + await createGdriveUser(uid, { + encryptedToken: "stale-access-token-9b", + encryptedRefreshToken: "refresh-token-9b-unrotated", + tokenExpiresAt: Date.now() - 1000, + }); + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-access-token-9b", + expires_in: 1800, + // no refresh_token field -- no rotation offered + }), + }); + + const userData = await getUserData(uid); + const expData = { storageProvider: "gdrive", owner: uid }; + + const result = await resolveToken(userData, expData); + + expect(result).toEqual({ success: true, token: "new-access-token-9b" }); + + const persisted = await getUserData(uid); + const gdrive = persisted.connectedAccounts.gdrive; + expect(decrypt(gdrive.encryptedToken)).toBe("new-access-token-9b"); + expect(decrypt(gdrive.encryptedRefreshToken)).toBe("refresh-token-9b-unrotated"); + }); +}); + +describe("10. resolve failures", () => { + it("returns INVALID_REFRESH_TOKEN when the refresh request fails", async () => { + const uid = `gdrive-resolve-10a-${randomUUID()}`; + await createGdriveUser(uid, { + encryptedToken: "stale-access-token-10a", + encryptedRefreshToken: "bad-refresh-token-10a", + tokenExpiresAt: Date.now() - 1000, + }); + + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve("invalid_grant"), + }); + + const userData = await getUserData(uid); + const expData = { storageProvider: "gdrive", owner: uid }; + + const result = await resolveToken(userData, expData); + + expect(result.success).toBe(false); + expect(result.error).toBe("INVALID_REFRESH_TOKEN"); + expect(typeof result.detail).toBe("string"); + expect(result.detail.length).toBeGreaterThan(0); + }); + + it("returns PROVIDER_NOT_CONNECTED when the user has no connectedAccounts.gdrive at all", async () => { + const uid = `gdrive-resolve-10b-${randomUUID()}`; + await db.collection("users").doc(uid).set({}); // no connectedAccounts field whatsoever + + const userData = await getUserData(uid); + const expData = { storageProvider: "gdrive", owner: uid }; + + const result = await resolveToken(userData, expData); + + expect(result.success).toBe(false); + expect(result.error).toBe("PROVIDER_NOT_CONNECTED"); + expect(typeof result.detail).toBe("string"); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); + +describe("11. osf-experiment dispatch unchanged (regression guard)", () => { + it("still returns the decrypted PAT for a personal-token OSF user -- expected to PASS today, unaffected by the gdrive generalization", async () => { + const userData = { + usingPersonalToken: true, + osfTokenValid: true, + osfToken: "valid-osf-pat-11", + }; + // storageProvider deliberately absent -- legacy OSF experiment. + const expData = { owner: "osf-owner-11" }; + + const result = await resolveToken(userData, expData); + + expect(result).toEqual({ success: true, token: "valid-osf-pat-11" }); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index c0e1123..9861daf 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -133,6 +133,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: false, failureReason: `Collision cache rehydration failed: ${detail}`, claimToken, @@ -163,6 +164,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: false, failureReason: "Collision cache rehydrating", claimToken, @@ -195,6 +197,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: false, failureReason: `Upload exception: ${detail}`, claimToken, @@ -230,6 +233,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: result.providerStatus || 0, sessionIncremented: false, failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, claimToken, diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 1440c3d..958d1d9 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -166,6 +166,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: true, failureReason: `Collision cache rehydration failed: ${detail}`, claimToken, @@ -197,6 +198,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: true, failureReason: "Collision cache rehydrating", claimToken, @@ -230,6 +232,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: true, failureReason: `Upload exception: ${detail}`, claimToken, @@ -266,6 +269,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: result.providerStatus || 0, sessionIncremented: true, failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, claimToken, diff --git a/functions/src/api-messages.ts b/functions/src/api-messages.ts index cb9bec1..a822bc0 100644 --- a/functions/src/api-messages.ts +++ b/functions/src/api-messages.ts @@ -39,6 +39,10 @@ const MESSAGES = { error: "INVALID_REFRESH_TOKEN", message: "The experiment owner's refresh token is not valid", }, + PROVIDER_NOT_CONNECTED: { + error: "PROVIDER_NOT_CONNECTED", + message: "The experiment owner has not connected an account for this experiment's storage provider", + }, INVALID_BASE64_DATA: { error: "INVALID_BASE64_DATA", message: "The data are not valid base64 data", diff --git a/functions/src/interfaces.ts b/functions/src/interfaces.ts index 6e5d9df..b218ef5 100644 --- a/functions/src/interfaces.ts +++ b/functions/src/interfaces.ts @@ -96,7 +96,9 @@ export interface ExperimentData { filename: string; storagePath: string; dataType: "data" | "base64"; - osfFilesLink: string; + // Optional — undefined for provider-migrated (e.g. gdrive) queue + // entries, which carry storageProvider/providerContainer instead. + osfFilesLink?: string; status: "pending" | "processing" | "completed" | "failed"; errorCode: number; retryCount: number; @@ -112,6 +114,10 @@ export interface ExperimentData { // entries queued before the collision cache existed — those skip the // cache entirely on retry). claimToken?: string; + // Provider-migration fields (additive; absent = legacy OSF queue entry, + // which falls back to the osfFilesLink-based container above). + storageProvider?: StorageProviderId; + providerContainer?: ContainerRef; } export interface OSFFile{ diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index b87f4dc..9e09a24 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -1,16 +1,31 @@ import MESSAGES from "./api-messages.js"; import updateMetadata from "./metadata-update.js"; import produceMetadata from "./metadata-production.js"; -import downloadMetadata from "./metadata-download.js"; import { DocumentReference, DocumentData } from "firebase-admin/firestore"; import { db } from "./app.js"; -import { decrypt } from "./crypto-utils.js"; -import { refreshAndUpdateUser } from "./refresh-token.js"; -import { osfProvider } from "./providers/osf.js"; -import { FileRef } from "./providers/types.js"; +import resolveToken from "./resolve-token.js"; +import { getProviderForExperiment } from "./providers/index.js"; +import { FileRef, ProviderErrorCode } from "./providers/types.js"; import { ExperimentData, UserData, Metadata, MetadataResponse } from './interfaces'; +// Thrown by performUpdate below when a provider's updateFile call fails +// (either by throwing, in OSF's case, or by returning a failure WriteResult, +// in gdrive's case). Carries the provider's error code, when known, so +// callers can distinguish a self-healable failure (stale ref) from one that +// recreating the file can't fix (auth/quota/rate-limit). +class ProviderUpdateError extends Error { + code?: ProviderErrorCode; + constructor(message: string, code?: ProviderErrorCode) { + super(message); + this.code = code; + } +} +// Failure codes for which recreating the metadata file is pointless: an +// auth or quota problem isn't fixed by writing a new file, and self-healing +// on RATE_LIMITED would double the write load exactly when the provider is +// telling us to back off. +const NON_HEALABLE_CODES: ProviderErrorCode[] = ["AUTH_EXPIRED", "RATE_LIMITED", "QUOTA_EXCEEDED"]; export default async function blockMetadata( exp_data: ExperimentData, @@ -22,26 +37,13 @@ export default async function blockMetadata( let metadataMessage: {metadataMessage: string} = {metadataMessage: ''}; -let decryptedOsfToken: string; -if (user_data.usingPersonalToken) { - decryptedOsfToken = decrypt(user_data.osfToken); -} else { - if (Date.now() > user_data.authTokenExpires) { - const refreshResult = await refreshAndUpdateUser(exp_data.owner, decrypt(user_data.refreshToken)); - if (!refreshResult.success) { - // Fall back to PAT if available - if (user_data.osfTokenValid && user_data.osfToken) { - decryptedOsfToken = decrypt(user_data.osfToken); - } else { - return { success: false, metadataMessage: "OAuth token refresh failed" }; - } - } else { - decryptedOsfToken = refreshResult.accessToken!; - } - } else { - decryptedOsfToken = decrypt(user_data.authToken); - } +const tokenResult = await resolveToken(user_data, exp_data); +if (!tokenResult.success) { + return { success: false, metadataMessage: tokenResult.detail }; } +const token = tokenResult.token; + +const { provider, container } = getProviderForExperiment(exp_data); try { @@ -73,9 +75,9 @@ try { // tracking. Afterward the ref (possibly null) is stored so this never // runs again for this experiment. if (metadataFileRef === undefined) { - const providerFiles = await osfProvider.listFiles( - { token: decryptedOsfToken }, - { provider: "osf", filesLink: exp_data.osfFilesLink } + const providerFiles = await provider.listFiles( + { token }, + container ); const found = providerFiles.find((file) => file.name === "dataset_description.json"); @@ -91,9 +93,9 @@ try { async function createMetadataFile(payload: object) { const serialized = JSON.stringify(payload, null, 2); - const response = await osfProvider.writeSessionFile( - { token: decryptedOsfToken }, - { provider: "osf", filesLink: exp_data.osfFilesLink }, + const response = await provider.writeSessionFile( + { token }, + container, `dataset_description.json`, serialized, { size: Buffer.byteLength(serialized), contentType: "application/json" } @@ -114,6 +116,28 @@ try { } } + // Updates the ref'd metadata file. Throws a ProviderUpdateError on any + // failure — OSF's updateFile already throws on non-200 responses; + // gdrive's never throws, so a returned {success:false} is converted + // into the same ProviderUpdateError shape here so callers can handle + // both provider styles identically. + async function performUpdate(fileRef: FileRef, serialized: string) { + const result = await provider.updateFile( + { token }, + container, + fileRef, + serialized, + { size: Buffer.byteLength(serialized), contentType: "application/json" } + ); + + if (!result.success) { + throw new ProviderUpdateError( + `Error updating metadata file: ${result.providerMessage}`, + result.error + ); + } + } + //When a ref and firestore metadata both exist, updating is done with respect to firestore. if (metadataFileRef && firestoreMetadata) { @@ -128,17 +152,17 @@ try { try { //The ref'd metadata file is updated with the above metadata. - await osfProvider.updateFile( - { token: decryptedOsfToken }, - { provider: "osf", filesLink: exp_data.osfFilesLink }, - metadataFileRef, - serialized, - { size: Buffer.byteLength(serialized), contentType: "application/json" } - ); + await performUpdate(metadataFileRef, serialized); } catch (e) { - // Self-heal: the ref is stale (the file was deleted provider-side). - // Create a fresh metadata file and store its new ref rather than - // failing the whole request. + // A returned failure with a non-healable code (auth/quota/rate + // limit) must propagate rather than self-heal — recreating the + // file can't fix any of those, and re-creating under rate-limiting + // would only make things worse. + if (e instanceof ProviderUpdateError && e.code && NON_HEALABLE_CODES.includes(e.code)) { + throw e; + } + // Self-heal: the ref is stale (the file was deleted provider-side), + // or the failure is otherwise recoverable by recreating the file. await createMetadataFile(updatedMetadata); } } @@ -147,29 +171,32 @@ try { metadataMessage = MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE; - //Metadata is downloaded from OSF, and is compared to incoming metadata to produce an updated version. - // ********[IMPORTANT]*********** - // Since Metadata is in OSF as evidenced by the ref, it is downloaded, and the type is asserted. - const downloadResponse = await downloadMetadata(exp_data.osfFilesLink, decryptedOsfToken, metadataFileRef.id as string); + //Metadata is downloaded from the provider, and is compared to incoming metadata to produce an updated version. + const downloadResult = await provider.downloadFile({ token }, container, metadataFileRef); - const osfMetadata: Metadata = downloadResponse.metadata; + if (!downloadResult.success) { + throw new Error(`Error downloading metadata file: ${downloadResult.providerMessage}`); + } - const updatedMetadata = await updateMetadata(osfMetadata, incomingMetadata); + let providerMetadata: Metadata; + try { + providerMetadata = JSON.parse(downloadResult.content) as Metadata; + } catch (e) { + throw new Error(`Error parsing downloaded metadata: ${e instanceof Error ? e.message : "Unknown error"}`); + } + + const updatedMetadata = await updateMetadata(providerMetadata, incomingMetadata); //Up to date metadata is uploaded to firestore. t.set(metadata_doc_ref, {metadata: updatedMetadata}, {merge: true}); - //Since metadata exists in OSF, it is updated and not set. - await osfProvider.updateFile( - { token: decryptedOsfToken }, - { provider: "osf", filesLink: exp_data.osfFilesLink }, - metadataFileRef, - JSON.stringify(incomingMetadata, null, 2), - { size: Buffer.byteLength(JSON.stringify(incomingMetadata, null, 2)), contentType: "application/json" } - ); + //Since metadata exists in the provider, it is updated and not set. + // No self-heal here (matches pre-existing behavior) — any failure + // propagates to the outer catch as METADATA_ERROR. + await performUpdate(metadataFileRef, JSON.stringify(incomingMetadata, null, 2)); } - // When no ref exists but firestore has metadata, the metadata file is (re)created in OSF. + // When no ref exists but firestore has metadata, the metadata file is (re)created in the provider. else if (!metadataFileRef && firestoreMetadata) { metadataMessage = MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF; @@ -179,15 +206,15 @@ try { t.update(metadata_doc_ref, {metadata: updatedMetadata}); - //If a metadata file does not exist in OSF, it is created with the above metadata. + //If a metadata file does not exist in the provider, it is created with the above metadata. await createMetadataFile(updatedMetadata); } - // When neither a ref nor firestore metadata exist, the metadata is created in OSF and firestore. + // When neither a ref nor firestore metadata exist, the metadata is created in the provider and firestore. else { metadataMessage = MESSAGES.METADATA_NOT_IN_FIRESTORE_OR_OSF; - //Incoming metadata is uploaded to firestore and OSF. + //Incoming metadata is uploaded to firestore and the provider. t.set(metadata_doc_ref, {metadata: incomingMetadata}, {merge: true}); @@ -195,7 +222,7 @@ try { } }); - + const metadataResponse: MetadataResponse = {success: true, ...metadataMessage}; return metadataResponse; } @@ -219,4 +246,4 @@ catch (error) { return metadataResponse; //METADATA BLOCK END }; -} \ No newline at end of file +} diff --git a/functions/src/metadata-download.ts b/functions/src/metadata-download.ts deleted file mode 100644 index a71365d..0000000 --- a/functions/src/metadata-download.ts +++ /dev/null @@ -1,59 +0,0 @@ -import fetch from "node-fetch"; -import validateJSON from "./validate-json.js"; -import { Metadata } from "./interfaces"; - -export default async function downloadMetadata( - osfComponent: string, - osfToken: string, - metadataId: string, -) { - //Gets the metadata of the data storage element in the OSF project. - const downloadMetadata = await fetch(`${osfComponent}${metadataId}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${osfToken}`, - } - }); - /** - * Checks the status of the metadata download. - * If the status is 404, logs the list of files to the console and returns an error object. - */ - if (downloadMetadata.status === 404) { - throw Error(`Error downloading metadata with code: ${downloadMetadata.status}, and message: ${downloadMetadata.statusText}`); - } - - /** - * Extracts the URL of the metadata file from the download object. - * @type {string} - */ - const fileUrl: string = downloadMetadata.url; - - /** - * Uses the download link provided by OSF to get the metadata file as a string. - * @param {string} fileUrl - The URL of the file to fetch. - * @returns {Promise} A promise that resolves to the data from the file. - */ - async function fetchMetadata(fileUrl: string) { - try { - const response = await fetch(fileUrl); - if (!response.ok) { - throw Error(`Error fetching metadata with code: ${response.status}, and message: ${response.statusText}`); - } - return await response.json(); - } catch (error) { - throw Error(`Error fetching metadata with code: 400, and message: ${error instanceof Error ? error.message : 'An unknown error occurred'}`) - } -} - // Download the metadata file. - const metadata: Metadata = await fetchMetadata(fileUrl) as Metadata; - - if (!metadata.variableMeasured || !metadata.variableMeasured[0].name) { - throw Error(`Error downloading metadata with code: 400, and message: Invalid metadata downloaded`) - } - - // Checks if the existing metadata is in valid JSON format, and that it contains the Psych-DS proper fields. - const success: boolean = validateJSON(JSON.stringify(metadata), ["name", "schemaVersion", "@context", "@type", "description", "author", "variableMeasured"]); - - return { success: success, errorCode: null, errorText: null, metadata: metadata}; -} \ No newline at end of file diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts new file mode 100644 index 0000000..840840f --- /dev/null +++ b/functions/src/providers/gdrive.ts @@ -0,0 +1,370 @@ +import fetch from "node-fetch"; +import { + StorageProvider, + ResolvedAuth, + ContainerRef, + FileRef, + FileMeta, + WriteResult, + DownloadResult, + ProviderErrorCode, +} from "./types.js"; + +// The gdrive container ref shape — only the folderId is meaningful to this +// adapter (the Drive folder an experiment's session files land in). +export interface GdriveContainerRef extends ContainerRef { + provider: "gdrive"; + folderId: string; +} + +const FOLDER_MIME = "application/vnd.google-apps.folder"; + +// A fixed boundary is fine here — the request body is built and sent in one +// shot, never streamed/concatenated across requests, so there's no need for +// per-call uniqueness. +const MULTIPART_BOUNDARY = "datapipe-gdrive-multipart-boundary"; + +// GDRIVE_API_BASE is read at CALL time (not module load) so tests — and, in +// production, config changes — can vary it without a process restart. +function getApiBase(): string { + return process.env.GDRIVE_API_BASE || "https://www.googleapis.com"; +} + +function authHeaders(auth: ResolvedAuth): Record { + return { Authorization: `Bearer ${auth.token}` }; +} + +// Google Drive `q` filters are single-quoted strings; escape backslashes and +// embedded single quotes per Drive's query syntax. +function escapeQueryValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); +} + +function isSuccessStatus(status: number): boolean { + return status === 200 || status === 201; +} + +interface MappedDriveError { + error: ProviderErrorCode; + providerStatus: number; + providerMessage: string; + retryAfter: number | null; +} + +// Shared error-mapping helper — every write/update/list/download call routes +// its non-2xx response through this. Drive never yields a duplicate-name +// conflict (NAME_CONFLICT): Drive allows multiple files with the same name +// in the same folder, so the collision cache (not the provider) is the only +// duplicate gate for gdrive experiments. +function mapDriveError( + status: number, + statusText: string, + body: { errors?: { reason?: string; message?: string }[] } | undefined, + retryAfterHeader: string | null +): MappedDriveError { + const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : null; + + let error: ProviderErrorCode; + if (status === 401) { + error = "AUTH_EXPIRED"; + } else if (status === 403) { + const reason = body?.errors?.[0]?.reason; + if (reason === "storageQuotaExceeded") { + error = "QUOTA_EXCEEDED"; + } else if ( + reason === "userRateLimitExceeded" || + reason === "rateLimitExceeded" || + reason === "dailyLimitExceeded" + ) { + error = "RATE_LIMITED"; + } else { + error = "AUTH_EXPIRED"; + } + } else if (status === 429) { + error = "RATE_LIMITED"; + } else { + error = "UNAVAILABLE"; + } + + return { error, providerStatus: status, providerMessage: statusText, retryAfter }; +} + +// Reads the body (only when the status requires inspecting it — the 403 +// reason drill-down) and maps the response into the shared error shape. +async function mapErrorResponse(response: { + status: number; + statusText: string; + headers: { get: (name: string) => string | null }; + json: () => Promise; +}): Promise { + let body: { errors?: { reason?: string; message?: string }[] } | undefined; + if (response.status === 403) { + try { + body = (await response.json()) as { errors?: { reason?: string; message?: string }[] }; + } catch { + body = undefined; + } + } + const retryAfterHeader = response.headers.get("Retry-After"); + return mapDriveError(response.status, response.statusText, body, retryAfterHeader); +} + +// Finds a folder by exact name under a given parent. Returns null if none +// exists yet. +async function findFolder( + auth: ResolvedAuth, + name: string, + parentId: string +): Promise { + const url = new URL(`${getApiBase()}/drive/v3/files`); + const q = `name='${escapeQueryValue(name)}' and '${parentId}' in parents and mimeType='${FOLDER_MIME}' and trashed=false`; + url.searchParams.set("q", q); + + const response = await fetch(url.toString(), { + method: "GET", + headers: authHeaders(auth), + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + throw new Error(`Google Drive folder lookup failed: ${mapped.providerStatus} ${mapped.providerMessage}`); + } + + const body = (await response.json()) as { files?: { id: string }[] }; + const files = body.files || []; + return files.length > 0 ? files[0].id : null; +} + +async function createFolder(auth: ResolvedAuth, name: string, parentId: string): Promise { + const response = await fetch(`${getApiBase()}/drive/v3/files`, { + method: "POST", + headers: { + ...authHeaders(auth), + "Content-Type": "application/json", + }, + body: JSON.stringify({ name, mimeType: FOLDER_MIME, parents: [parentId] }), + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + throw new Error(`Google Drive folder creation failed: ${mapped.providerStatus} ${mapped.providerMessage}`); + } + + const body = (await response.json()) as { id: string }; + return body.id; +} + +async function findOrCreateFolder(auth: ResolvedAuth, name: string, parentId: string): Promise { + const existingId = await findFolder(auth, name, parentId); + if (existingId) { + return existingId; + } + return createFolder(auth, name, parentId); +} + +// Hand-built multipart/related body: part 1 is the JSON metadata (name + +// parents), part 2 is the raw file payload — no extra dependencies needed +// for this. +function buildMultipartBody(metadata: object, data: string | Buffer, contentType: string): Buffer { + const dataBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + const preamble = + `--${MULTIPART_BOUNDARY}\r\n` + + `Content-Type: application/json; charset=UTF-8\r\n\r\n` + + `${JSON.stringify(metadata)}\r\n` + + `--${MULTIPART_BOUNDARY}\r\n` + + `Content-Type: ${contentType}\r\n\r\n`; + const epilogue = `\r\n--${MULTIPART_BOUNDARY}--`; + + return Buffer.concat([Buffer.from(preamble), dataBuffer, Buffer.from(epilogue)]); +} + +export const gdriveProvider: StorageProvider = { + id: "gdrive", + authMethod: "oauth2", + capabilities: { + nativeSubfolders: true, + supportsRegion: false, + maxFileSizeBytes: null, + quotaNote: "Free Google accounts share 15 GB across Drive, Gmail, and Photos", + }, + + async createDataContainer(auth: ResolvedAuth, researcherInput: Record): Promise { + const name = researcherInput.name as string; + + let rootId = await findFolder(auth, "DataPipe", "root"); + if (!rootId) { + rootId = await createFolder(auth, "DataPipe", "root"); + } + + // Experiment folders are always created fresh — Drive allows duplicate + // names, so there's nothing to find-or-create here. + const folderId = await createFolder(auth, name, rootId); + + return { provider: "gdrive", folderId }; + }, + + async writeSessionFile( + auth: ResolvedAuth, + container: ContainerRef, + filename: string, + data: string | Buffer, + meta: FileMeta + ): Promise { + const gdriveContainer = container as GdriveContainerRef; + + let parentId = gdriveContainer.folderId; + let uploadFilename = filename; + + const slashIndex = filename.indexOf("/"); + if (slashIndex !== -1) { + const subfolderName = filename.slice(0, slashIndex); + uploadFilename = filename.slice(slashIndex + 1); + try { + parentId = await findOrCreateFolder(auth, subfolderName, gdriveContainer.folderId); + } catch (e) { + return { + success: false, + error: "UNAVAILABLE", + providerStatus: null, + providerMessage: e instanceof Error ? e.message : "Unknown error", + retryAfter: null, + }; + } + } + + const body = buildMultipartBody( + { name: uploadFilename, parents: [parentId] }, + data, + meta.contentType + ); + + const response = await fetch(`${getApiBase()}/upload/drive/v3/files?uploadType=multipart`, { + method: "POST", + headers: { + ...authHeaders(auth), + "Content-Type": `multipart/related; boundary=${MULTIPART_BOUNDARY}`, + }, + body, + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + return { success: false, ...mapped }; + } + + const responseBody = (await response.json()) as { id?: string; name?: string }; + const storedFilename = responseBody.name ?? uploadFilename; + + return { + success: true, + fileRef: { id: responseBody.id, name: responseBody.name } as unknown as FileRef, + storedFilename, + }; + }, + + async updateFile( + auth: ResolvedAuth, + _container: ContainerRef, + existingFileRef: FileRef, + data: string | Buffer, + meta: FileMeta + ): Promise { + const response = await fetch( + `${getApiBase()}/upload/drive/v3/files/${existingFileRef.id}?uploadType=media`, + { + method: "PATCH", + headers: { + ...authHeaders(auth), + "Content-Type": meta.contentType, + }, + body: data, + } + ); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + return { success: false, ...mapped }; + } + + return { + success: true, + fileRef: existingFileRef, + storedFilename: existingFileRef.name, + }; + }, + + async listFiles(auth: ResolvedAuth, container: ContainerRef): Promise { + const gdriveContainer = container as GdriveContainerRef; + const q = `'${gdriveContainer.folderId}' in parents and trashed=false`; + + const results: FileRef[] = []; + let pageToken: string | undefined; + + do { + const url = new URL(`${getApiBase()}/drive/v3/files`); + url.searchParams.set("q", q); + url.searchParams.set("fields", "nextPageToken,files(id,name,mimeType)"); + url.searchParams.set("pageSize", "1000"); + if (pageToken) { + url.searchParams.set("pageToken", pageToken); + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: authHeaders(auth), + }); + + // A failed listing MUST throw, never return a partial/empty result: + // collision-cache rehydration treats the returned list as the complete + // set of existing filenames, and Drive has no 409 backstop — silently + // returning [] here would warm the cache empty and let duplicates + // through. The throw surfaces as CollisionCacheUnavailableError. + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + throw new Error( + `Google Drive listing failed: ${mapped.providerStatus} ${mapped.providerMessage}` + ); + } + + const body = (await response.json()) as { + nextPageToken?: string; + files?: { id: string; name: string; mimeType: string }[]; + }; + + for (const file of body.files || []) { + if (file.mimeType === FOLDER_MIME) { + continue; + } + results.push({ id: file.id, name: file.name }); + } + + pageToken = body.nextPageToken; + } while (pageToken); + + return results; + }, + + async downloadFile( + auth: ResolvedAuth, + _container: ContainerRef, + fileRef: FileRef + ): Promise { + const response = await fetch(`${getApiBase()}/drive/v3/files/${fileRef.id}?alt=media`, { + method: "GET", + headers: authHeaders(auth), + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + return { + success: false, + error: mapped.error, + providerStatus: mapped.providerStatus, + providerMessage: mapped.providerMessage, + }; + } + + const content = await response.text(); + return { success: true, content }; + }, +}; diff --git a/functions/src/providers/index.ts b/functions/src/providers/index.ts index 901fafe..ff02741 100644 --- a/functions/src/providers/index.ts +++ b/functions/src/providers/index.ts @@ -1,9 +1,11 @@ import { registerProvider, getProvider } from "./registry.js"; import { osfProvider } from "./osf.js"; +import { gdriveProvider } from "./gdrive.js"; import { StorageProvider, ContainerRef } from "./types.js"; import { ExperimentData } from "../interfaces.js"; registerProvider(osfProvider); +registerProvider(gdriveProvider); export function getProviderForExperiment(exp_data: ExperimentData): { provider: StorageProvider; @@ -26,4 +28,5 @@ export function getProviderForExperiment(exp_data: ExperimentData): { export { registerProvider, getProvider } from "./registry.js"; export { osfProvider } from "./osf.js"; +export { gdriveProvider } from "./gdrive.js"; export * from "./types.js"; diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts index ad587fd..1ae6b86 100644 --- a/functions/src/providers/osf.ts +++ b/functions/src/providers/osf.ts @@ -9,6 +9,7 @@ import { FileRef, FileMeta, WriteResult, + DownloadResult, ProviderErrorCode, } from "./types.js"; @@ -115,4 +116,31 @@ export const osfProvider: StorageProvider = { .filter((file) => file.attributes.kind === "file") .map((file) => ({ name: file.attributes.name, id: file.id })); }, + + async downloadFile( + auth: ResolvedAuth, + container: ContainerRef, + fileRef: FileRef + ): Promise { + const osfContainer = container as OSFContainerRef; + + const response = await fetch(`${osfContainer.filesLink}${fileRef.id}`, { + method: "GET", + headers: { + Authorization: `Bearer ${auth.token}`, + }, + }); + + if (response.status !== 200) { + return { + success: false, + error: mapStatus(response.status), + providerStatus: response.status, + providerMessage: response.statusText, + }; + } + + const content = await response.text(); + return { success: true, content }; + }, }; diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index ba2b275..c93caaf 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -59,6 +59,18 @@ export type WriteResult = retryAfter?: number | null; }; +export type DownloadResult = + | { + success: true; + content: string; + } + | { + success: false; + error: ProviderErrorCode; + providerStatus: number | null; + providerMessage: string | null; + }; + // Descriptive (UI hints, subfolder fallback, size-cap warnings) — never a // correctness gate. Collision detection lives in Firestore, not here. export interface ProviderCapabilities { @@ -115,6 +127,15 @@ export interface StorageProvider { // Full listing (adapters paginate internally). Used for collision-cache // rehydration and dashboard file counts. listFiles(auth: ResolvedAuth, container: ContainerRef): Promise; + + // Fetches a file's contents as text. Used by metadata-block.ts to read + // back an existing dataset_description.json. Never throws — failures come + // back as a DownloadResult, same shape convention as WriteResult. + downloadFile( + auth: ResolvedAuth, + container: ContainerRef, + fileRef: FileRef + ): Promise; } // users/{uid}.connectedAccounts.* shapes (additive Firestore schema). diff --git a/functions/src/queue-upload.ts b/functions/src/queue-upload.ts index 818e582..a890ca5 100644 --- a/functions/src/queue-upload.ts +++ b/functions/src/queue-upload.ts @@ -1,5 +1,6 @@ import { Timestamp } from "firebase-admin/firestore"; import { db, storage } from "./app.js"; +import { StorageProviderId, ContainerRef } from "./providers/types.js"; interface QueueUploadParams { experimentID: string; @@ -7,11 +8,18 @@ interface QueueUploadParams { filename: string; data: string; dataType: "data" | "base64"; - osfFilesLink: string; + // Optional — undefined for provider-migrated (e.g. gdrive) experiments, + // which carry storageProvider/providerContainer instead. + osfFilesLink?: string; errorCode: number; sessionIncremented: boolean; failureReason?: string; claimToken?: string; + // Provider-migration fields (additive; absent for legacy OSF experiments — + // omitted from the Firestore write below rather than stored as undefined, + // since Firestore rejects undefined field values). + storageProvider?: StorageProviderId; + providerContainer?: ContainerRef; } const MAX_RETRIES = 5; @@ -42,14 +50,16 @@ export default async function queueUpload(params: QueueUploadParams): Promise = { experimentID: params.experimentID, owner: params.owner, filename: params.filename, storagePath, dataType: params.dataType, - osfFilesLink: params.osfFilesLink, status: "pending", errorCode: params.errorCode, retryCount: 0, @@ -62,7 +72,19 @@ export default async function queueUpload(params: QueueUploadParams): Promise { @@ -43,3 +44,87 @@ export default async function resolveToken( return { success: true, token: decrypt(user_data.authToken) }; } + +async function resolveGdriveToken( + user_data: UserData, + exp_data: ExperimentData, +): Promise { + const gdrive = user_data.connectedAccounts?.gdrive; + + if (!gdrive) { + return { + success: false, + error: "PROVIDER_NOT_CONNECTED", + detail: "No connected Google Drive account for this experiment's owner", + }; + } + + if (gdrive.tokenExpiresAt > Date.now()) { + return { success: true, token: decrypt(gdrive.encryptedToken) }; + } + + const tokenUrl = process.env.GDRIVE_TOKEN_URL || "https://oauth2.googleapis.com/token"; + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: decrypt(gdrive.encryptedRefreshToken), + client_id: process.env.GDRIVE_CLIENT_ID as string, + client_secret: process.env.GDRIVE_CLIENT_SECRET as string, + }); + + let tokenResponse: Response; + try { + tokenResponse = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown network error"; + return { success: false, error: "INVALID_REFRESH_TOKEN", detail }; + } + + if (!tokenResponse.ok) { + const detail = await tokenResponse.text(); + return { success: false, error: "INVALID_REFRESH_TOKEN", detail: detail || "Refresh token is not valid" }; + } + + const tokenData = await tokenResponse.json(); + + const newTokenExpiresAt = Date.now() + tokenData.expires_in * 1000; + + const update: Record = { + "connectedAccounts.gdrive.encryptedToken": encrypt(tokenData.access_token), + "connectedAccounts.gdrive.tokenExpiresAt": newTokenExpiresAt, + }; + + // Only rotate the refresh token when the provider actually issued a new + // one — otherwise leave the existing one in place. + if (tokenData.refresh_token) { + update["connectedAccounts.gdrive.encryptedRefreshToken"] = encrypt(tokenData.refresh_token); + } + + await db.doc(`users/${exp_data.owner}`).update(update); + + return { success: true, token: tokenData.access_token }; +} + +export default async function resolveToken( + user_data: UserData, + exp_data: ExperimentData, +): Promise { + if (!exp_data.storageProvider || exp_data.storageProvider === "osf") { + return resolveOsfToken(user_data, exp_data); + } + + if (exp_data.storageProvider === "gdrive") { + return resolveGdriveToken(user_data, exp_data); + } + + return { + success: false, + error: "PROVIDER_NOT_CONNECTED", + detail: `Unsupported storage provider: ${exp_data.storageProvider}`, + }; +} diff --git a/functions/src/scheduled-upload-retry.ts b/functions/src/scheduled-upload-retry.ts index 933fe67..45f2111 100644 --- a/functions/src/scheduled-upload-retry.ts +++ b/functions/src/scheduled-upload-retry.ts @@ -1,7 +1,8 @@ import { onSchedule } from "firebase-functions/v2/scheduler"; import { Timestamp } from "firebase-admin/firestore"; import { db, storage } from "./app.js"; -import { osfProvider } from "./providers/osf.js"; +import { getProvider } from "./providers/index.js"; +import { ContainerRef, StorageProviderId } from "./providers/types.js"; import resolveToken from "./resolve-token.js"; import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData } from "./interfaces.js"; @@ -134,16 +135,23 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho return; } - const container = { provider: "osf" as const, filesLink: data.osfFilesLink }; + // Provider/container come from the queue doc's provider-migration fields + // when present; legacy entries (queued before this generalization) fall + // back to the OSF shape built from osfFilesLink. + const providerId: StorageProviderId = (data.storageProvider as StorageProviderId) || "osf"; + const provider = getProvider(providerId); + const container: ContainerRef = data.providerContainer + ? (data.providerContainer as ContainerRef) + : { provider: "osf", filesLink: data.osfFilesLink }; // Collision cache: only entries queued after the cache existed carry a // claimToken. Entries queued before it skip the cache entirely — legacy - // behavior, OSF's own 409 backstop still applies to them. + // behavior, the provider's own conflict backstop still applies to them. if (data.claimToken) { let claimResult: Awaited>; try { claimResult = await claimFilename(data.experimentID, data.filename, data.claimToken, () => - osfProvider.listFiles({ token }, container) + provider.listFiles({ token }, container) ); } catch (e) { if (e instanceof CollisionCacheUnavailableError) { @@ -169,7 +177,7 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho // Attempt the upload try { - const result = await osfProvider.writeSessionFile( + const result = await provider.writeSessionFile( { token }, container, data.filename, @@ -192,11 +200,11 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho } // File already exists — treat as success (original upload may have worked) await markCompleted(docRef, data); - console.log(`Upload ${queueDoc.id} marked complete — file already exists in OSF.`); + console.log(`Upload ${queueDoc.id} marked complete — file already exists provider-side.`); return; } - await handleRetryFailure(docRef, data, `OSF error ${result.providerStatus}: ${result.providerMessage}`, result.retryAfter); + await handleRetryFailure(docRef, data, `Provider error ${result.providerStatus}: ${result.providerMessage}`, result.retryAfter); } catch (e) { const detail = e instanceof Error ? e.message : "Unknown error"; await handleRetryFailure(docRef, data, `Upload exception: ${detail}`); From 8f70d234a8c24b6a27cfd318191ba57ce37f585a Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Thu, 23 Jul 2026 09:24:30 -0400 Subject: [PATCH 08/14] fix: write dual-run disagreement logs before responding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collisionCacheDisagreement audit entry was written after the 400 response was already sent, so observers of the log (including the step-3a integration test under parallel-suite load) raced the write. Logs in the disagreement branch now land before the response in both apidata and apibase64 — same fix class as the earlier get-condition log race. Co-Authored-By: Claude Fable 5 --- functions/src/api-base64.ts | 4 +++- functions/src/api-data.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index 9861daf..73c32fb 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -219,12 +219,14 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: // says it's taken. OSF is still the backstop — record the // disagreement and confirm the claim (the name is now provably taken). await confirmClaim(experimentID, filename, claimToken); - res.status(400).json(MESSAGES.OSF_FILE_EXISTS); + // Logs before response — see the matching comment in api-data.ts: + // responding first races observers of the log against the write. await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); await writeLog(experimentID, "logError", { collisionCacheDisagreement: true, direction: "cache-free-provider-conflict", }); + res.status(400).json(MESSAGES.OSF_FILE_EXISTS); return; } // Queue all other failures for retry. The claim stays pending so the diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 958d1d9..06a1f17 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -255,12 +255,15 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 // says it's taken. OSF is still the backstop — record the // disagreement and confirm the claim (the name is now provably taken). await confirmClaim(experimentID, filename, claimToken); - res.status(400).json({...MESSAGES.OSF_FILE_EXISTS, metadataMessage}); + // Logs are written BEFORE the response here (unlike other branches): + // the disagreement entry is the dual-run's whole audit trail, and + // responding first races observers of the log against the write. await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); await writeLog(experimentID, "logError", { collisionCacheDisagreement: true, direction: "cache-free-provider-conflict", }); + res.status(400).json({...MESSAGES.OSF_FILE_EXISTS, metadataMessage}); return; } // Queue all other failures for retry. The claim stays pending so the From c65373282c90bcff2c9d9d97e793b6682890a767 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Thu, 23 Jul 2026 09:24:30 -0400 Subject: [PATCH 09/14] feat: provider OAuth connect flow (gdrive) alongside the OSF identity flow Build step 4b, completing build step 4 of the provider migration. The OSF OAuth flow is an identity flow (signup/sign-in/linking with Firebase custom tokens) and stays byte-identical. New providers get a separate storage-grant flow for already-authenticated users: - providers/oauth-config.ts: per-provider OAuth registry (gdrive only for now; env-driven, call-time reads; figshare becomes a config addition) - generateOAuthState optionally takes a provider: state doc records it and the response includes a ready-made authorize URL with access_type=offline&prompt=consent (without which Google never issues a refresh token) - connectProvider: CSRF-state validation (single-use, provider-matched), verifyIdToken uid ownership, code exchange, hard-fail if the exchange returns no refresh_token (no half-connected accounts), sibling-safe encrypted persistence into connectedAccounts. - disconnectProvider: same auth, FieldValue.delete of the map entry - gdrive token refresh extracted to providers/gdrive-oauth.ts, shared by resolveToken and a new scheduled pass (refreshExpiringGdriveTokens, 10-min window) that cannot break the OSF pass - design doc: documents the pre-existing test hazard that the OSF refresh path has no URL override and can hit production accounts.osf.io from emulator tests TDD: 17 contract tests reviewed red first; refresh-helper tests use per-token assertions and a discriminating fetch mock so the helper's collection-wide scan stays isolated from parallel suites. Full emulator suite green twice consecutively (27 suites, 191 tests). Co-Authored-By: Claude Fable 5 --- docs/provider-migration-design.md | 7 + functions/.env.datapipe-test | 7 +- .../__tests__/oauth-connect-emulator.test.js | 507 ++++++++++++++++++ .../oauth-connect-refresh-emulator.test.js | 191 +++++++ ...oauth-connect-scheduled-regression.test.js | 90 ++++ functions/src/connect-provider.ts | 193 +++++++ functions/src/generate-oauth-state.ts | 47 +- functions/src/index.ts | 3 + functions/src/providers/gdrive-oauth.ts | 75 +++ functions/src/providers/oauth-config.ts | 44 ++ functions/src/resolve-token.ts | 50 +- functions/src/scheduled-token-refresh.ts | 53 ++ 12 files changed, 1217 insertions(+), 50 deletions(-) create mode 100644 functions/src/__tests__/oauth-connect-emulator.test.js create mode 100644 functions/src/__tests__/oauth-connect-refresh-emulator.test.js create mode 100644 functions/src/__tests__/oauth-connect-scheduled-regression.test.js create mode 100644 functions/src/connect-provider.ts create mode 100644 functions/src/providers/gdrive-oauth.ts create mode 100644 functions/src/providers/oauth-config.ts diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index 0987307..b9fc536 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -357,6 +357,13 @@ provider, it does not trigger a redesign. ## Open questions +- **Test-suite hazard (pre-existing, discovered during step 4b)**: the OSF + token-refresh path has no URL override (unlike GDRIVE_TOKEN_URL), so an + emulator test that seeds a refresh-due OSF user makes a REAL network call + to production accounts.osf.io using the credentials in functions/.env. + The scheduled-refresh regression test deliberately pins a network-free + path because of this. Fix: introduce an OSF_TOKEN_URL override mirroring + the gdrive pattern, then pin the live-refresh branch properly. - Decide the exact collision-cache TTL window (90 days proposed, not yet validated against real usage patterns). - Decide the UX for Dataverse's federated `serverUrl` requirement (does diff --git a/functions/.env.datapipe-test b/functions/.env.datapipe-test index aad45d6..a28fb52 100644 --- a/functions/.env.datapipe-test +++ b/functions/.env.datapipe-test @@ -1,2 +1,7 @@ GDRIVE_API_BASE=http://127.0.0.1:3579 -GDRIVE_TOKEN_URL=http://127.0.0.1:3579/token +GDRIVE_TOKEN_URL=http://127.0.0.1:3580/token +GDRIVE_AUTHORIZE_URL=http://127.0.0.1:3580/authorize +GDRIVE_CLIENT_ID=test-client-id +GDRIVE_CLIENT_SECRET=test-client-secret +GDRIVE_REDIRECT_URI=http://localhost:3000/oauth2/gdrive +TOKEN_ENCRYPTION_KEY=abababababababababababababababababababababababababababababababab diff --git a/functions/src/__tests__/oauth-connect-emulator.test.js b/functions/src/__tests__/oauth-connect-emulator.test.js new file mode 100644 index 0000000..c1d0c58 --- /dev/null +++ b/functions/src/__tests__/oauth-connect-emulator.test.js @@ -0,0 +1,507 @@ +/** + * @jest-environment node + */ + +// RED-phase tests for step 4b (docs/provider-migration-design.md, +// scratchpad/step4b-oauth-connect-spec.md), cases 1-9 of the test plan. +// +// generateOAuthState (generate-oauth-state.ts) exists today but only knows +// the legacy, provider-less OSF flow -- it ignores any `provider` field in +// the POST body entirely, so case 1 (no provider) is a regression guard +// expected to PASS today, while cases 2-3 (provider handling, unknown- +// provider validation) fail red because that branch doesn't exist yet. +// +// connectProvider and disconnectProvider (functions/src/connect-provider.ts, +// exported as connectprovider/disconnectprovider per index.ts's lowercase +// export convention -- see apiData -> apidata) do not exist at all yet, so +// every request to their emulator URLs 404s. Cases 4-9 are all red for that +// reason; none of the validation/persistence logic described in the spec +// exists to exercise yet. Where a case's setup depends on case 2's +// (not-yet-existing) provider-aware generateOAuthState -- e.g. case 4's +// "state from (2)" -- this file still calls the real endpoint the way the +// finished feature is meant to be exercised; today that just means the +// created state doc won't carry `provider: "gdrive"` yet, which is +// irrelevant since connectProvider 404s long before it would read that +// field anyway. +// +// Real Auth-emulator idTokens: the Auth emulator's accounts:signUp REST +// endpoint (http://localhost:9099/identitytoolkit.googleapis.com/v1/ +// accounts:signUp?key=fake) returns a real idToken + localId for any +// email/password; localId is used as the uid so connectProvider's (future) +// auth.verifyIdToken(idToken) check matches -- there is no other way to get +// a verifiable idToken against the emulator's Auth backend. +// +// Mock OAuth token server: a fixed port (3580, this file only -- see +// functions/.env.datapipe-test) express server standing in for Google's +// token endpoint. Fixed, not listen(0), for the same reason as +// gdrive-emulator.test.js's mock Drive server: the emulator-hosted +// connectProvider function has no other way to discover where the mock +// lives, since GDRIVE_TOKEN_URL is read from the Functions emulator's own +// env, not passed at request time. Port 3579 (also fixed) is reserved for +// gdrive-emulator.test.js's mock Drive API and is never touched here. +// +// TOKEN_ENCRYPTION_KEY: this file's process sets the SAME fixed 64-hex value +// baked into functions/.env.datapipe-test so the Firestore-persisted +// encryptedToken -- written by the separate Functions-emulator process -- +// can be decrypted and verified here. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import express from "express"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; + +// Must match functions/.env.datapipe-test exactly. +const TOKEN_ENCRYPTION_KEY = "ab".repeat(32); +const GDRIVE_AUTHORIZE_URL = "http://127.0.0.1:3580/authorize"; +const GDRIVE_CLIENT_ID = "test-client-id"; +const GDRIVE_REDIRECT_URI = "http://localhost:3000/oauth2/gdrive"; +const GDRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file"; +const TOKEN_PORT = 3580; + +const FUNCTIONS_BASE = "http://localhost:5001/datapipe-test/us-central1"; +const AUTH_EMULATOR_SIGNUP_URL = + "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake"; + +let db; +let mockTokenServer; +let decrypt; + +beforeAll(async () => { + process.env.TOKEN_ENCRYPTION_KEY = TOKEN_ENCRYPTION_KEY; + ({ decrypt } = await import("../../lib/crypto-utils.js")); + + let app; + try { + app = getApp("oauth-connect-test"); + } catch { + app = initializeApp(config, "oauth-connect-test"); + } + db = getFirestore(app); + + mockTokenServer = await createMockTokenServer(); +}); + +afterEach(() => { + mockTokenServer.reset(); +}); + +afterAll(() => { + mockTokenServer.server.close(); +}); + +// ---- helpers ---- + +// A minimal stand-in for Google's token endpoint: records every received +// form-encoded body and returns a configurable response. Defaults to a +// happy-path grant so tests that don't care about the exchange details +// (e.g. auth-failure cases, which never reach the exchange) don't need to +// configure it. +function createMockTokenServer() { + const app = express(); + app.use(express.urlencoded({ extended: false })); + + function defaultResponse() { + return { + status: 200, + body: { + access_token: "mock-access-token", + refresh_token: "mock-refresh-token", + expires_in: 3600, + }, + }; + } + + let nextResponse = defaultResponse(); + const receivedRequests = []; + + app.post("/token", (req, res) => { + receivedRequests.push({ ...req.body }); + res.status(nextResponse.status).json(nextResponse.body); + }); + + return new Promise((resolve) => { + const server = app.listen(TOKEN_PORT, () => { + resolve({ + server, + setNextResponse(status, body) { + nextResponse = { status, body }; + }, + getLastRequest() { + return receivedRequests[receivedRequests.length - 1]; + }, + reset() { + receivedRequests.length = 0; + nextResponse = defaultResponse(); + }, + }); + }); + }); +} + +async function signUpEmulatorUser() { + const email = `oauth-connect-${randomUUID()}@example.test`; + const res = await fetch(AUTH_EMULATOR_SIGNUP_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password: "Password123!", returnSecureToken: true }), + }); + const body = await res.json(); + if (!res.ok) { + throw new Error(`Auth emulator signUp failed (${res.status}): ${JSON.stringify(body)}`); + } + return { uid: body.localId, idToken: body.idToken }; +} + +async function generateState(provider) { + const res = await fetch(`${FUNCTIONS_BASE}/generateoauthstate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(provider ? { provider } : {}), + }); + const body = await res.json(); + return { status: res.status, body }; +} + +async function createStateDoc(overrides = {}) { + const state = randomUUID(); + await db.collection("oauth_states").doc(state).set({ + createdAt: Date.now(), + expiresAt: Date.now() + 10 * 60 * 1000, + provider: "gdrive", + ...overrides, + }); + return state; +} + +async function getStateDoc(state) { + return db.collection("oauth_states").doc(state).get(); +} + +async function getUserData(uid) { + const snap = await db.collection("users").doc(uid).get(); + return snap.data(); +} + +async function postJson(url, payload) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await res.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { rawBody: text }; + } + return { status: res.status, body }; +} + +function callConnectProvider(payload) { + return postJson(`${FUNCTIONS_BASE}/connectprovider`, payload); +} + +function callDisconnectProvider(payload) { + return postJson(`${FUNCTIONS_BASE}/disconnectprovider`, payload); +} + +// ---- cases ---- + +describe("1. generateOAuthState without provider (regression guard)", () => { + it("returns { state } only, with no authorizeUrl, and the state doc has no provider field -- expected to PASS today", async () => { + const { status, body } = await generateState(); + + expect(status).toBe(200); + expect(typeof body.state).toBe("string"); + expect(body.authorizeUrl).toBeUndefined(); + expect(Object.keys(body).sort()).toEqual(["state"]); + + const stateDoc = await getStateDoc(body.state); + expect(stateDoc.exists).toBe(true); + expect(stateDoc.data().provider).toBeUndefined(); + }); +}); + +describe("2. generateOAuthState with provider gdrive", () => { + it("returns { state, authorizeUrl } with the correct query params, and records provider on the state doc", async () => { + const { status, body } = await generateState("gdrive"); + + expect(status).toBe(200); + expect(typeof body.state).toBe("string"); + expect(typeof body.authorizeUrl).toBe("string"); + + const url = new URL(body.authorizeUrl); + expect(`${url.origin}${url.pathname}`).toBe(GDRIVE_AUTHORIZE_URL); + expect(url.searchParams.get("client_id")).toBe(GDRIVE_CLIENT_ID); + expect(url.searchParams.get("redirect_uri")).toBe(GDRIVE_REDIRECT_URI); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe(GDRIVE_SCOPE); + expect(url.searchParams.get("state")).toBe(body.state); + expect(url.searchParams.get("access_type")).toBe("offline"); + expect(url.searchParams.get("prompt")).toBe("consent"); + + const stateDoc = await getStateDoc(body.state); + expect(stateDoc.data().provider).toBe("gdrive"); + }); +}); + +describe("3. generateOAuthState with unknown provider", () => { + it("returns 400", async () => { + const { status } = await generateState("not-a-real-provider"); + expect(status).toBe(400); + }); +}); + +describe("4. connectProvider happy path", () => { + it("exchanges the code, persists an encrypted gdrive connection, and deletes the state (single-use)", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const { body: stateBody } = await generateState("gdrive"); + const state = stateBody.state; + + mockTokenServer.setNextResponse(200, { + access_token: "case4-access-token", + refresh_token: "case4-refresh-token", + expires_in: 3600, + }); + + const before = Date.now(); + const { status, body } = await callConnectProvider({ + provider: "gdrive", + code: "case4-auth-code", + state, + uid, + idToken, + }); + const after = Date.now(); + + expect(status).toBe(200); + expect(body).toEqual({ success: true, provider: "gdrive" }); + + const userData = await getUserData(uid); + const gdrive = userData.connectedAccounts.gdrive; + expect(gdrive.authMethod).toBe("oauth2"); + expect(gdrive.encryptedToken.startsWith("v1:")).toBe(true); + expect(decrypt(gdrive.encryptedToken)).toBe("case4-access-token"); + expect(gdrive.tokenExpiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000 - 5000); + expect(gdrive.tokenExpiresAt).toBeLessThanOrEqual(after + 3600 * 1000 + 5000); + + const lastRequest = mockTokenServer.getLastRequest(); + expect(lastRequest.grant_type).toBe("authorization_code"); + expect(lastRequest.code).toBe("case4-auth-code"); + expect(lastRequest.client_id).toBe(GDRIVE_CLIENT_ID); + expect(lastRequest.redirect_uri).toBe(GDRIVE_REDIRECT_URI); + expect(typeof lastRequest.client_secret).toBe("string"); + expect(lastRequest.client_secret.length).toBeGreaterThan(0); + + const stateDoc = await getStateDoc(state); + expect(stateDoc.exists).toBe(false); + }); +}); + +describe("5. connectProvider state validation", () => { + it("rejects a state issued without a provider (legacy OSF state) as a provider mismatch", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const { body: legacyStateBody } = await generateState(); // no provider -- legacy OSF state + + const { status } = await callConnectProvider({ + provider: "gdrive", + code: "case5-mismatch-code", + state: legacyStateBody.state, + uid, + idToken, + }); + + expect(status).toBe(400); + }); + + it("rejects a reused (already-consumed) state", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const state = await createStateDoc(); + await db.collection("oauth_states").doc(state).delete(); // simulate prior consumption + + const { status } = await callConnectProvider({ + provider: "gdrive", + code: "case5-reused-code", + state, + uid, + idToken, + }); + + expect(status).toBe(400); + }); + + it("rejects an expired state", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const state = await createStateDoc({ expiresAt: Date.now() - 1000 }); + + const { status } = await callConnectProvider({ + provider: "gdrive", + code: "case5-expired-code", + state, + uid, + idToken, + }); + + expect(status).toBe(400); + }); +}); + +describe("6. connectProvider auth failures", () => { + it("returns 401 when idToken is missing, and persists nothing", async () => { + const { uid } = await signUpEmulatorUser(); + const state = await createStateDoc(); + + const { status } = await callConnectProvider({ + provider: "gdrive", + code: "case6-missing-idtoken", + state, + uid, + }); + + expect(status).toBe(401); + const userData = await getUserData(uid); + expect(userData?.connectedAccounts?.gdrive).toBeUndefined(); + }); + + it("returns 403 when idToken belongs to a different emulator user than uid, and persists nothing", async () => { + const userA = await signUpEmulatorUser(); + const userB = await signUpEmulatorUser(); + const state = await createStateDoc(); + + const { status } = await callConnectProvider({ + provider: "gdrive", + code: "case6-mismatched-user", + state, + uid: userA.uid, + idToken: userB.idToken, + }); + + expect(status).toBe(403); + const userAData = await getUserData(userA.uid); + expect(userAData?.connectedAccounts?.gdrive).toBeUndefined(); + }); +}); + +describe("7. token exchange without a refresh_token", () => { + it("returns 400 and persists nothing (half-connected-account guard)", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const state = await createStateDoc(); + + mockTokenServer.setNextResponse(200, { + access_token: "case7-access-token", + expires_in: 3600, + // no refresh_token -- Google only issues one with access_type=offline&prompt=consent + }); + + const { status, body } = await callConnectProvider({ + provider: "gdrive", + code: "case7-no-refresh-code", + state, + uid, + idToken, + }); + + expect(status).toBe(400); + expect(body.error).toBe("Token exchange failed"); + + const userData = await getUserData(uid); + expect(userData?.connectedAccounts?.gdrive).toBeUndefined(); + }); +}); + +describe("8. connectProvider does not clobber sibling providers", () => { + it("leaves a pre-existing connectedAccounts.dataverse entry intact after connecting gdrive", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const dataverseEntry = { + authMethod: "static-token", + encryptedToken: "dataverse-token-placeholder", + serverUrl: "https://demo.dataverse.org", + }; + await db + .collection("users") + .doc(uid) + .set({ connectedAccounts: { dataverse: dataverseEntry } }, { merge: true }); + + const { body: stateBody } = await generateState("gdrive"); + mockTokenServer.setNextResponse(200, { + access_token: "case8-access-token", + refresh_token: "case8-refresh-token", + expires_in: 3600, + }); + + const { status } = await callConnectProvider({ + provider: "gdrive", + code: "case8-code", + state: stateBody.state, + uid, + idToken, + }); + + expect(status).toBe(200); + const userData = await getUserData(uid); + expect(userData.connectedAccounts.dataverse).toEqual(dataverseEntry); + expect(userData.connectedAccounts.gdrive).toBeDefined(); + }); +}); + +describe("9. disconnectProvider", () => { + it("removes connectedAccounts.gdrive while leaving siblings intact", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const dataverseEntry = { + authMethod: "static-token", + encryptedToken: "dataverse-token-placeholder-9", + serverUrl: "https://demo.dataverse.org", + }; + await db + .collection("users") + .doc(uid) + .set({ + connectedAccounts: { + gdrive: { + authMethod: "oauth2", + encryptedToken: "pre-existing-gdrive-token", + encryptedRefreshToken: "pre-existing-gdrive-refresh", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + }, + dataverse: dataverseEntry, + }, + }); + + const { status, body } = await callDisconnectProvider({ provider: "gdrive", uid, idToken }); + + expect(status).toBe(200); + expect(body).toEqual({ success: true }); + + const userData = await getUserData(uid); + expect(userData.connectedAccounts.gdrive).toBeUndefined(); + expect(userData.connectedAccounts.dataverse).toEqual(dataverseEntry); + }); + + it("returns 403 for a wrong-user idToken and leaves the entry untouched", async () => { + const userA = await signUpEmulatorUser(); + const userB = await signUpEmulatorUser(); + const gdriveEntry = { + authMethod: "oauth2", + encryptedToken: "userA-gdrive-token", + encryptedRefreshToken: "userA-gdrive-refresh", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + }; + await db.collection("users").doc(userA.uid).set({ connectedAccounts: { gdrive: gdriveEntry } }); + + const { status } = await callDisconnectProvider({ + provider: "gdrive", + uid: userA.uid, + idToken: userB.idToken, + }); + + expect(status).toBe(403); + const userAData = await getUserData(userA.uid); + expect(userAData.connectedAccounts.gdrive).toEqual(gdriveEntry); + }); +}); diff --git a/functions/src/__tests__/oauth-connect-refresh-emulator.test.js b/functions/src/__tests__/oauth-connect-refresh-emulator.test.js new file mode 100644 index 0000000..bee1eed --- /dev/null +++ b/functions/src/__tests__/oauth-connect-refresh-emulator.test.js @@ -0,0 +1,191 @@ +/** + * @jest-environment node + */ + +// RED-phase test for step 4b (scratchpad/step4b-oauth-connect-spec.md), case +// 10 of the test plan. +// +// refreshExpiringGdriveTokens does not exist yet -- the spec calls for it to +// be extracted out of scheduled-token-refresh.ts (which today only knows the +// OSF refresh pass) as a new exported helper, sharing its actual refresh +// logic with resolve-token.ts via a shared providers/gdrive-oauth.ts module. +// Importing a named export that the compiled module doesn't provide fails at +// ESM module-evaluation time ("does not provide an export named +// 'refreshExpiringGdriveTokens'"), which fails EVERY test in whatever file +// performs the import -- so this case lives in its own file, per the +// build-step instructions, to keep that red from masking the +// connectProvider/disconnectProvider/generateOAuthState cases in +// oauth-connect-emulator.test.js or the OSF regression guard in +// oauth-connect-scheduled-regression.test.js. +// +// This test never touches port 3580 (the mock token server used by the +// connectProvider tests): it mocks global.fetch directly, in-process, the +// same way resolve-token-gdrive.test.js's cases 9-10 do for resolveToken's +// gdrive refresh path. TOKEN_ENCRYPTION_KEY/GDRIVE_* env vars here are +// scoped to this jest process only and don't need to match +// functions/.env.datapipe-test (which is loaded by the separate Functions- +// emulator process for the HTTP-endpoint tests). + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import { refreshExpiringGdriveTokens } from "../../lib/scheduled-token-refresh.js"; +import { decrypt } from "../../lib/crypto-utils.js"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; + +let db; +const ORIGINAL_FETCH = global.fetch; +const ORIGINAL_ENV = { + TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, + GDRIVE_TOKEN_URL: process.env.GDRIVE_TOKEN_URL, + GDRIVE_CLIENT_ID: process.env.GDRIVE_CLIENT_ID, + GDRIVE_CLIENT_SECRET: process.env.GDRIVE_CLIENT_SECRET, +}; + +beforeAll(async () => { + let app; + try { + app = getApp("oauth-connect-refresh-test"); + } catch { + app = initializeApp(config, "oauth-connect-refresh-test"); + } + db = getFirestore(app); + + // Scoped to this process only -- mirrors resolve-token-gdrive.test.js. + process.env.TOKEN_ENCRYPTION_KEY = "22".repeat(32); + process.env.GDRIVE_TOKEN_URL = "https://gdrive-token.mock.test/token"; + process.env.GDRIVE_CLIENT_ID = "test-gdrive-client-id-refresh"; + process.env.GDRIVE_CLIENT_SECRET = "test-gdrive-client-secret-refresh"; +}); + +afterAll(() => { + process.env.TOKEN_ENCRYPTION_KEY = ORIGINAL_ENV.TOKEN_ENCRYPTION_KEY; + process.env.GDRIVE_TOKEN_URL = ORIGINAL_ENV.GDRIVE_TOKEN_URL; + process.env.GDRIVE_CLIENT_ID = ORIGINAL_ENV.GDRIVE_CLIENT_ID; + process.env.GDRIVE_CLIENT_SECRET = ORIGINAL_ENV.GDRIVE_CLIENT_SECRET; + global.fetch = ORIGINAL_FETCH; +}); + +// The helper legitimately scans the ENTIRE users collection, and the +// Firestore emulator is shared across parallel jest workers — other suites +// (resolve-token-gdrive, gdrive-emulator, oauth-connect) seed their own +// gdrive users, some deliberately expired, which this helper WILL pick up +// mid-run. Two isolation rules follow: +// 1. Never assert on global fetch call counts — only on calls carrying +// THIS test's (unique) refresh token. +// 2. The fetch mock must answer foreign refresh tokens with a failure — +// the helper's failure path leaves the user untouched, so we can't +// corrupt another suite's seeded tokens. +function installDiscriminatingFetchMock(ownToken, ownResponse) { + global.fetch = jest.fn(async (_url, options) => { + const params = new URLSearchParams(options?.body); + if (params.get("refresh_token") === ownToken) { + return ownResponse; + } + return { ok: false, status: 400, text: () => Promise.resolve("foreign-token-rejected") }; + }); +} + +function callsForToken(token) { + return global.fetch.mock.calls.filter(([, options]) => { + const params = new URLSearchParams(options?.body); + return params.get("refresh_token") === token; + }); +} + +const WINDOW_MS = 10 * 60 * 1000; // matches the spec's default window + +async function createGdriveUser(uid, overrides = {}) { + const gdrive = { + authMethod: "oauth2", + encryptedToken: "plain-access-token", + encryptedRefreshToken: "plain-refresh-token", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + ...overrides, + }; + await db.collection("users").doc(uid).set({ connectedAccounts: { gdrive } }); + return gdrive; +} + +async function getUserData(uid) { + const snap = await db.collection("users").doc(uid).get(); + return snap.data(); +} + +describe("10. refreshExpiringGdriveTokens", () => { + it("refreshes and persists a new encrypted token for a user inside the window", async () => { + const uid = `refresh-window-${randomUUID()}`; + const ownRefreshToken = `own-refresh-${randomUUID()}`; + await createGdriveUser(uid, { + tokenExpiresAt: Date.now() + 2 * 60 * 1000, + encryptedRefreshToken: ownRefreshToken, + }); + + installDiscriminatingFetchMock(ownRefreshToken, { + ok: true, + json: () => + Promise.resolve({ + access_token: "refreshed-token", + expires_in: 3600, + refresh_token: "refreshed-refresh-token", + }), + }); + + const before = Date.now(); + await refreshExpiringGdriveTokens(WINDOW_MS); + const after = Date.now(); + + expect(callsForToken(ownRefreshToken)).toHaveLength(1); + const persisted = await getUserData(uid); + const gdrive = persisted.connectedAccounts.gdrive; + expect(decrypt(gdrive.encryptedToken)).toBe("refreshed-token"); + expect(decrypt(gdrive.encryptedRefreshToken)).toBe("refreshed-refresh-token"); + expect(gdrive.tokenExpiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000 - 5000); + expect(gdrive.tokenExpiresAt).toBeLessThanOrEqual(after + 3600 * 1000 + 5000); + }); + + it("leaves a user outside the window untouched and never calls fetch for them", async () => { + const uid = `refresh-outside-${randomUUID()}`; + const ownRefreshToken = `outside-refresh-${randomUUID()}`; + const original = await createGdriveUser(uid, { + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + encryptedRefreshToken: ownRefreshToken, + }); + + installDiscriminatingFetchMock(ownRefreshToken, { + ok: true, + json: () => Promise.resolve({ access_token: "should-never-be-used", expires_in: 3600 }), + }); + + await refreshExpiringGdriveTokens(WINDOW_MS); + + expect(callsForToken(ownRefreshToken)).toHaveLength(0); + const persisted = await getUserData(uid); + expect(persisted.connectedAccounts.gdrive).toEqual(original); + }); + + it("leaves the connection as-was and does not throw when the refresh request fails", async () => { + const uid = `refresh-fail-${randomUUID()}`; + const ownRefreshToken = `fail-refresh-${randomUUID()}`; + const original = await createGdriveUser(uid, { + tokenExpiresAt: Date.now() + 2 * 60 * 1000, + encryptedRefreshToken: ownRefreshToken, + }); + + installDiscriminatingFetchMock(ownRefreshToken, { + ok: false, + status: 400, + text: () => Promise.resolve("invalid_grant"), + }); + + await expect(refreshExpiringGdriveTokens(WINDOW_MS)).resolves.not.toThrow(); + + expect(callsForToken(ownRefreshToken)).toHaveLength(1); + const persisted = await getUserData(uid); + expect(persisted.connectedAccounts.gdrive).toEqual(original); + }); +}); diff --git a/functions/src/__tests__/oauth-connect-scheduled-regression.test.js b/functions/src/__tests__/oauth-connect-scheduled-regression.test.js new file mode 100644 index 0000000..06c9502 --- /dev/null +++ b/functions/src/__tests__/oauth-connect-scheduled-regression.test.js @@ -0,0 +1,90 @@ +/** + * @jest-environment node + */ + +// RED-phase regression guard for step 4b (scratchpad/step4b-oauth-connect- +// spec.md), case 11 of the test plan. +// +// scheduledTokenRefresh (scheduled-token-refresh.ts) is an onSchedule +// function. The Functions emulator exposes scheduled functions for manual +// invocation over HTTP at the same host:port/project/region/name URL as any +// other function (the Local Emulator Suite's documented manual-trigger +// support for onSchedule functions). This test pins its EXISTING, +// OSF-only observable behavior for one user, so that case 10's gdrive +// generalization -- which the spec requires to wrap the new gdrive pass so +// a gdrive failure "can't break the OSF pass" -- cannot silently change it. +// +// It is kept in its own file (not sharing oauth-connect-refresh-emulator. +// test.js) precisely because that file imports a not-yet-existing named +// export and fails at module load, which would fail every test in the file +// -- this regression guard needs to run and report its own, independent +// result. +// +// IMPORTANT deviation from a literal reading of the spec: NEXT_PUBLIC_OSF_ENV +// is "" (not unset) in functions/.env (loaded ahead of .env.datapipe-test, +// which doesn't override it), so refresh-token.ts's hardcoded token URL +// resolves to the REAL "https://accounts.osf.io/oauth2/token" -- and .env +// also carries real-looking CLIENT_ID/CLIENT_SECRET values. Empirically +// invoking the scheduled function against a seeded user whose refresh token +// is due for refresh made a genuine network call to production OSF (it +// returned {"error":"invalid_request"} for the bogus refresh token). Doing +// that from a repeatable test/CI run is exactly the kind of non-hermetic, +// production-touching side effect this suite should avoid -- there is no +// GDRIVE_TOKEN_URL-style override for OSF's endpoint to redirect it to a +// local mock. So instead of pinning the live-refresh branch, this test pins +// the query + per-user-skip behavior that scheduled-token-refresh.ts already +// has for a user with no refreshToken (see its "if (!userData.refreshToken) +// ... continue" branch): the user still matches the query but is skipped +// before any network call happens, which is fully safe to exercise here. +// The live-refresh branch itself could not be safely pinned without hitting +// production OSF -- flagged in the build-step report rather than forced. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; +// Verified empirically against this repo's firebase-tools (15.8.0): since +// firebase.json's emulator suite doesn't include the Pub/Sub emulator, +// onSchedule functions are registered for manual HTTP invocation under a +// "-" suffixed trigger name (the emulator logs "function ignored +// because the pubsub emulator does not exist or is not running" for the +// bare name, but still lists "...-0" as a valid HTTP target and executes it +// normally when POSTed). The bare, unsuffixed name 404s. +const SCHEDULED_URL = "http://localhost:5001/datapipe-test/us-central1/scheduledtokenrefresh-0"; + +let db; + +beforeAll(async () => { + let app; + try { + app = getApp("oauth-connect-scheduled-regression-test"); + } catch { + app = initializeApp(config, "oauth-connect-scheduled-regression-test"); + } + db = getFirestore(app); +}); + +describe("11. OSF scheduled pass (regression guard)", () => { + it("matches an OAuth user within the expiration window who has no refreshToken, skips them without a network call, and leaves the doc untouched -- expected to PASS today", async () => { + const uid = `scheduled-osf-regression-${randomUUID()}`; + const original = { + email: `${uid}@example.test`, + usingPersonalToken: false, + refreshTokenExpires: Date.now(), // within the 2-week window -- matches the query + // refreshToken deliberately absent -- exercises the existing + // "if (!userData.refreshToken) { ...; continue; }" skip branch, so + // this never reaches the OSF token endpoint. + }; + await db.collection("users").doc(uid).set(original); + + const response = await fetch(SCHEDULED_URL, { method: "POST" }); + expect(response.ok).toBe(true); + + const persisted = (await db.collection("users").doc(uid).get()).data(); + expect(persisted).toEqual(original); + }); +}); diff --git a/functions/src/connect-provider.ts b/functions/src/connect-provider.ts new file mode 100644 index 0000000..9bba0f5 --- /dev/null +++ b/functions/src/connect-provider.ts @@ -0,0 +1,193 @@ +// Provider connect/disconnect flow (docs/provider-migration-design.md, +// scratchpad/step4b-oauth-connect-spec.md). +// +// This is a storage GRANT flow for already-authenticated users, distinct +// from oauth2-callback.ts's OSF IDENTITY flow (signup/sign-in/account +// linking, Firebase custom tokens). There is no signup path here, ever — +// the caller must already hold a valid Firebase idToken for the uid they +// claim. + +import { onRequest } from "firebase-functions/v2/https"; +import { FieldValue } from "firebase-admin/firestore"; +import { db, auth } from "./app.js"; +import { encrypt } from "./crypto-utils.js"; +import { getOAuthConfig } from "./providers/oauth-config.js"; + +type AuthCheckResult = + | { ok: true } + | { ok: false; status: number; error: string }; + +async function verifyOwnership(uid: string, idToken: string | undefined): Promise { + if (!idToken) { + return { ok: false, status: 401, error: 'Authentication required' }; + } + try { + const decodedToken = await auth.verifyIdToken(idToken); + if (decodedToken.uid !== uid) { + return { ok: false, status: 403, error: 'User ID does not match authenticated user' }; + } + return { ok: true }; + } catch { + return { ok: false, status: 401, error: 'Invalid authentication token' }; + } +} + +export const connectProvider = onRequest({ cors: true }, async (req, res) => { + try { + if (req.method !== 'POST') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + const { provider, code, state, uid, idToken } = req.body || {}; + + if (!provider || !code || !state || !uid) { + res.status(400).json({ error: 'Missing required parameters' }); + return; + } + + let config; + try { + config = getOAuthConfig(provider); + } catch { + res.status(400).json({ error: 'Unknown provider' }); + return; + } + + // Server-side CSRF validation: verify the state was issued by our + // server. Same oauth_states collection + semantics as the OSF callback + // (oauth2-callback.ts): exists, not expired, single-use delete. + const stateRef = db.collection('oauth_states').doc(state); + const stateDoc = await stateRef.get(); + if (!stateDoc.exists) { + res.status(400).json({ error: 'Invalid state parameter' }); + return; + } + const stateData = stateDoc.data(); + if (stateData && stateData.expiresAt < Date.now()) { + await stateRef.delete(); + res.status(400).json({ error: 'State parameter has expired' }); + return; + } + // Delete after use — each state token is single-use. + await stateRef.delete(); + + // The state must have been issued for this exact provider (a legacy + // OSF state, issued with no provider at all, must never be accepted + // here). + if (!stateData || stateData.provider !== provider) { + res.status(400).json({ error: 'State was not issued for this provider' }); + return; + } + + // Verify that the caller owns the uid they claim. No signup path here. + const authCheck = await verifyOwnership(uid, idToken); + if (!authCheck.ok) { + res.status(authCheck.status).json({ error: authCheck.error }); + return; + } + + // Exchange the authorization code for tokens. + const params = new URLSearchParams({ + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: config.redirectUri, + grant_type: 'authorization_code', + }); + + let tokenResponse: Response; + try { + tokenResponse = await fetch(config.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + } catch (e) { + console.error('Token exchange network error:', e instanceof Error ? e.message : 'Unknown error'); + res.status(400).json({ error: 'Token exchange failed' }); + return; + } + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text(); + console.error('Token exchange failed:', errorText); + res.status(400).json({ error: 'Token exchange failed' }); + return; + } + + const tokenData = await tokenResponse.json(); + + // Hard-fail on a missing refresh_token: Google only issues one with + // access_type=offline&prompt=consent, so its absence means we'd + // otherwise persist a half-connected account with no way to refresh. + if (!tokenData.access_token || !tokenData.refresh_token || !tokenData.expires_in) { + res.status(400).json({ error: 'Token exchange failed' }); + return; + } + + // Dot-path persist via set()+mergeFields: creates users/{uid} if it + // doesn't exist yet (a freshly-signed-up user may have no Firestore + // doc at all), while touching only connectedAccounts. and + // leaving any sibling provider connections untouched. + const fieldPath = `connectedAccounts.${provider}`; + await db.doc(`users/${uid}`).set( + { + connectedAccounts: { + [provider]: { + authMethod: 'oauth2', + encryptedToken: encrypt(tokenData.access_token), + encryptedRefreshToken: encrypt(tokenData.refresh_token), + tokenExpiresAt: Date.now() + tokenData.expires_in * 1000, + }, + }, + }, + { mergeFields: [fieldPath] } + ); + + res.status(200).json({ success: true, provider }); + } catch (error) { + console.error('Error connecting provider:', error instanceof Error ? error.message : 'Unknown error'); + res.status(500).json({ error: 'Failed to connect provider' }); + } +}); + +export const disconnectProvider = onRequest({ cors: true }, async (req, res) => { + try { + if (req.method !== 'POST') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + const { provider, uid, idToken } = req.body || {}; + + if (!provider || !uid) { + res.status(400).json({ error: 'Missing required parameters' }); + return; + } + + try { + getOAuthConfig(provider); + } catch { + res.status(400).json({ error: 'Unknown provider' }); + return; + } + + const authCheck = await verifyOwnership(uid, idToken); + if (!authCheck.ok) { + res.status(authCheck.status).json({ error: authCheck.error }); + return; + } + + await db.doc(`users/${uid}`).update({ + [`connectedAccounts.${provider}`]: FieldValue.delete(), + }); + + res.status(200).json({ success: true }); + } catch (error) { + console.error('Error disconnecting provider:', error instanceof Error ? error.message : 'Unknown error'); + res.status(500).json({ error: 'Failed to disconnect provider' }); + } +}); diff --git a/functions/src/generate-oauth-state.ts b/functions/src/generate-oauth-state.ts index 78d3b61..610324f 100644 --- a/functions/src/generate-oauth-state.ts +++ b/functions/src/generate-oauth-state.ts @@ -1,5 +1,6 @@ import { onRequest } from "firebase-functions/v2/https"; import { db } from "./app.js"; +import { getOAuthConfig } from "./providers/oauth-config.js"; export const generateOAuthState = onRequest({ cors: true }, async (req, res) => { try { @@ -8,17 +9,53 @@ export const generateOAuthState = onRequest({ cors: true }, async (req, res) => return; } + // Optional `provider` in the body. Absent → exactly today's legacy + // behavior (the OSF flow keeps working byte-identically): a bare + // { state } response and a state doc with no provider field. + const { provider } = req.body || {}; + const state = crypto.randomUUID(); + const stateData: Record = { + createdAt: Date.now(), + expiresAt: Date.now() + 10 * 60 * 1000, + }; + + let authorizeUrl: string | undefined; + + if (provider) { + let config; + try { + config = getOAuthConfig(provider); + } catch { + res.status(400).json({ error: 'Unknown provider' }); + return; + } + + stateData.provider = provider; + + const url = new URL(config.authorizeUrl); + url.searchParams.set('client_id', config.clientId); + url.searchParams.set('redirect_uri', config.redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', config.scope); + url.searchParams.set('state', state); + for (const [key, value] of Object.entries(config.extraAuthParams)) { + url.searchParams.set(key, value); + } + authorizeUrl = url.toString(); + } // Store in Firestore with a 10-minute expiry. // The callback endpoint will look this up to verify the state // was actually issued by our server, then delete it (single-use). - await db.collection('oauth_states').doc(state).set({ - createdAt: Date.now(), - expiresAt: Date.now() + 10 * 60 * 1000, - }); + await db.collection('oauth_states').doc(state).set(stateData); + + const response: Record = { state }; + if (authorizeUrl) { + response.authorizeUrl = authorizeUrl; + } - res.status(200).json({ state }); + res.status(200).json(response); } catch (error) { console.error('Error generating OAuth state:', error instanceof Error ? error.message : 'Unknown error'); res.status(500).json({ error: 'Failed to generate state' }); diff --git a/functions/src/index.ts b/functions/src/index.ts index ecbb6c1..40adaf6 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -11,6 +11,7 @@ import { scheduledUploadRetry } from "./scheduled-upload-retry.js"; import { scheduledPendingRecovery } from "./scheduled-pending-recovery.js"; import { apiQueueStatus } from "./api-queue-status.js"; import { generateOAuthState } from "./generate-oauth-state.js"; +import { connectProvider, disconnectProvider } from "./connect-provider.js"; import { saveOsfToken } from "./save-osf-token.js"; import { getOsfToken } from "./get-osf-token.js"; import { onUserDeleted } from "./on-user-deleted.js"; @@ -31,6 +32,8 @@ export { scheduledPendingRecovery as scheduledpendingrecovery, apiQueueStatus as apiqueuestatus, generateOAuthState as generateoauthstate, + connectProvider as connectprovider, + disconnectProvider as disconnectprovider, saveOsfToken as saveosftoken, getOsfToken as getosftoken, onUserDeleted as onuserdeleted diff --git a/functions/src/providers/gdrive-oauth.ts b/functions/src/providers/gdrive-oauth.ts new file mode 100644 index 0000000..3a80b66 --- /dev/null +++ b/functions/src/providers/gdrive-oauth.ts @@ -0,0 +1,75 @@ +// Shared gdrive OAuth token-refresh logic (scratchpad/step4b-oauth-connect- +// spec.md). Extracted out of resolve-token.ts so the same refresh+persist +// path can be called both lazily (resolve-token.ts, on-demand when a token +// has expired) and proactively (scheduled-token-refresh.ts's +// refreshExpiringGdriveTokens, run on a schedule ahead of expiry). +// +// Uses the runtime's global `fetch`, not the "node-fetch" package — matches +// resolve-token.ts's existing OSF refresh sibling (refresh-token.ts) and is +// pinned by resolve-token-gdrive.test.js, which mocks global.fetch. + +import { decrypt, encrypt } from "../crypto-utils.js"; +import { db } from "../app.js"; +import { OAuth2AccountConnection } from "./types.js"; + +export type GdriveRefreshResult = + | { success: true; accessToken: string } + | { success: false; error: string; detail: string }; + +/** + * Refreshes a single user's gdrive access token using their stored refresh + * token, and persists the new (encrypted) access token / expiry — rotating + * the refresh token too, if the provider issued a new one. Does not check + * whether the current token is actually expired; callers decide when to + * invoke this. + */ +export async function refreshGdriveToken( + uid: string, + connection: OAuth2AccountConnection +): Promise { + const tokenUrl = process.env.GDRIVE_TOKEN_URL || "https://oauth2.googleapis.com/token"; + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: decrypt(connection.encryptedRefreshToken), + client_id: process.env.GDRIVE_CLIENT_ID as string, + client_secret: process.env.GDRIVE_CLIENT_SECRET as string, + }); + + let tokenResponse: Response; + try { + tokenResponse = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown network error"; + return { success: false, error: "INVALID_REFRESH_TOKEN", detail }; + } + + if (!tokenResponse.ok) { + const detail = await tokenResponse.text(); + return { success: false, error: "INVALID_REFRESH_TOKEN", detail: detail || "Refresh token is not valid" }; + } + + const tokenData = await tokenResponse.json(); + + const newTokenExpiresAt = Date.now() + tokenData.expires_in * 1000; + + const update: Record = { + "connectedAccounts.gdrive.encryptedToken": encrypt(tokenData.access_token), + "connectedAccounts.gdrive.tokenExpiresAt": newTokenExpiresAt, + }; + + // Only rotate the refresh token when the provider actually issued a new + // one — otherwise leave the existing one in place. + if (tokenData.refresh_token) { + update["connectedAccounts.gdrive.encryptedRefreshToken"] = encrypt(tokenData.refresh_token); + } + + await db.doc(`users/${uid}`).update(update); + + return { success: true, accessToken: tokenData.access_token }; +} diff --git a/functions/src/providers/oauth-config.ts b/functions/src/providers/oauth-config.ts new file mode 100644 index 0000000..ee5a1de --- /dev/null +++ b/functions/src/providers/oauth-config.ts @@ -0,0 +1,44 @@ +// Provider OAuth registry (docs/provider-migration-design.md, +// scratchpad/step4b-oauth-connect-spec.md). +// +// Only 'gdrive' is registered today. 'osf' deliberately has no entry here — +// the OSF identity flow (oauth2-callback.ts) is a separate, untouched +// legacy path with its own env vars (CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). +// Structured so a provider like figshare is a config addition, not a +// rewrite. + +export interface OAuthConfig { + authorizeUrl: string; + tokenUrl: string; + clientId: string; + clientSecret: string; + redirectUri: string; + scope: string; + extraAuthParams: Record; +} + +// Each entry is a factory (not a plain object) so env vars are read at +// CALL time, not module load — mirrors providers/gdrive.ts's getApiBase(). +const CONFIG_FACTORIES: Record OAuthConfig> = { + gdrive: () => ({ + authorizeUrl: + process.env.GDRIVE_AUTHORIZE_URL || "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: process.env.GDRIVE_TOKEN_URL || "https://oauth2.googleapis.com/token", + clientId: process.env.GDRIVE_CLIENT_ID as string, + clientSecret: process.env.GDRIVE_CLIENT_SECRET as string, + redirectUri: process.env.GDRIVE_REDIRECT_URI as string, + scope: "https://www.googleapis.com/auth/drive.file", + // Without these, Google won't issue a refresh_token on the consent + // grant — a half-connected account (access token, no refresh token) + // is treated as a hard failure downstream. + extraAuthParams: { access_type: "offline", prompt: "consent" }, + }), +}; + +export function getOAuthConfig(provider: string): OAuthConfig { + const factory = CONFIG_FACTORIES[provider]; + if (!factory) { + throw new Error(`Unknown or unsupported OAuth provider: ${provider}`); + } + return factory(); +} diff --git a/functions/src/resolve-token.ts b/functions/src/resolve-token.ts index 2ca0033..47884cb 100644 --- a/functions/src/resolve-token.ts +++ b/functions/src/resolve-token.ts @@ -1,6 +1,6 @@ -import { decrypt, encrypt } from "./crypto-utils.js"; +import { decrypt } from "./crypto-utils.js"; import { refreshAndUpdateUser } from "./refresh-token.js"; -import { db } from "./app.js"; +import { refreshGdriveToken } from "./providers/gdrive-oauth.js"; import { ExperimentData, UserData } from './interfaces'; type TokenResult = { @@ -63,51 +63,13 @@ async function resolveGdriveToken( return { success: true, token: decrypt(gdrive.encryptedToken) }; } - const tokenUrl = process.env.GDRIVE_TOKEN_URL || "https://oauth2.googleapis.com/token"; - const params = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: decrypt(gdrive.encryptedRefreshToken), - client_id: process.env.GDRIVE_CLIENT_ID as string, - client_secret: process.env.GDRIVE_CLIENT_SECRET as string, - }); - - let tokenResponse: Response; - try { - tokenResponse = await fetch(tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: params.toString(), - }); - } catch (e) { - const detail = e instanceof Error ? e.message : "Unknown network error"; - return { success: false, error: "INVALID_REFRESH_TOKEN", detail }; - } - - if (!tokenResponse.ok) { - const detail = await tokenResponse.text(); - return { success: false, error: "INVALID_REFRESH_TOKEN", detail: detail || "Refresh token is not valid" }; - } - - const tokenData = await tokenResponse.json(); + const refreshResult = await refreshGdriveToken(exp_data.owner, gdrive); - const newTokenExpiresAt = Date.now() + tokenData.expires_in * 1000; - - const update: Record = { - "connectedAccounts.gdrive.encryptedToken": encrypt(tokenData.access_token), - "connectedAccounts.gdrive.tokenExpiresAt": newTokenExpiresAt, - }; - - // Only rotate the refresh token when the provider actually issued a new - // one — otherwise leave the existing one in place. - if (tokenData.refresh_token) { - update["connectedAccounts.gdrive.encryptedRefreshToken"] = encrypt(tokenData.refresh_token); + if (!refreshResult.success) { + return { success: false, error: refreshResult.error, detail: refreshResult.detail }; } - await db.doc(`users/${exp_data.owner}`).update(update); - - return { success: true, token: tokenData.access_token }; + return { success: true, token: refreshResult.accessToken }; } export default async function resolveToken( diff --git a/functions/src/scheduled-token-refresh.ts b/functions/src/scheduled-token-refresh.ts index 9d68824..1cf7afc 100644 --- a/functions/src/scheduled-token-refresh.ts +++ b/functions/src/scheduled-token-refresh.ts @@ -1,10 +1,54 @@ import { onSchedule } from "firebase-functions/v2/scheduler"; import { db } from "./app.js"; import { refreshAndUpdateUser } from "./refresh-token.js"; +import { refreshGdriveToken } from "./providers/gdrive-oauth.js"; import { decrypt } from "./crypto-utils.js"; import { UserData } from "./interfaces.js"; const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000; +const GDRIVE_DEFAULT_WINDOW_MS = 10 * 60 * 1000; + +/** + * Proactively refreshes gdrive access tokens for users whose token expires + * within `windowMs` (default 10 minutes). Mirrors the OSF pass's cadence + * convention, but on a much shorter window since gdrive access tokens are + * short-lived (~1 hour) rather than the ~1-month OSF refresh-token window. + * + * Failures are logged and skipped — a single user's refresh failure must + * never abort the whole pass, and (in 4b) there is no user-visible state + * change on failure: the connection is simply left as-is. + */ +export async function refreshExpiringGdriveTokens(windowMs: number = GDRIVE_DEFAULT_WINDOW_MS): Promise { + const expirationThreshold = Date.now() + windowMs; + + const usersSnapshot = await db + .collection("users") + .where("connectedAccounts.gdrive.tokenExpiresAt", "<", expirationThreshold) + .get(); + + if (usersSnapshot.empty) { + return; + } + + for (const userDoc of usersSnapshot.docs) { + const userData = userDoc.data() as UserData; + const gdrive = userData.connectedAccounts?.gdrive; + const userId = userDoc.id; + + if (!gdrive) { + continue; + } + + try { + const result = await refreshGdriveToken(userId, gdrive); + if (!result.success) { + console.error(`Failed to refresh gdrive token for user ${userId}: ${result.detail}`); + } + } catch (error) { + console.error(`Error refreshing gdrive token for user ${userId}:`, error); + } + } +} /** * Scheduled function that runs weekly to proactively refresh OAuth tokens @@ -68,4 +112,13 @@ export const scheduledTokenRefresh = onSchedule("0 2 * * 0", async () => { console.log( `Token refresh complete. Success: ${successCount}, Failed: ${failCount}` ); + + // gdrive pass runs after the OSF pass, wrapped so a gdrive-side failure + // (e.g. a query error) can never break/roll back the OSF pass above — + // refreshExpiringGdriveTokens itself already isolates per-user failures. + try { + await refreshExpiringGdriveTokens(); + } catch (error) { + console.error("Error during gdrive token refresh pass:", error); + } }); From 6b9eb59bef48df75613b63fdb6abba4cb865d9be Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Thu, 23 Jul 2026 10:03:47 -0400 Subject: [PATCH 10/14] feat: server-side experiment creation for provider-backed experiments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build step 7a. OSF experiment creation stays browser-driven and untouched; non-OSF providers get a server-side endpoint since their container creation (createDataContainer) is server-only. - create-experiment.ts: verifies idToken ownership, resolves the provider token, creates the storage container, and batch-writes the experiment doc + users.experiments arrayUnion with exact parity to the client's id format (12-char nanoid alphabet) and default fields (including the requiredFields ["trial_type"] default the client hardcodes); rejects osf/unknown providers - firestore.rules: verifyFields split into baseFields + a conditional hasAll — provider-shaped docs require storageProvider+providerContainer, legacy docs still require the OSF trio; users rules (and the clients-cannot-write-connectedAccounts guarantee) unchanged and pinned by a new rules test - scheduled-pending-recovery: promoteToQueue now passes storageProvider/providerContainer through with omit-if-undefined — fixes a real bug where recovery of a gdrive experiment's pending upload threw on undefined osfFilesLink and was silently swallowed - /api/createexperiment rewrite; verifyOwnership shared from connect-provider; nanoid added to functions deps; gdrive-emulator's mock server gained the same EADDRINUSE retry its port-3579 sibling uses TDD: 10 contract cases reviewed red first (rules gap, endpoint 404s, and the recovery bug all demonstrated before implementation). Full emulator suite green twice consecutively (29 suites, 205 tests). Co-Authored-By: Claude Fable 5 --- __tests__/firestore-rules.test.js | 146 +++++++ firebase.json | 4 + firestore.rules | 8 +- functions/package-lock.json | 19 + functions/package.json | 1 + .../create-experiment-emulator.test.js | 372 ++++++++++++++++++ .../src/__tests__/gdrive-emulator.test.js | 51 ++- ...nding-recovery-provider-regression.test.js | 131 ++++++ functions/src/connect-provider.ts | 4 +- functions/src/create-experiment.ts | 173 ++++++++ functions/src/index.ts | 4 +- functions/src/providers/index.ts | 2 +- functions/src/scheduled-pending-recovery.ts | 25 +- 13 files changed, 916 insertions(+), 24 deletions(-) create mode 100644 functions/src/__tests__/create-experiment-emulator.test.js create mode 100644 functions/src/__tests__/pending-recovery-provider-regression.test.js create mode 100644 functions/src/create-experiment.ts diff --git a/__tests__/firestore-rules.test.js b/__tests__/firestore-rules.test.js index 7259f34..00a97f1 100644 --- a/__tests__/firestore-rules.test.js +++ b/__tests__/firestore-rules.test.js @@ -121,4 +121,150 @@ describe('/experiments', () => { await assertFails(getDoc(doc(user123.firestore(), 'experiments/456'))); }); +}); + +// --------------------------------------------------------------------------- +// step 7a: firestore.rules generalization (scratchpad/step7a-create-endpoint- +// spec.md). These are ADDITIVE describe blocks -- none of the cases above are +// altered. They exercise the NEW rules shape (baseFields() minus the OSF trio +// plus a storageProvider/providerContainer-OR-osfRepo/osfComponent/ +// osfFilesLink conditional) that firestore.rules does not implement yet. +// +// Field-set parity: `baseFields()` below is the current verifyFields() hasAll +// list (['active', 'activeBase64', 'activeConditionAssignment', 'id', +// 'osfRepo', 'osfComponent', 'osfFilesLink', 'owner', 'title', 'sessions', +// 'nConditions', 'currentCondition', 'useValidation', 'allowJSON', 'allowCSV', +// 'requiredFields', 'maxSessions', 'limitSessions']) MINUS the three OSF +// fields, exactly as the spec's new baseFields() is defined to be. +// +// Expected-red summary (see build-step report for the verified run): +// - case 1 (legacy OSF create): both sub-cases already PASS today -- pinned +// regression guards, not exercising the gap. +// - case 2 (gdrive-shaped UPDATE succeeds for the owner): RED today -- current +// verifyFields() unconditionally requires osfRepo/osfComponent/osfFilesLink, +// which a gdrive-shaped doc never has. +// - case 3 (gdrive-shaped update validation): both assertFails sub-cases +// already hold true today, but not for the reason the new rules will +// enforce -- today ANY gdrive-shaped doc is denied (missing the OSF trio) +// regardless of providerContainer or ownership; they're pinned here as +// "must remain denied after the generalization too", not proof of the gap. +// - case 4 (/users hasOnly unchanged): already PASSes today -- pinned +// regression guard that clients still cannot write connectedAccounts. +describe('/experiments — provider-migration generalization (step 7a)', () => { + function baseFields(overrides = {}) { + return { + active: false, + activeBase64: false, + activeConditionAssignment: false, + id: overrides.id, + owner: overrides.owner, + title: 'Test experiment', + sessions: 0, + nConditions: 1, + currentCondition: 0, + useValidation: true, + allowJSON: true, + allowCSV: true, + requiredFields: [], + maxSessions: 1, + limitSessions: false, + ...overrides, + }; + } + + describe('1. legacy OSF experiment create (regression guard)', () => { + it('succeeds with all OSF fields present -- expected to PASS today and after generalization', async () => { + const docId = 'exp-7a-legacy-create-1'; + const user123 = testEnv.authenticatedContext('user123'); + + await assertSucceeds(setDoc(doc(user123.firestore(), `experiments/${docId}`), baseFields({ + id: docId, + owner: 'user123', + osfRepo: 'abc12', + osfComponent: 'def34', + osfFilesLink: 'https://files.osf.io/v1/resources/abc12/providers/osfstorage/', + }))); + }); + + it('fails when osfFilesLink is missing and no storageProvider is present -- pinned contract', async () => { + const docId = 'exp-7a-legacy-create-2'; + const user123 = testEnv.authenticatedContext('user123'); + + await assertFails(setDoc(doc(user123.firestore(), `experiments/${docId}`), baseFields({ + id: docId, + owner: 'user123', + osfRepo: 'abc12', + osfComponent: 'def34', + // osfFilesLink deliberately omitted; no storageProvider either. + }))); + }); + }); + + describe('2. gdrive-shaped experiment update by owner', () => { + it('succeeds for the owner when the doc carries storageProvider + providerContainer instead of OSF fields', async () => { + const docId = 'exp-7a-gdrive-update-1'; + await seedDB({ + [`experiments/${docId}`]: baseFields({ + id: docId, + owner: 'user123', + storageProvider: 'gdrive', + providerContainer: { provider: 'gdrive', folderId: 'folder-abc' }, + }), + }); + + const user123 = testEnv.authenticatedContext('user123'); + await assertSucceeds( + updateDoc(doc(user123.firestore(), `experiments/${docId}`), { active: true }) + ); + }); + }); + + describe('3. gdrive-shaped update validation', () => { + it('fails when providerContainer is missing even though storageProvider is present', async () => { + const docId = 'exp-7a-gdrive-update-2'; + await seedDB({ + [`experiments/${docId}`]: baseFields({ + id: docId, + owner: 'user123', + storageProvider: 'gdrive', + // providerContainer deliberately omitted. + }), + }); + + const user123 = testEnv.authenticatedContext('user123'); + await assertFails( + updateDoc(doc(user123.firestore(), `experiments/${docId}`), { active: true }) + ); + }); + + it('fails when a non-owner attempts to update a gdrive-shaped experiment', async () => { + const docId = 'exp-7a-gdrive-update-3'; + await seedDB({ + [`experiments/${docId}`]: baseFields({ + id: docId, + owner: 'user123', + storageProvider: 'gdrive', + providerContainer: { provider: 'gdrive', folderId: 'folder-abc' }, + }), + }); + + const user456 = testEnv.authenticatedContext('user456'); + await assertFails( + updateDoc(doc(user456.firestore(), `experiments/${docId}`), { active: true }) + ); + }); + }); + + describe('4. /users hasOnly unchanged (regression guard)', () => { + it('rejects account-creation writes that include connectedAccounts -- clients can never write it', async () => { + const user123 = testEnv.authenticatedContext('user123'); + + await assertFails(setDoc(doc(user123.firestore(), 'users/user123'), { + email: 'john@doe.com', + experiments: ['exp1'], + osfToken: '', + connectedAccounts: { gdrive: { authMethod: 'oauth2' } }, + })); + }); + }); }); \ No newline at end of file diff --git a/firebase.json b/firebase.json index d5122af..a5a24f2 100644 --- a/firebase.json +++ b/firebase.json @@ -53,6 +53,10 @@ { "source": "/api/queuestatus", "function": "apiqueuestatus" + }, + { + "source": "/api/createexperiment", + "function": "createexperiment" } ] }, diff --git a/firestore.rules b/firestore.rules index ba93a78..aebdb59 100644 --- a/firestore.rules +++ b/firestore.rules @@ -17,8 +17,14 @@ service cloud.firestore { (isAccountCreation() || isTokenMethodUpdate() || isExperimentsUpdate()); } match /experiments/{experimentId} { + function baseFields() { + return request.resource.data.keys().hasAll(['active', 'activeBase64', 'activeConditionAssignment', 'id', 'owner', 'title', 'sessions', 'nConditions', 'currentCondition', 'useValidation', 'allowJSON', 'allowCSV', 'requiredFields', 'maxSessions', 'limitSessions']) + } function verifyFields() { - return request.resource.data.keys().hasAll(['active', 'activeBase64', 'activeConditionAssignment', 'id', 'osfRepo', 'osfComponent', 'osfFilesLink', 'owner', 'title', 'sessions', 'nConditions', 'currentCondition', 'useValidation', 'allowJSON', 'allowCSV', 'requiredFields', 'maxSessions', 'limitSessions']) + return baseFields() && + (('storageProvider' in request.resource.data) + ? request.resource.data.keys().hasAll(['storageProvider', 'providerContainer']) + : request.resource.data.keys().hasAll(['osfRepo', 'osfComponent', 'osfFilesLink'])); } allow read: if(request.auth.uid != null) && resource.data.owner == request.auth.uid; diff --git a/functions/package-lock.json b/functions/package-lock.json index a89b634..2d79546 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -15,6 +15,7 @@ "firebase-functions": "^7.2.2", "is-base64": "^1.1.0", "joi": "^17.7.0", + "nanoid": "^5.1.16", "node-fetch": "^3.2.10" }, "devDependencies": { @@ -13021,6 +13022,24 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", diff --git a/functions/package.json b/functions/package.json index 55da299..dc05543 100644 --- a/functions/package.json +++ b/functions/package.json @@ -26,6 +26,7 @@ "firebase-functions": "^7.2.2", "is-base64": "^1.1.0", "joi": "^17.7.0", + "nanoid": "^5.1.16", "node-fetch": "^3.2.10" }, "devDependencies": { diff --git a/functions/src/__tests__/create-experiment-emulator.test.js b/functions/src/__tests__/create-experiment-emulator.test.js new file mode 100644 index 0000000..da788ed --- /dev/null +++ b/functions/src/__tests__/create-experiment-emulator.test.js @@ -0,0 +1,372 @@ +/** + * @jest-environment node + */ + +// RED-phase integration tests for step 7a (scratchpad/step7a-create-endpoint- +// spec.md), cases 5-9 of the test plan. +// +// createExperiment (functions/src/create-experiment.ts) does not exist yet: +// it isn't implemented, isn't exported from index.ts, and has no +// firebase.json rewrite. Every request to its emulator URL therefore 404s +// today -- a missing-behavior failure, not a mock-server/transport bug. +// Following the lowercase function-name convention (apiData -> apidata, +// connectProvider -> connectprovider), the URL under test is +// http://localhost:5001/datapipe-test/us-central1/createexperiment. +// +// Mock Google Drive: createDataContainer's two calls (find-or-create the +// shared "DataPipe" root folder, then always-create the experiment folder) +// are served by a fixed-port (3579) express server, reusing the same +// GDRIVE_API_BASE=http://127.0.0.1:3579 wiring in functions/.env.datapipe-test +// that gdrive-emulator.test.js's mock Drive server already uses -- there is +// only one GDRIVE_API_BASE for the whole Functions-emulator process, so any +// gdrive-touching suite in this test-ci run must bind that same address. +// Because gdrive-emulator.test.js's suite ALSO binds port 3579 and Jest may +// schedule the two test files onto different, truly-concurrent workers, this +// file's listen() retries on EADDRINUSE (with backoff) instead of assuming +// the port is free -- whichever suite starts first grabs it, the other waits +// for that suite's afterAll() to release it. This is defensive, not a claim +// that the two suites are meant to run interleaved: no two tests actually +// hold the port at the same instant. +// +// Auth: real Auth-emulator idTokens via accounts:signUp, exactly like +// oauth-connect-emulator.test.js's signUpEmulatorUser helper -- the future +// createExperiment endpoint is spec'd to verify ownership the same way +// connect-provider.ts's verifyOwnership does (401 missing, 403 mismatch). +// +// connectedAccounts.gdrive is seeded with a bare-plaintext encryptedToken +// (no "v1:" prefix), relying on crypto-utils.ts's decrypt() plaintext +// fallback -- same convention as gdrive-emulator.test.js, sidestepping the +// need for this process and the Functions-emulator child process to agree on +// TOKEN_ENCRYPTION_KEY. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import express from "express"; +import MESSAGES from "../api-messages"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; +const FOLDER_MIME = "application/vnd.google-apps.folder"; +const DRIVE_PORT = 3579; +const FUNCTIONS_BASE = "http://localhost:5001/datapipe-test/us-central1"; +const CREATE_EXPERIMENT_URL = `${FUNCTIONS_BASE}/createexperiment`; +const AUTH_EMULATOR_SIGNUP_URL = + "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake"; + +// ---- helpers ---- + +function parseQuery(q) { + const nameMatch = /name\s*=\s*'([^']*)'/.exec(q || ""); + const parentMatch = /'([^']*)'\s+in\s+parents/.exec(q || ""); + const folderOnly = /mimeType\s*=\s*'application\/vnd\.google-apps\.folder'/.test(q || ""); + return { + name: nameMatch ? nameMatch[1] : null, + parent: parentMatch ? parentMatch[1] : null, + folderOnly, + }; +} + +// Minimal mock Drive: only what createDataContainer needs (a folder lookup by +// name+parent, and a folder create), plus a forceStatus(name, status) hook +// for case 9. No upload/download routes -- this endpoint never touches file +// content. +function createMockDriveServer() { + const app = express(); + app.use(express.json()); + + const filesById = new Map(); + const createCountsByName = new Map(); + const forcedStatus = new Map(); + let nextSeq = 1; + + app.get("/drive/v3/files", (req, res) => { + const { name, parent, folderOnly } = parseQuery(req.query.q); + let matches = Array.from(filesById.values()).filter((f) => { + if (parent && !f.parents.includes(parent)) return false; + if (name && f.name !== name) return false; + if (folderOnly && f.mimeType !== FOLDER_MIME) return false; + return true; + }); + matches.sort((a, b) => a.__seq - b.__seq); + res.status(200).json({ files: matches.map((f) => ({ id: f.id, name: f.name, mimeType: f.mimeType })) }); + }); + + app.post("/drive/v3/files", (req, res) => { + const payload = req.body || {}; + createCountsByName.set(payload.name, (createCountsByName.get(payload.name) || 0) + 1); + + const forced = forcedStatus.get(payload.name); + if (forced && forced !== 200 && forced !== 201) { + res.status(forced).json({ errors: [{ reason: "mockForced", message: `mock-forced-status-${forced}` }] }); + return; + } + + const id = `mock-folder-${nextSeq++}`; + filesById.set(id, { id, name: payload.name, mimeType: payload.mimeType, parents: payload.parents || [], __seq: nextSeq }); + res.status(200).json({ id, name: payload.name }); + }); + + return new Promise((resolve, reject) => { + const tryListen = (retriesLeft) => { + const server = app.listen(DRIVE_PORT); + server.once("listening", () => { + resolve({ + server, + getCreateCount: (name) => createCountsByName.get(name) || 0, + getFolderId: (name) => { + for (const f of filesById.values()) { + if (f.name === name) return f.id; + } + return null; + }, + forceStatus: (name, status) => forcedStatus.set(name, status), + reset: () => { + filesById.clear(); + createCountsByName.clear(); + forcedStatus.clear(); + nextSeq = 1; + }, + }); + }); + server.once("error", (err) => { + if (err.code === "EADDRINUSE" && retriesLeft > 0) { + setTimeout(() => tryListen(retriesLeft - 1), 500); + } else { + reject(err); + } + }); + }; + tryListen(60); // up to ~30s, in case gdrive-emulator.test.js's suite holds the port + }); +} + +async function signUpEmulatorUser() { + const email = `create-experiment-${randomUUID()}@example.test`; + const res = await fetch(AUTH_EMULATOR_SIGNUP_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password: "Password123!", returnSecureToken: true }), + }); + const body = await res.json(); + if (!res.ok) { + throw new Error(`Auth emulator signUp failed (${res.status}): ${JSON.stringify(body)}`); + } + return { uid: body.localId, idToken: body.idToken }; +} + +async function postJson(url, payload) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await res.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { rawBody: text }; + } + return { status: res.status, body }; +} + +function callCreateExperiment(payload) { + return postJson(CREATE_EXPERIMENT_URL, payload); +} + +async function seedGdriveUser(uid, overrides = {}) { + await db.collection("users").doc(uid).set({ + connectedAccounts: { + gdrive: { + authMethod: "oauth2", + encryptedToken: "create-experiment-plaintext-token", // plaintext fallback, see header comment + encryptedRefreshToken: "create-experiment-plaintext-refresh", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + providerAccountId: "create-experiment-acct", + ...overrides, + }, + }, + }); +} + +async function experimentsForOwner(uid) { + const snap = await db.collection("experiments").where("owner", "==", uid).get(); + return snap.docs; +} + +let db; +let mockDrive; + +beforeAll(async () => { + mockDrive = await createMockDriveServer(); + + let app; + try { + app = getApp("create-experiment-test"); + } catch { + app = initializeApp(config, "create-experiment-test"); + } + db = getFirestore(app); +}); + +afterEach(() => { + mockDrive.reset(); +}); + +afterAll(() => { + mockDrive.server.close(); +}); + +describe("5. createExperiment happy path (gdrive)", () => { + it("returns 200 and creates an experiment doc matching the client's default field set, plus provider fields and no OSF fields", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + await seedGdriveUser(uid); + const title = `Case5 Experiment ${randomUUID()}`; + + const { status, body } = await callCreateExperiment({ provider: "gdrive", title, idToken, uid }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + // Same id format as lib/experiment-creation.js's customAlphabet( + // "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 12). + expect(body.experimentID).toMatch(/^[0-9A-Za-z]{12}$/); + + const folderId = mockDrive.getFolderId(title); + expect(typeof folderId).toBe("string"); + expect(body.providerContainer).toEqual({ provider: "gdrive", folderId }); + + // Mock Drive saw the DataPipe-root find/create and the experiment-folder + // create with name=title. + expect(mockDrive.getCreateCount("DataPipe")).toBe(1); + expect(mockDrive.getCreateCount(title)).toBe(1); + + const expDoc = await db.collection("experiments").doc(body.experimentID).get(); + expect(expDoc.exists).toBe(true); + const expData = expDoc.data(); + + // Exact default-field parity with createExperimentDocument in + // lib/experiment-creation.js (verified by reading that file): title, + // active:false, activeBase64:false, activeConditionAssignment:false, + // sessions:0, id, owner, nConditions:1, currentCondition:0, + // useValidation:true, allowJSON:true, allowCSV:true, + // requiredFields:["trial_type"] (NOT [] -- the client hardcodes + // ["trial_type"], it is not a parameterized default), limitSessions:false, + // maxSessions:1 -- PLUS storageProvider/providerContainer instead of + // osfRepo/osfComponent/osfFilesLink. + expect(expData).toEqual({ + title, + active: false, + activeBase64: false, + activeConditionAssignment: false, + sessions: 0, + limitSessions: false, + maxSessions: 1, + id: body.experimentID, + owner: uid, + nConditions: 1, + currentCondition: 0, + useValidation: true, + allowJSON: true, + allowCSV: true, + requiredFields: ["trial_type"], + storageProvider: "gdrive", + providerContainer: { provider: "gdrive", folderId }, + }); + expect(expData.osfRepo).toBeUndefined(); + expect(expData.osfComponent).toBeUndefined(); + expect(expData.osfFilesLink).toBeUndefined(); + + const userDoc = await db.collection("users").doc(uid).get(); + expect(userDoc.data().experiments).toContain(body.experimentID); + }); +}); + +describe("6. createExperiment provider validation", () => { + it("returns 400 for provider 'osf' (OSF creation stays browser-driven)", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const title = `Case6 OSF ${randomUUID()}`; + + const { status } = await callCreateExperiment({ provider: "osf", title, idToken, uid }); + + expect(status).toBe(400); + expect((await experimentsForOwner(uid)).length).toBe(0); + }); + + it("returns 400 for an unknown provider", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const title = `Case6 Unknown ${randomUUID()}`; + + const { status } = await callCreateExperiment({ provider: "not-a-real-provider", title, idToken, uid }); + + expect(status).toBe(400); + expect((await experimentsForOwner(uid)).length).toBe(0); + }); +}); + +describe("7. createExperiment with no connected gdrive account", () => { + it("returns 400 surfacing PROVIDER_NOT_CONNECTED", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + // Deliberately no connectedAccounts.gdrive seeded. + const title = `Case7 ${randomUUID()}`; + + const { status, body } = await callCreateExperiment({ provider: "gdrive", title, idToken, uid }); + + expect(status).toBe(400); + expect(body).toEqual(expect.objectContaining(MESSAGES.PROVIDER_NOT_CONNECTED)); + expect((await experimentsForOwner(uid)).length).toBe(0); + }); +}); + +describe("8. createExperiment auth failures", () => { + it("returns 403 for a wrong-user idToken and creates nothing", async () => { + const userA = await signUpEmulatorUser(); + const userB = await signUpEmulatorUser(); + await seedGdriveUser(userA.uid); + const title = `Case8 wrong-user ${randomUUID()}`; + + const { status } = await callCreateExperiment({ + provider: "gdrive", + title, + idToken: userB.idToken, + uid: userA.uid, + }); + + expect(status).toBe(403); + expect((await experimentsForOwner(userA.uid)).length).toBe(0); + const userAData = (await db.collection("users").doc(userA.uid).get()).data(); + expect(userAData.experiments || []).toEqual([]); + }); + + it("returns 401 for a missing idToken and creates nothing", async () => { + const { uid } = await signUpEmulatorUser(); + const title = `Case8 missing-idtoken ${randomUUID()}`; + + const { status } = await callCreateExperiment({ provider: "gdrive", title, uid }); + + expect(status).toBe(401); + expect((await experimentsForOwner(uid)).length).toBe(0); + }); +}); + +describe("9. createExperiment Drive container-creation failure", () => { + it("returns 502 when Drive folder creation fails, and creates nothing", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + await seedGdriveUser(uid); + const title = `Case9 ${randomUUID()}`; + + // The DataPipe root-folder create still succeeds; only the + // experiment-named folder create is forced to fail. + mockDrive.forceStatus(title, 500); + + const { status, body } = await callCreateExperiment({ provider: "gdrive", title, idToken, uid }); + + expect(status).toBe(502); + expect(body.error).toBe("Failed to create storage container"); + expect((await experimentsForOwner(uid)).length).toBe(0); + const userData = (await db.collection("users").doc(uid).get()).data(); + expect(userData?.experiments || []).toEqual([]); + }); +}); diff --git a/functions/src/__tests__/gdrive-emulator.test.js b/functions/src/__tests__/gdrive-emulator.test.js index 1487e25..32dccd7 100644 --- a/functions/src/__tests__/gdrive-emulator.test.js +++ b/functions/src/__tests__/gdrive-emulator.test.js @@ -297,23 +297,42 @@ function createMockDriveServer() { res.status(200).json({ access_token: "mock-refreshed-token", expires_in: 3600, token_type: "Bearer" }); }); - return new Promise((resolve) => { - const server = app.listen(DRIVE_PORT, () => { - resolve({ - server, - port: DRIVE_PORT, - getUploadCount: (name) => uploadCountsByName.get(name) || 0, - getUpdateCount: (id) => updateCountsById.get(id) || 0, - forceStatus: (nameOrId, status) => forcedStatus.set(nameOrId, status), - reset: () => { - filesById.clear(); - uploadCountsByName.clear(); - updateCountsById.clear(); - forcedStatus.clear(); - nextSeq = 1; - }, + // Fixed-port bind, shared with create-experiment-emulator.test.js's mock + // Drive server (both read the same GDRIVE_API_BASE=http://127.0.0.1:3579 + // wired into functions/.env.datapipe-test). Jest may schedule the two test + // files onto different, truly-concurrent workers, so this retries on + // EADDRINUSE (with backoff) instead of assuming the port is free -- + // whichever suite starts first grabs it, the other waits for that suite's + // afterAll() to release it. Defensive only: no two tests actually hold the + // port at the same instant. + return new Promise((resolve, reject) => { + const tryListen = (retriesLeft) => { + const server = app.listen(DRIVE_PORT); + server.once("listening", () => { + resolve({ + server, + port: DRIVE_PORT, + getUploadCount: (name) => uploadCountsByName.get(name) || 0, + getUpdateCount: (id) => updateCountsById.get(id) || 0, + forceStatus: (nameOrId, status) => forcedStatus.set(nameOrId, status), + reset: () => { + filesById.clear(); + uploadCountsByName.clear(); + updateCountsById.clear(); + forcedStatus.clear(); + nextSeq = 1; + }, + }); }); - }); + server.once("error", (err) => { + if (err.code === "EADDRINUSE" && retriesLeft > 0) { + setTimeout(() => tryListen(retriesLeft - 1), 500); + } else { + reject(err); + } + }); + }; + tryListen(60); // up to ~30s, in case create-experiment-emulator.test.js's suite holds the port }); } diff --git a/functions/src/__tests__/pending-recovery-provider-regression.test.js b/functions/src/__tests__/pending-recovery-provider-regression.test.js new file mode 100644 index 0000000..ad90c2b --- /dev/null +++ b/functions/src/__tests__/pending-recovery-provider-regression.test.js @@ -0,0 +1,131 @@ +/** + * @jest-environment node + */ + +// RED-phase regression test for step 7a's scheduled-pending-recovery.ts audit +// (scratchpad/step7a-create-endpoint-spec.md, case 10 of the test plan). +// +// The audit: scheduledPendingRecovery's re-queue path (promoteToQueue in +// functions/src/scheduled-pending-recovery.ts) builds its uploadQueue doc by +// hand, reading only `expData.osfFilesLink` off the recovered experiment -- +// it never reads/forwards `expData.storageProvider` / +// `expData.providerContainer`. Unlike queue-upload.ts (which explicitly +// passes storageProvider/providerContainer through, see api-data.ts's calls), +// this path has no such wiring. A recovered pending upload for a gdrive +// experiment would therefore fall back to the legacy OSF shape in the queue +// doc and fail when scheduled-upload-retry.ts later tries to process it. +// This test seeds exactly that scenario and asserts the queue doc carries the +// provider fields -- expected RED today. +// +// Seam: scheduled-pending-recovery.ts exports only `scheduledPendingRecovery` +// (an onSchedule-wrapped function); `recoverPendingUploads`/`promoteToQueue` +// are private. Rather than going through the Functions-emulator's HTTP +// manual-trigger URL (the "scheduledpendingrecovery-0" pattern established by +// oauth-connect-scheduled-regression.test.js's case 11), this test uses a +// more direct seam: firebase-functions v2's onSchedule() implementation +// (node_modules/firebase-functions/lib/v2/providers/scheduler.js) stashes the +// raw, unwrapped handler on the returned function as `.run` (`func.run = +// handler`). Dynamically importing the COMPILED module (functions/lib/, +// requires `npm run build` first -- same convention as +// oauth-connect-emulator.test.js's `await import("../../lib/crypto-utils.js")`) +// and calling `scheduledPendingRecovery.run()` invokes recoverPendingUploads() +// directly in THIS process. +// +// That matters because recoverPendingUploads() only recovers files older +// than STALE_THRESHOLD_MS (15 minutes) -- a real pending-data file would need +// to sit in the emulator for 15 real minutes before this suite could observe +// it being recovered, which is impractical for CI. Because `.run()` executes +// the real handler in-process (not over HTTP to the separate +// Functions-emulator child process), this test can mock the global `Date.now` +// that recoverPendingUploads() reads to compute its cutoff, shifting the +// cutoff forward past the (really, just-created) file's real timeCreated -- +// without touching STALE_THRESHOLD_MS or any other production code. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { getStorage } from "firebase-admin/storage"; +import { randomUUID } from "crypto"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199"; +jest.setTimeout(30000); + +const config = { + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}; + +const STALE_THRESHOLD_MS = 15 * 60 * 1000; + +let db; +let bucket; +let scheduledPendingRecovery; + +beforeAll(async () => { + // A NAMED app for this test's own seeding/assertions -- deliberately not + // "[DEFAULT]", so that dynamically importing the compiled + // scheduled-pending-recovery.js below (whose app.js calls bare + // initializeApp() with no name) doesn't collide with an already-existing + // default app in this same module registry. + let app; + try { + app = getApp("pending-recovery-regression-test"); + } catch { + app = initializeApp(config, "pending-recovery-regression-test"); + } + db = getFirestore(app); + bucket = getStorage(app).bucket(); + + ({ scheduledPendingRecovery } = await import("../../lib/scheduled-pending-recovery.js")); +}); + +async function seedPendingFile(experimentID, filename, data) { + const storagePath = `pending-data/${experimentID}/${filename}_${Date.now()}`; + const envelope = { experimentID, filename, data }; + const file = bucket.file(storagePath); + await file.save(JSON.stringify(envelope), { contentType: "application/json" }); + return storagePath; +} + +describe("10. scheduled-pending-recovery carries provider fields through for a gdrive experiment", () => { + it("promotes a stale pending file for a gdrive experiment into uploadQueue with storageProvider + providerContainer set", async () => { + const experimentID = `pending-recovery-gdrive-${randomUUID()}`; + const filename = `case10-${randomUUID()}.json`; + const owner = `pending-recovery-owner-${randomUUID()}`; + const folderId = `folder-${randomUUID()}`; + + await db.collection("experiments").doc(experimentID).set({ + active: true, + owner, + storageProvider: "gdrive", + providerContainer: { provider: "gdrive", folderId }, + // osfFilesLink deliberately absent -- a gdrive experiment has none. + }); + + await seedPendingFile(experimentID, filename, `[{"trial_type":"html-keyboard-response"}]`); + + // Shift the recovery pass's notion of "now" forward so its + // STALE_THRESHOLD_MS cutoff falls after this file's real (just-now) + // timeCreated, without waiting 15 real minutes. + const realNow = Date.now(); + const nowSpy = jest.spyOn(Date, "now").mockReturnValue(realNow + STALE_THRESHOLD_MS + 5 * 60 * 1000); + try { + await scheduledPendingRecovery.run({}); + } finally { + nowSpy.mockRestore(); + } + + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + const queueDoc = await db.collection("uploadQueue").doc(docId).get(); + expect(queueDoc.exists).toBe(true); + + const queueData = queueDoc.data(); + expect(queueData.experimentID).toBe(experimentID); + expect(queueData.owner).toBe(owner); + // The actual gap: promoteToQueue must pass these through, the same way + // queue-upload.ts's callers in api-data.ts already do. + expect(queueData.storageProvider).toBe("gdrive"); + expect(queueData.providerContainer).toEqual({ provider: "gdrive", folderId }); + expect(queueData.osfFilesLink).toBeUndefined(); + }); +}); diff --git a/functions/src/connect-provider.ts b/functions/src/connect-provider.ts index 9bba0f5..bb57964 100644 --- a/functions/src/connect-provider.ts +++ b/functions/src/connect-provider.ts @@ -13,11 +13,11 @@ import { db, auth } from "./app.js"; import { encrypt } from "./crypto-utils.js"; import { getOAuthConfig } from "./providers/oauth-config.js"; -type AuthCheckResult = +export type AuthCheckResult = | { ok: true } | { ok: false; status: number; error: string }; -async function verifyOwnership(uid: string, idToken: string | undefined): Promise { +export async function verifyOwnership(uid: string, idToken: string | undefined): Promise { if (!idToken) { return { ok: false, status: 401, error: 'Authentication required' }; } diff --git a/functions/src/create-experiment.ts b/functions/src/create-experiment.ts new file mode 100644 index 0000000..32c22a2 --- /dev/null +++ b/functions/src/create-experiment.ts @@ -0,0 +1,173 @@ +// Server-side experiment creation for non-OSF storage providers +// (scratchpad/step7a-create-endpoint-spec.md, docs/provider-migration-design.md). +// +// OSF experiment creation stays entirely browser-driven (see +// lib/experiment-creation.js) -- the browser calls the OSF API directly and +// batch-writes Firestore with a Firebase client SDK, which is fine because +// the OSF token flow already lives client-side. New providers (starting with +// gdrive) need a server-side path instead: createDataContainer is +// server-only (it needs the decrypted, possibly-refreshed provider token +// that only resolve-token.ts can produce), and the resulting container ref +// must be folded into the experiment doc atomically with its creation. +// +// This endpoint intentionally mirrors createExperimentDocument in +// lib/experiment-creation.js field-for-field (including the +// requiredFields: ["trial_type"] default, which the client hardcodes rather +// than parameterizes) so that gdrive- and OSF-created experiment docs stay +// uniform for every other consumer (api-data.ts, the dashboard, etc.). + +import { onRequest } from "firebase-functions/v2/https"; +import { FieldValue } from "firebase-admin/firestore"; +import { customAlphabet } from "nanoid"; +import { db } from "./app.js"; +import { verifyOwnership } from "./connect-provider.js"; +import resolveToken from "./resolve-token.js"; +import { getProvider, listProviders } from "./providers/index.js"; +import { ContainerRef, StorageProviderId } from "./providers/types.js"; +import { ExperimentData, UserData } from "./interfaces.js"; +import MESSAGES from "./api-messages.js"; + +// Same alphabet/length as lib/experiment-creation.js's +// customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 12) +// -- experiment ids must stay uniform whether created client-side (OSF) or +// server-side (gdrive and later providers). +const generateExperimentId = customAlphabet( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + 12 +); + +interface ExperimentSettingsOverrides { + nConditions?: number; + useValidation?: boolean; + allowJSON?: boolean; + allowCSV?: boolean; + requiredFields?: string[]; + limitSessions?: boolean; + maxSessions?: number; +} + +export const createExperiment = onRequest({ cors: true }, async (req, res) => { + try { + if (req.method !== "POST") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + const { + provider, + title, + idToken, + uid, + experimentSettings, + }: { + provider?: string; + title?: string; + idToken?: string; + uid?: string; + experimentSettings?: ExperimentSettingsOverrides; + } = req.body || {}; + + if (!provider || !title || !uid) { + res.status(400).json(MESSAGES.MISSING_PARAMETER); + return; + } + + // Verify the caller owns the uid they claim -- same shape as + // connect-provider.ts's storage-grant flow (401 missing/invalid token, + // 403 uid mismatch). No signup path here, ever. + const authCheck = await verifyOwnership(uid, idToken); + if (!authCheck.ok) { + res.status(authCheck.status).json({ error: authCheck.error }); + return; + } + + // OSF creation stays browser-driven; only registered NON-osf providers + // may be created through this endpoint. + if (provider === "osf" || !listProviders().includes(provider as StorageProviderId)) { + res.status(400).json({ error: "Unsupported provider" }); + return; + } + + const storageProvider = getProvider(provider as StorageProviderId); + + const userDocRef = db.doc(`users/${uid}`); + const userDoc = await userDocRef.get(); + // A freshly-signed-up user may have no Firestore doc yet -- treat that + // the same as "no connected accounts" rather than throwing, so the + // PROVIDER_NOT_CONNECTED surface below applies uniformly. + const userData: UserData = (userDoc.data() as UserData) || ({} as UserData); + + const tokenResult = await resolveToken(userData, { + storageProvider: provider as StorageProviderId, + owner: uid, + } as ExperimentData); + + if (!tokenResult.success) { + const errorMessage = + MESSAGES[tokenResult.error as keyof typeof MESSAGES] || MESSAGES.TOKEN_RESOLUTION_ERROR; + res.status(400).json(errorMessage); + return; + } + + let providerContainer: ContainerRef; + try { + providerContainer = await storageProvider.createDataContainer( + { token: tokenResult.token }, + { name: title } + ); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown error"; + res.status(502).json({ error: "Failed to create storage container", detail }); + return; + } + + const settings = experimentSettings || {}; + const nConditions = settings.nConditions ?? 1; + const useValidation = settings.useValidation ?? true; + const allowJSON = settings.allowJSON ?? true; + const allowCSV = settings.allowCSV ?? true; + const requiredFields = settings.requiredFields ?? ["trial_type"]; + const limitSessions = settings.limitSessions ?? false; + const maxSessions = settings.maxSessions ?? 1; + + const experimentID = generateExperimentId(); + + const experimentDocRef = db.collection("experiments").doc(experimentID); + + const batch = db.batch(); + batch.set(experimentDocRef, { + title, + active: false, + activeBase64: false, + activeConditionAssignment: false, + sessions: 0, + limitSessions, + maxSessions, + id: experimentID, + owner: uid, + nConditions, + currentCondition: 0, + useValidation, + allowJSON, + allowCSV, + requiredFields, + storageProvider: provider, + providerContainer, + }); + // set+merge (not update) -- a freshly-signed-up user may have no + // Firestore doc yet, same rationale as connect-provider.ts's + // set()+mergeFields for connectedAccounts. + batch.set( + userDocRef, + { experiments: FieldValue.arrayUnion(experimentID) }, + { merge: true } + ); + + await batch.commit(); + + res.status(200).json({ success: true, experimentID, providerContainer }); + } catch (error) { + console.error("Error creating experiment:", error instanceof Error ? error.message : "Unknown error"); + res.status(500).json({ error: "Failed to create experiment" }); + } +}); diff --git a/functions/src/index.ts b/functions/src/index.ts index 40adaf6..082cbde 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -15,6 +15,7 @@ import { connectProvider, disconnectProvider } from "./connect-provider.js"; import { saveOsfToken } from "./save-osf-token.js"; import { getOsfToken } from "./get-osf-token.js"; import { onUserDeleted } from "./on-user-deleted.js"; +import { createExperiment } from "./create-experiment.js"; setGlobalOptions({ maxInstances: 20 @@ -36,5 +37,6 @@ export { disconnectProvider as disconnectprovider, saveOsfToken as saveosftoken, getOsfToken as getosftoken, - onUserDeleted as onuserdeleted + onUserDeleted as onuserdeleted, + createExperiment as createexperiment }; diff --git a/functions/src/providers/index.ts b/functions/src/providers/index.ts index ff02741..19ba505 100644 --- a/functions/src/providers/index.ts +++ b/functions/src/providers/index.ts @@ -26,7 +26,7 @@ export function getProviderForExperiment(exp_data: ExperimentData): { }; } -export { registerProvider, getProvider } from "./registry.js"; +export { registerProvider, getProvider, listProviders } from "./registry.js"; export { osfProvider } from "./osf.js"; export { gdriveProvider } from "./gdrive.js"; export * from "./types.js"; diff --git a/functions/src/scheduled-pending-recovery.ts b/functions/src/scheduled-pending-recovery.ts index 15c9800..212d603 100644 --- a/functions/src/scheduled-pending-recovery.ts +++ b/functions/src/scheduled-pending-recovery.ts @@ -139,13 +139,20 @@ async function promoteToQueue( const now = Timestamp.now(); const nextRetryAt = Timestamp.fromMillis(now.toMillis() + 60 * 1000); // 1 minute — retry soon - transaction.set(docRef, { + // osfFilesLink/storageProvider/providerContainer are included only when + // present -- Firestore rejects undefined field values, and a gdrive + // experiment has no osfFilesLink just as a legacy OSF experiment has no + // storageProvider/providerContainer. Same omit-if-undefined convention as + // queue-upload.ts, whose callers (api-data.ts) already pass these + // through; this re-queue path must carry them too, or a recovered + // pending upload for a gdrive experiment falls back to the legacy OSF + // shape and fails when scheduled-upload-retry.ts processes it. + const queueDocData: Record = { experimentID, owner: expData.owner, filename, storagePath, dataType: "data", - osfFilesLink: expData.osfFilesLink, status: "pending", errorCode: 0, retryCount: 0, @@ -157,7 +164,19 @@ async function promoteToQueue( failureReason: "Recovered from interrupted upload (server restart or memory limit)", deduplicationKey, sessionIncremented: false, - }); + }; + + if (expData.osfFilesLink !== undefined) { + queueDocData.osfFilesLink = expData.osfFilesLink; + } + if (expData.storageProvider !== undefined) { + queueDocData.storageProvider = expData.storageProvider; + } + if (expData.providerContainer !== undefined) { + queueDocData.providerContainer = expData.providerContainer; + } + + transaction.set(docRef, queueDocData); return true; }); From be36c94e8d58a3d891df223db59dab0a16f5b30c Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Thu, 23 Jul 2026 10:50:47 -0400 Subject: [PATCH 11/14] feat: provider selector, connect UI, and provider-aware dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build step 7b, completing the frontend (design-doc step 7). - lib/provider-config.js: STORAGE_PROVIDERS map — new providers become config entries (name, isConnected, container link/label); OSF keeps its bespoke legacy UI deliberately - admin/new.js: storage selector (native radio inputs — Chakra's compound RadioGroup defers state outside act(), breaking synchronous tests; OSF path byte-identical and pinned by regression test). gdrive branch: connect CTA when unlinked, title-only form posting to /api/createexperiment via createProviderExperiment, routes to the new experiment on success - ProviderConnections + account.js Storage Providers section: connect via generateOAuthState {provider} -> authorizeUrl redirect; disconnect via /api/disconnectprovider; status reads connectedAccounts reactively - pages/oauth2/connect.js: storage-grant OAuth callback (CSRF-checked, sign-in required, posts to /api/connectprovider) — separate from the OSF identity callback by design - ExperimentInfo renders the provider container link for provider-backed experiments; legacy OSF rows unchanged. QueuePanel copy generalized to provider-neutral phrasing - firebase.json: /api/connectprovider + /api/disconnectprovider rewrites TDD: 14 RTL contract cases reviewed red first (3 as pinned regression guards). Next.js production build clean; full emulator suite green twice (35 suites, 223 tests, --maxWorkers=2). Deploy note: GDRIVE_REDIRECT_URI must point at /oauth2/connect. Co-Authored-By: Claude Fable 5 --- __tests__/connect-callback-page.test.jsx | 115 +++++++++ __tests__/experiment-info.test.jsx | 51 ++++ __tests__/new-experiment-page.test.jsx | 218 +++++++++++++++++ __tests__/provider-config.test.js | 41 ++++ __tests__/provider-connections.test.jsx | 114 +++++++++ __tests__/queue-panel.test.jsx | 69 ++++++ components/account/ProviderConnections.js | 113 +++++++++ components/dashboard/ExperimentInfo.js | 56 +++-- components/dashboard/QueuePanel.js | 26 +- firebase.json | 8 + lib/experiment-creation.js | 38 +++ lib/provider-config.js | 14 ++ pages/admin/account.js | 8 + pages/admin/new.js | 279 +++++++++++++++------- pages/oauth2/connect.js | 185 ++++++++++++++ 15 files changed, 1216 insertions(+), 119 deletions(-) create mode 100644 __tests__/connect-callback-page.test.jsx create mode 100644 __tests__/experiment-info.test.jsx create mode 100644 __tests__/new-experiment-page.test.jsx create mode 100644 __tests__/provider-config.test.js create mode 100644 __tests__/provider-connections.test.jsx create mode 100644 __tests__/queue-panel.test.jsx create mode 100644 components/account/ProviderConnections.js create mode 100644 lib/provider-config.js create mode 100644 pages/oauth2/connect.js diff --git a/__tests__/connect-callback-page.test.jsx b/__tests__/connect-callback-page.test.jsx new file mode 100644 index 0000000..8b83c6f --- /dev/null +++ b/__tests__/connect-callback-page.test.jsx @@ -0,0 +1,115 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { system } from "../lib/theme"; +import "@testing-library/jest-dom"; + +const mockGetIdToken = jest.fn(() => Promise.resolve("id-token-123")); +jest.mock("../lib/firebase", () => ({ + auth: { currentUser: { uid: "user-1", getIdToken: () => mockGetIdToken() } }, + db: {}, +})); + +// UserContext is re-provided per test via +// so signed-in vs signed-out can vary within this file without re-mocking +// the module. +jest.mock("../lib/context", () => ({ + UserContext: require("react").createContext({ user: null, loading: false }), +})); + +const mockPush = jest.fn(); +let mockQuery = {}; +jest.mock("next/router", () => ({ + useRouter: () => ({ query: mockQuery, push: mockPush }), +})); + +import { UserContext } from "../lib/context"; +import ConnectCallbackPage from "../pages/oauth2/connect"; + +function renderPage({ user = { uid: "user-1" } } = {}) { + return render( + + + + + + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetIdToken.mockClear(); + mockGetIdToken.mockImplementation(() => Promise.resolve("id-token-123")); + global.fetch = jest.fn(); + localStorage.clear(); + mockQuery = {}; +}); + +describe("oauth2/connect callback page", () => { + it("9. happy path posts to connectprovider and routes to /admin/account on success", async () => { + mockQuery = { code: "auth-code-1", state: "state-abc" }; + localStorage.setItem("latestCSRFToken", "state-abc"); + localStorage.setItem("providerConnectFlow", "gdrive"); + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + renderPage(); + + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe("/api/connectprovider"); + expect(JSON.parse(options.body)).toEqual({ + provider: "gdrive", + code: "auth-code-1", + state: "state-abc", + uid: "user-1", + idToken: "id-token-123", + }); + + await waitFor(() => + expect(mockPush).toHaveBeenCalledWith("/admin/account") + ); + }); + + it("10. state mismatch shows error UI and does not call connectprovider", async () => { + mockQuery = { code: "auth-code-1", state: "state-abc" }; + localStorage.setItem("latestCSRFToken", "different-state"); + localStorage.setItem("providerConnectFlow", "gdrive"); + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + renderPage(); + + await waitFor(() => + expect( + screen.getByText(/invalid state|csrf/i) + ).toBeInTheDocument() + ); + expect( + screen.getByRole("link", { name: /admin.*account|account/i }) + ).toHaveAttribute("href", "/admin/account"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("11. signed-out user shows error UI with a sign-in link and does not call connectprovider", async () => { + mockQuery = { code: "auth-code-1", state: "state-abc" }; + localStorage.setItem("latestCSRFToken", "state-abc"); + localStorage.setItem("providerConnectFlow", "gdrive"); + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + renderPage({ user: null }); + + await waitFor(() => + expect( + screen.getByRole("link", { name: /sign in/i }) + ).toBeInTheDocument() + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/experiment-info.test.jsx b/__tests__/experiment-info.test.jsx new file mode 100644 index 0000000..d6f52c2 --- /dev/null +++ b/__tests__/experiment-info.test.jsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { system } from "../lib/theme"; +import "@testing-library/jest-dom"; + +import ExperimentInfo from "../components/dashboard/ExperimentInfo"; + +function renderInfo(data) { + return render( + + + + ); +} + +describe("ExperimentInfo — legacy OSF experiments (pinned regression)", () => { + it("12. renders OSF Project and OSF Data Component links for legacy experiments", () => { + renderInfo({ + id: "exp1", + osfRepo: "abc12", + osfComponent: "def34", + sessions: 3, + }); + + expect(screen.getByText("OSF Project")).toBeInTheDocument(); + expect(screen.getByText("OSF Data Component")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /abc12/ })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /def34/ })).toBeInTheDocument(); + }); +}); + +describe("ExperimentInfo — provider-aware rendering", () => { + it("13. renders Google Drive Folder link for gdrive experiments; OSF labels are absent", () => { + renderInfo({ + id: "exp2", + storageProvider: "gdrive", + providerContainer: { folderId: "folder123" }, + sessions: 5, + }); + + expect(screen.getByText("Google Drive Folder")).toBeInTheDocument(); + const link = screen.getByRole("link", { name: /folder123/ }); + expect(link).toHaveAttribute( + "href", + "https://drive.google.com/drive/folders/folder123" + ); + + expect(screen.queryByText("OSF Project")).not.toBeInTheDocument(); + expect(screen.queryByText("OSF Data Component")).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/new-experiment-page.test.jsx b/__tests__/new-experiment-page.test.jsx new file mode 100644 index 0000000..638630c --- /dev/null +++ b/__tests__/new-experiment-page.test.jsx @@ -0,0 +1,218 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { system } from "../lib/theme"; +import "@testing-library/jest-dom"; + +// Mock firebase since test env doesn't have NEXT_PUBLIC_FIREBASE_CONFIG. +// auth.currentUser mirrors a signed-in user with a working getIdToken(). +const mockGetIdToken = jest.fn(() => Promise.resolve("id-token-123")); +jest.mock("../lib/firebase", () => ({ + auth: { currentUser: { uid: "user-1", getIdToken: () => mockGetIdToken() } }, + db: {}, +})); + +// Mock context to provide a signed-in user (this page is wrapped in AuthCheck). +jest.mock("../lib/context", () => ({ + UserContext: require("react").createContext({ + user: { uid: "user-1" }, + loading: false, + }), +})); + +// lib/experiment-creation.js (imported transitively by pages/admin/new.js) +// pulls in `nanoid`, which ships ESM-only and isn't transformed by Jest by +// default (`Cannot use import statement outside a module`). Mock it out +// rather than touching jest.config.js's transformIgnorePatterns. +jest.mock("nanoid", () => ({ + customAlphabet: () => () => "mocked-id", +})); + +// firebase/firestore's `doc` (and friends used transitively by +// lib/experiment-creation.js) must not touch a real Firestore instance. +jest.mock("firebase/firestore", () => ({ + doc: jest.fn(() => ({})), + writeBatch: jest.fn(() => ({ + set: jest.fn(), + update: jest.fn(), + commit: jest.fn(() => Promise.resolve()), + })), + arrayUnion: jest.fn((v) => v), + setDoc: jest.fn(() => Promise.resolve()), +})); + +// The page navigates via the `Router` singleton default export (see +// pages/admin/new.js: `import Router from "next/router"`), while AuthCheck +// uses the `useRouter()` hook. Mock both from the same module. +const mockPush = jest.fn(); +jest.mock("next/router", () => ({ + __esModule: true, + default: { push: (...args) => mockPush(...args) }, + useRouter: () => ({ push: mockPush, pathname: "/admin/new", query: {} }), +})); + +jest.mock("react-firebase-hooks/firestore", () => ({ + useDocumentData: jest.fn(), +})); + +import { useDocumentData } from "react-firebase-hooks/firestore"; +import NewExperimentPage from "../pages/admin/new"; + +function renderPage() { + return render( + + + + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetIdToken.mockClear(); + mockGetIdToken.mockImplementation(() => Promise.resolve("id-token-123")); + global.fetch = jest.fn(); +}); + +describe("NewExperimentPage — OSF path (pinned regression)", () => { + it("2. default render shows the OSF form exactly as today", () => { + useDocumentData.mockReturnValue([ + { refreshToken: "osf-refresh-token", usingPersonalToken: false }, + false, + undefined, + ]); + + renderPage(); + + expect(screen.getByText("Existing OSF Project")).toBeInTheDocument(); + expect( + screen.getByText("New OSF Data Component Name") + ).toBeInTheDocument(); + expect(screen.getByText("Storage Location")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create" })).toBeInTheDocument(); + }); +}); + +describe("NewExperimentPage — Google Drive provider selector", () => { + // NOTE ON INTERACTION MECHANICS: the spec allows a RadioGroup or a Select + // for "Where should data be stored?". These assertions target the option + // label text via getByLabelText, which works for either control as long + // as the GREEN implementation gives the Google Drive option an + // accessible name of "Google Drive" (radio input's associated label, or + // an