From 022c24ad69e7d008b68712d9a74fdd26594265ae Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Sat, 25 Jul 2026 13:03:29 -0400 Subject: [PATCH 1/2] feat: let experiments target a chosen gdrive folder (backend) Backend for per-experiment Google Drive folder selection. The Picker UI (front-end, next) lets a researcher pick an existing Drive folder; these pieces let the picked folder become the experiment's data-folder parent instead of the fixed My Drive/DataPipe. - getProviderAccessToken (/api/getprovideraccesstoken): owner-authenticated endpoint returning a short-lived drive.file access token to the browser for Picker use, minted via resolve-token from the stored refresh token. Returns the access token only -- no refresh token or other user data. - gdriveProvider.createDataContainer: create the experiment folder under a researcher-supplied parentId when present; unchanged My Drive/DataPipe fallback when absent. - createExperiment: plumb optional parentFolderId through to the container. Additive and backward-compatible: parentFolderId is optional and the new endpoint is unused until the Picker UI lands. Emulator tests cover the endpoint's full auth/validation matrix and the parent-placement behavior (plus a default-path regression). Verified green (36/36) + clean build. Co-Authored-By: Claude Opus 4.8 --- firebase.json | 4 + .../create-experiment-emulator.test.js | 47 +++++ ...get-provider-access-token-emulator.test.js | 193 ++++++++++++++++++ functions/src/create-experiment.ts | 8 +- functions/src/get-provider-access-token.ts | 89 ++++++++ functions/src/index.ts | 4 +- functions/src/providers/gdrive.ts | 22 +- 7 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 functions/src/__tests__/get-provider-access-token-emulator.test.js create mode 100644 functions/src/get-provider-access-token.ts diff --git a/firebase.json b/firebase.json index c8987ef..c8614c9 100644 --- a/firebase.json +++ b/firebase.json @@ -65,6 +65,10 @@ { "source": "/api/disconnectprovider", "function": "disconnectprovider" + }, + { + "source": "/api/getprovideraccesstoken", + "function": "getprovideraccesstoken" } ] }, diff --git a/functions/src/__tests__/create-experiment-emulator.test.js b/functions/src/__tests__/create-experiment-emulator.test.js index da788ed..5c24c8c 100644 --- a/functions/src/__tests__/create-experiment-emulator.test.js +++ b/functions/src/__tests__/create-experiment-emulator.test.js @@ -122,6 +122,12 @@ function createMockDriveServer() { } return null; }, + getParents: (name) => { + for (const f of filesById.values()) { + if (f.name === name) return f.parents; + } + return null; + }, forceStatus: (name, status) => forcedStatus.set(name, status), reset: () => { filesById.clear(); @@ -370,3 +376,44 @@ describe("9. createExperiment Drive container-creation failure", () => { expect(userData?.experiments || []).toEqual([]); }); }); + +describe("10. createExperiment with parentFolderId", () => { + it("creates the experiment folder directly under the given parent, skipping the DataPipe-root lookup entirely", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + await seedGdriveUser(uid); + const title = `Case10 ${randomUUID()}`; + const parentFolderId = `picker-folder-${randomUUID()}`; + + const { status, body } = await callCreateExperiment({ + provider: "gdrive", + title, + idToken, + uid, + parentFolderId, + }); + + expect(status).toBe(200); + const folderId = mockDrive.getFolderId(title); + expect(typeof folderId).toBe("string"); + expect(body.providerContainer).toEqual({ provider: "gdrive", folderId }); + expect(mockDrive.getParents(title)).toEqual([parentFolderId]); + + // The DataPipe-root find-or-create is skipped entirely when a + // researcher-chosen parent is supplied. + expect(mockDrive.getCreateCount("DataPipe")).toBe(0); + }); + + it("still lands under the DataPipe root when parentFolderId is omitted (regression)", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + await seedGdriveUser(uid); + const title = `Case10b ${randomUUID()}`; + + const { status } = await callCreateExperiment({ provider: "gdrive", title, idToken, uid }); + + expect(status).toBe(200); + const dataPipeId = mockDrive.getFolderId("DataPipe"); + expect(typeof dataPipeId).toBe("string"); + expect(mockDrive.getParents(title)).toEqual([dataPipeId]); + expect(mockDrive.getCreateCount("DataPipe")).toBe(1); + }); +}); diff --git a/functions/src/__tests__/get-provider-access-token-emulator.test.js b/functions/src/__tests__/get-provider-access-token-emulator.test.js new file mode 100644 index 0000000..f08e84b --- /dev/null +++ b/functions/src/__tests__/get-provider-access-token-emulator.test.js @@ -0,0 +1,193 @@ +/** + * @jest-environment node + */ + +// Emulator integration tests for getProviderAccessToken +// (functions/src/get-provider-access-token.ts), the endpoint the Picker +// front-end (a later build step) calls to obtain a raw Drive access token +// for client-side use. Follows the exact patterns established by +// oauth-connect-emulator.test.js (auth via the Auth emulator's +// accounts:signUp, encrypted-token seeding/decryption) and +// create-experiment-emulator.test.js (postJson/signUpEmulatorUser helpers). +// +// Per index.ts's lowercase export convention (getProviderAccessToken -> +// getprovideraccesstoken), the URL under test is +// http://localhost:5001/datapipe-test/us-central1/getprovideraccesstoken. +// +// No new mock Drive/token server is started here: the happy-path case seeds +// an UNEXPIRED connectedAccounts.gdrive entry (same shape +// create-experiment-emulator.test.js's seedGdriveUser uses), so +// resolve-token.ts's resolveGdriveToken never needs to hit the token +// endpoint at all -- avoiding any risk of colliding with the reserved fixed +// ports (3579 = mock Drive API, 3580 = mock OAuth token server) that other +// suites in this same jest run bind. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import MESSAGES from "../api-messages"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; +const FUNCTIONS_BASE = "http://localhost:5001/datapipe-test/us-central1"; +const GET_TOKEN_URL = `${FUNCTIONS_BASE}/getprovideraccesstoken`; +const AUTH_EMULATOR_SIGNUP_URL = + "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake"; + +let db; + +beforeAll(() => { + let app; + try { + app = getApp("get-provider-access-token-test"); + } catch { + app = initializeApp(config, "get-provider-access-token-test"); + } + db = getFirestore(app); +}); + +// ---- helpers ---- + +async function signUpEmulatorUser() { + const email = `get-provider-access-token-${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 getViaGet(payload) { + // 405 case: method must be rejected before body parsing matters, but a GET + // with a query string is enough to exercise the method check. + return fetch(`${GET_TOKEN_URL}?${new URLSearchParams(payload)}`, { method: "GET" }).then( + async (res) => ({ status: res.status, body: await res.json().catch(() => ({})) }) + ); +} + +function callGetProviderAccessToken(payload) { + return postJson(GET_TOKEN_URL, payload); +} + +async function seedGdriveUser(uid, overrides = {}) { + await db.collection("users").doc(uid).set({ + connectedAccounts: { + gdrive: { + authMethod: "oauth2", + // plaintext fallback, same convention as create-experiment-emulator.test.js + encryptedToken: "get-token-plaintext-access-token", + encryptedRefreshToken: "get-token-plaintext-refresh-token", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, // unexpired -- no refresh needed + providerAccountId: "get-token-acct", + ...overrides, + }, + }, + }); +} + +// ---- cases ---- + +describe("getProviderAccessToken method + validation", () => { + it("returns 405 for a GET request", async () => { + const { status } = await getViaGet({ provider: "gdrive", uid: "whoever" }); + expect(status).toBe(405); + }); + + it("returns 400 when provider is missing", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const { status } = await callGetProviderAccessToken({ uid, idToken }); + expect(status).toBe(400); + }); + + it("returns 400 when uid is missing", async () => { + const { idToken } = await signUpEmulatorUser(); + const { status } = await callGetProviderAccessToken({ provider: "gdrive", idToken }); + expect(status).toBe(400); + }); +}); + +describe("getProviderAccessToken auth failures", () => { + it("returns 401 when idToken is missing", async () => { + const { uid } = await signUpEmulatorUser(); + const { status } = await callGetProviderAccessToken({ provider: "gdrive", uid }); + expect(status).toBe(401); + }); + + it("returns 403 when idToken belongs to a different emulator user than uid", async () => { + const userA = await signUpEmulatorUser(); + const userB = await signUpEmulatorUser(); + await seedGdriveUser(userA.uid); + + const { status } = await callGetProviderAccessToken({ + provider: "gdrive", + uid: userA.uid, + idToken: userB.idToken, + }); + + expect(status).toBe(403); + }); +}); + +describe("getProviderAccessToken provider validation", () => { + it("returns 400 for provider 'osf' (the OSF identity flow is separate)", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const { status } = await callGetProviderAccessToken({ provider: "osf", uid, idToken }); + expect(status).toBe(400); + }); + + it("returns 400 for an unknown provider", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const { status } = await callGetProviderAccessToken({ + provider: "not-a-real-provider", + uid, + idToken, + }); + expect(status).toBe(400); + }); +}); + +describe("getProviderAccessToken token resolution", () => { + it("returns 400 surfacing PROVIDER_NOT_CONNECTED when no gdrive account is connected", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + // Deliberately no connectedAccounts.gdrive seeded. + const { status, body } = await callGetProviderAccessToken({ provider: "gdrive", uid, idToken }); + + expect(status).toBe(400); + expect(body).toEqual(expect.objectContaining(MESSAGES.PROVIDER_NOT_CONNECTED)); + }); + + it("returns 200 with the decrypted accessToken for a connected gdrive user, and nothing else", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + await seedGdriveUser(uid); + + const { status, body } = await callGetProviderAccessToken({ provider: "gdrive", uid, idToken }); + + expect(status).toBe(200); + expect(body).toEqual({ accessToken: "get-token-plaintext-access-token" }); + // No refresh token or other user data leaks into the response. + expect(Object.keys(body).sort()).toEqual(["accessToken"]); + }); +}); diff --git a/functions/src/create-experiment.ts b/functions/src/create-experiment.ts index 32c22a2..fa19714 100644 --- a/functions/src/create-experiment.ts +++ b/functions/src/create-experiment.ts @@ -59,12 +59,18 @@ export const createExperiment = onRequest({ cors: true }, async (req, res) => { idToken, uid, experimentSettings, + parentFolderId, }: { provider?: string; title?: string; idToken?: string; uid?: string; experimentSettings?: ExperimentSettingsOverrides; + // Researcher-chosen Drive folder (via the Picker) to create the + // experiment's data folder under, instead of the default DataPipe + // root. Optional and provider-shaped -- createDataContainer ignores it + // for providers that don't understand a parentId. + parentFolderId?: string; } = req.body || {}; if (!provider || !title || !uid) { @@ -113,7 +119,7 @@ export const createExperiment = onRequest({ cors: true }, async (req, res) => { try { providerContainer = await storageProvider.createDataContainer( { token: tokenResult.token }, - { name: title } + { name: title, ...(parentFolderId ? { parentId: parentFolderId } : {}) } ); } catch (e) { const detail = e instanceof Error ? e.message : "Unknown error"; diff --git a/functions/src/get-provider-access-token.ts b/functions/src/get-provider-access-token.ts new file mode 100644 index 0000000..d686311 --- /dev/null +++ b/functions/src/get-provider-access-token.ts @@ -0,0 +1,89 @@ +// Short-lived provider access token for client-side Google Picker use +// (docs/provider-migration-design.md). The Picker's folder-choosing UI +// (a later, front-end build step) needs a raw Drive access token in the +// browser, but decrypting/refreshing that token is server-only work that +// only resolve-token.ts can do -- this endpoint is the one place that +// hands a decrypted token back to an authenticated caller. +// +// Auth + request shape mirrors connect-provider.ts's storage-grant flow +// (POST only, { provider, uid, idToken } body, verifyOwnership for +// 401/403). Token resolution mirrors create-experiment.ts's use of +// resolve-token.ts, including its MESSAGES mapping on failure. + +import { onRequest } from "firebase-functions/v2/https"; +import { db } from "./app.js"; +import { verifyOwnership } from "./connect-provider.js"; +import resolveToken from "./resolve-token.js"; +import { getOAuthConfig } from "./providers/oauth-config.js"; +import { StorageProviderId } from "./providers/types.js"; +import { ExperimentData, UserData } from "./interfaces.js"; +import MESSAGES from "./api-messages.js"; + +export const getProviderAccessToken = onRequest({ cors: true }, async (req, res) => { + try { + if (req.method !== "POST") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + const { + provider, + uid, + idToken, + }: { provider?: string; uid?: string; idToken?: string } = req.body || {}; + + if (!provider || !uid) { + res.status(400).json({ error: "Missing required parameters" }); + return; + } + + // getOAuthConfig only has an entry for OAuth2 providers (gdrive today). + // OSF deliberately has no entry -- its identity flow is a separate, + // legacy path (oauth2-callback.ts) -- so this single check rejects both + // "osf" and any unregistered/unknown provider, same as connect-provider.ts. + try { + getOAuthConfig(provider); + } catch { + res.status(400).json({ error: "Unknown provider" }); + 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; + } + + const userDoc = await db.doc(`users/${uid}`).get(); + // A freshly-signed-up user may have no Firestore doc yet -- treat that + // the same as "no connected accounts" rather than throwing, so + // PROVIDER_NOT_CONNECTED applies uniformly (same as create-experiment.ts). + 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; + } + + // Only the access token -- never the refresh token or any other user + // data. resolve-token.ts's TokenResult carries no expiry to surface + // alongside it. + res.status(200).json({ accessToken: tokenResult.token }); + } catch (error) { + console.error( + "Error getting provider access token:", + error instanceof Error ? error.message : "Unknown error" + ); + res.status(500).json({ error: "Failed to get provider access token" }); + } +}); diff --git a/functions/src/index.ts b/functions/src/index.ts index 082cbde..c4d9f76 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -16,6 +16,7 @@ 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"; +import { getProviderAccessToken } from "./get-provider-access-token.js"; setGlobalOptions({ maxInstances: 20 @@ -38,5 +39,6 @@ export { saveOsfToken as saveosftoken, getOsfToken as getosftoken, onUserDeleted as onuserdeleted, - createExperiment as createexperiment + createExperiment as createexperiment, + getProviderAccessToken as getprovideraccesstoken }; diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index b88e86a..a863b75 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -190,15 +190,27 @@ export const gdriveProvider: StorageProvider = { 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"); + const parentId = researcherInput.parentId as string | undefined; + + // A researcher-chosen parent (via the Picker) bypasses the DataPipe-root + // convention entirely -- the experiment folder is created directly under + // whatever folder they picked. Absent a parentId, fall back to today's + // behavior: find-or-create a shared "DataPipe" folder at root and nest + // the experiment folder under that. + let targetParentId: string; + if (parentId) { + targetParentId = parentId; + } else { + let rootId = await findFolder(auth, "DataPipe", "root"); + if (!rootId) { + rootId = await createFolder(auth, "DataPipe", "root"); + } + targetParentId = rootId; } // 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); + const folderId = await createFolder(auth, name, targetParentId); return { provider: "gdrive", folderId }; }, From 3e0a7737d73e32bf3977c659ec410e1c99bab49c Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Sat, 25 Jul 2026 13:20:07 -0400 Subject: [PATCH 2/2] feat: Google Drive folder picker for experiment creation (front-end) Lets researchers optionally choose which existing Drive folder an experiment's data folder is created in, via the Google Picker. If no folder is chosen, behavior is unchanged (My Drive/DataPipe/). - lib/google-picker.js: isolated gapi/Picker loader (memoized, SSR-guarded) exposing pickDriveFolder() -> { id, name } | null (cancel). Builds a folder-only DocsView with setAppId so the drive.file grant ties to our app and the picked folder is writable server-side later. - pages/admin/new.js (gdrive path only): a "Choose Drive folder" button that fetches a short-lived token from /api/getprovideraccesstoken, opens the Picker, and stores the selection; passed to createProviderExperiment. - createProviderExperiment forwards the optional parentFolderId. - firebase-deploy-test.yml: NEXT_PUBLIC_GOOGLE_PICKER_API_KEY (referrer/API restricted, browser-safe) + NEXT_PUBLIC_GDRIVE_PROJECT_NUMBER. Tests: parentFolderId forwarding/omission, picker script single-injection + PICKED/CANCEL paths; existing new-experiment-page regression intact. Verified: 11/11 front-end tests green, build compiles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .github/workflows/firebase-deploy-test.yml | 5 + __tests__/experiment-creation.test.js | 130 ++++++++++++++++++++ __tests__/google-picker.test.js | 91 ++++++++++++++ lib/experiment-creation.js | 3 +- lib/google-picker.js | 133 +++++++++++++++++++++ pages/admin/new.js | 89 +++++++++++++- 6 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 __tests__/experiment-creation.test.js create mode 100644 __tests__/google-picker.test.js create mode 100644 lib/google-picker.js diff --git a/.github/workflows/firebase-deploy-test.yml b/.github/workflows/firebase-deploy-test.yml index 22f513a..bf84a6a 100644 --- a/.github/workflows/firebase-deploy-test.yml +++ b/.github/workflows/firebase-deploy-test.yml @@ -20,6 +20,11 @@ env: NEXT_PUBLIC_GENERATE_STATE: "https://datapipe-test.web.app/api/generateoauthstate" NEXT_PUBLIC_BASE_URL: "https://datapipe-test.web.app" NEXT_PUBLIC_OSF_ENV: "" + # Google Picker (folder selection for gdrive experiments). The API key is + # restricted to the Picker API + our domains, so it is safe in the browser + # bundle -- not a secret. The project number is public. + NEXT_PUBLIC_GOOGLE_PICKER_API_KEY: "AIzaSyDLg6uprrY5BPjY4ClVZGSvy7sd_ug0t9M" + NEXT_PUBLIC_GDRIVE_PROJECT_NUMBER: "699904257039" jobs: deploy: diff --git a/__tests__/experiment-creation.test.js b/__tests__/experiment-creation.test.js new file mode 100644 index 0000000..d865a31 --- /dev/null +++ b/__tests__/experiment-creation.test.js @@ -0,0 +1,130 @@ +// nanoid v5 ships ESM-only and isn't transformed by the default Jest config +// (node_modules is excluded); experiment-creation.js imports it at the top +// level for the (untested-here) OSF path, so it must be mocked even though +// createProviderExperiment itself never calls it. +jest.mock("nanoid", () => ({ + customAlphabet: () => () => "mocked-id", +})); + +jest.mock("../lib/firebase", () => ({ + auth: { currentUser: null }, + db: {}, +})); + +// firebase/firestore's doc/writeBatch/arrayUnion (used by the untested-here +// OSF path in this module) 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), +})); + +import { createProviderExperiment } from "../lib/experiment-creation"; +import { auth } from "../lib/firebase"; + +function mockUser({ uid = "user-123", idToken = "id-token-abc" } = {}) { + return { + uid, + getIdToken: jest.fn().mockResolvedValue(idToken), + }; +} + +describe("createProviderExperiment", () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + auth.currentUser = null; + }); + + it("omits parentFolderId from the request body when not provided", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ experimentID: "exp-1" }), + }); + + const result = await createProviderExperiment("gdrive", "My Experiment"); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe("/api/createexperiment"); + const body = JSON.parse(options.body); + expect(body).toEqual({ + provider: "gdrive", + title: "My Experiment", + uid: "user-123", + idToken: "id-token-abc", + }); + expect(body.parentFolderId).toBeUndefined(); + expect(result).toEqual({ experimentId: "exp-1" }); + }); + + it("forwards parentFolderId in the request body when provided", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ experimentID: "exp-2" }), + }); + + const result = await createProviderExperiment( + "gdrive", + "My Experiment", + "folder-xyz" + ); + + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.parentFolderId).toBe("folder-xyz"); + expect(body).toEqual({ + provider: "gdrive", + title: "My Experiment", + uid: "user-123", + idToken: "id-token-abc", + parentFolderId: "folder-xyz", + }); + expect(result).toEqual({ experimentId: "exp-2" }); + }); + + it("omits parentFolderId when it is falsy (empty string)", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ experimentID: "exp-3" }), + }); + + await createProviderExperiment("gdrive", "My Experiment", ""); + + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.parentFolderId).toBeUndefined(); + }); + + it("throws when the user is not authenticated", async () => { + auth.currentUser = null; + + await expect( + createProviderExperiment("gdrive", "My Experiment") + ).rejects.toThrow("User not authenticated"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("throws the server error message when the request fails", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: "Forbidden" }), + }); + + await expect( + createProviderExperiment("gdrive", "My Experiment") + ).rejects.toThrow("Forbidden"); + }); +}); diff --git a/__tests__/google-picker.test.js b/__tests__/google-picker.test.js new file mode 100644 index 0000000..f96ad0c --- /dev/null +++ b/__tests__/google-picker.test.js @@ -0,0 +1,91 @@ +import { pickDriveFolder } from "../lib/google-picker"; + +// Flushes the microtask queue (all chained Promise .then callbacks that were +// already scheduled) without needing to know exactly how many hops deep the +// module's internal promise chain is. +function flushMicrotasks() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("pickDriveFolder", () => { + beforeEach(() => { + document.body.innerHTML = ""; + delete window.gapi; + delete window.google; + }); + + it("injects the Google API script only once across multiple calls, and resolves/cancels via the picker callback", async () => { + const appendChildSpy = jest.spyOn(document.body, "appendChild"); + + let capturedCallback; + const fakePicker = { setVisible: jest.fn() }; + const fakeGoogle = { + picker: { + ViewId: { FOLDERS: "folders" }, + Action: { PICKED: "picked", CANCEL: "cancel" }, + DocsView: jest.fn().mockImplementation(() => ({ + setMimeTypes: jest.fn().mockReturnThis(), + setSelectFolderEnabled: jest.fn().mockReturnThis(), + setIncludeFolders: jest.fn().mockReturnThis(), + })), + PickerBuilder: jest.fn().mockImplementation(() => ({ + addView: jest.fn().mockReturnThis(), + setOAuthToken: jest.fn().mockReturnThis(), + setDeveloperKey: jest.fn().mockReturnThis(), + setAppId: jest.fn().mockReturnThis(), + setCallback: jest.fn(function (cb) { + capturedCallback = cb; + return this; + }), + build: jest.fn(() => fakePicker), + })), + }, + }; + const fakeGapi = { + load: jest.fn((_name, { callback }) => callback()), + }; + + // Simulate the real <script src="apis.google.com/js/api.js"> tag: once + // it's appended to the DOM, it would set window.gapi and fire "load". + appendChildSpy.mockImplementation((node) => { + if (node.tagName === "SCRIPT") { + window.gapi = fakeGapi; + window.google = fakeGoogle; + node.dispatchEvent(new Event("load")); + } + return node; + }); + + // First call: user cancels the picker. + const firstPick = pickDriveFolder({ + accessToken: "token-1", + apiKey: "key", + appId: "app-id", + }); + await flushMicrotasks(); + expect(typeof capturedCallback).toBe("function"); + capturedCallback({ action: fakeGoogle.picker.Action.CANCEL }); + await expect(firstPick).resolves.toBeNull(); + + // Second call: user picks a folder. + const secondPick = pickDriveFolder({ + accessToken: "token-2", + apiKey: "key", + appId: "app-id", + }); + await flushMicrotasks(); + capturedCallback({ + action: fakeGoogle.picker.Action.PICKED, + docs: [{ id: "folder-1", name: "My Folder" }], + }); + await expect(secondPick).resolves.toEqual({ + id: "folder-1", + name: "My Folder", + }); + + const scriptAppends = appendChildSpy.mock.calls.filter( + ([node]) => node.tagName === "SCRIPT" + ); + expect(scriptAppends).toHaveLength(1); + }); +}); diff --git a/lib/experiment-creation.js b/lib/experiment-creation.js index e655102..4fc22df 100644 --- a/lib/experiment-creation.js +++ b/lib/experiment-creation.js @@ -161,7 +161,7 @@ export async function createExperimentDocument(experimentData) { // decrypted provider token that only resolve-token.ts can produce. This // helper just calls that endpoint and normalizes the response shape to // match createExperiment()'s { experimentId } contract. -export async function createProviderExperiment(provider, title) { +export async function createProviderExperiment(provider, title, parentFolderId) { const user = auth.currentUser; if (!user) { throw new Error("User not authenticated"); @@ -179,6 +179,7 @@ export async function createProviderExperiment(provider, title) { title, uid: user.uid, idToken, + ...(parentFolderId ? { parentFolderId } : {}), }), }); diff --git a/lib/google-picker.js b/lib/google-picker.js new file mode 100644 index 0000000..b5ed252 --- /dev/null +++ b/lib/google-picker.js @@ -0,0 +1,133 @@ +// Encapsulates all Google Picker / gapi interaction so that React +// components stay thin and the Google-specific browser API surface is +// isolated to a single module. No secrets live here -- the apiKey/appId +// are passed in by the caller (sourced from env vars). + +const PICKER_SCRIPT_SRC = "https://apis.google.com/js/api.js"; + +let gapiLoadPromise = null; +let pickerLoadPromise = null; + +function loadGapiScript() { + if (typeof window === "undefined") { + return Promise.reject(new Error("Google Picker is only available in the browser")); + } + + if (window.gapi) { + return Promise.resolve(window.gapi); + } + + if (gapiLoadPromise) { + return gapiLoadPromise; + } + + gapiLoadPromise = new Promise((resolve, reject) => { + const existingScript = document.querySelector( + `script[src="${PICKER_SCRIPT_SRC}"]` + ); + + const handleLoad = () => { + if (window.gapi) { + resolve(window.gapi); + } else { + reject(new Error("Failed to load Google API script")); + } + }; + + if (existingScript) { + // Script tag is already present (e.g. injected by a previous call + // that hasn't finished loading yet) -- just wait for it. + existingScript.addEventListener("load", handleLoad); + existingScript.addEventListener("error", () => + reject(new Error("Failed to load Google API script")) + ); + return; + } + + const script = document.createElement("script"); + script.src = PICKER_SCRIPT_SRC; + script.async = true; + script.defer = true; + script.addEventListener("load", handleLoad); + script.addEventListener("error", () => + reject(new Error("Failed to load Google API script")) + ); + document.body.appendChild(script); + }); + + return gapiLoadPromise; +} + +function loadPicker() { + if (pickerLoadPromise) { + return pickerLoadPromise; + } + + pickerLoadPromise = loadGapiScript().then( + (gapi) => + new Promise((resolve, reject) => { + if (gapi.picker) { + resolve(gapi); + return; + } + gapi.load("picker", { + callback: () => resolve(gapi), + onerror: () => reject(new Error("Failed to load Google Picker API")), + }); + }) + ); + + return pickerLoadPromise; +} + +/** + * Opens the Google Picker configured for selecting a single Drive folder. + * + * @param {Object} options + * @param {string} options.accessToken - short-lived drive.file OAuth token + * @param {string} options.apiKey - Google API developer key + * @param {string} options.appId - Google Cloud project number, ties the + * drive.file grant to our app so the folder is writable server-side later + * @returns {Promise<{id: string, name: string} | null>} resolves with the + * chosen folder's id/name, or null if the user cancelled the picker. + */ +export async function pickDriveFolder({ accessToken, apiKey, appId }) { + await loadPicker(); + const google = window.google; + + if (!google || !google.picker) { + throw new Error("Google Picker failed to initialize"); + } + + return new Promise((resolve, reject) => { + try { + const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS) + .setMimeTypes("application/vnd.google-apps.folder") + .setSelectFolderEnabled(true) + .setIncludeFolders(true); + + const picker = new google.picker.PickerBuilder() + .addView(view) + .setOAuthToken(accessToken) + .setDeveloperKey(apiKey) + .setAppId(appId) + .setCallback((data) => { + if (data.action === google.picker.Action.PICKED) { + const doc = data.docs && data.docs[0]; + if (doc) { + resolve({ id: doc.id, name: doc.name }); + } else { + resolve(null); + } + } else if (data.action === google.picker.Action.CANCEL) { + resolve(null); + } + }) + .build(); + + picker.setVisible(true); + } catch (err) { + reject(err); + } + }); +} diff --git a/pages/admin/new.js b/pages/admin/new.js index 1baabe7..5caf100 100644 --- a/pages/admin/new.js +++ b/pages/admin/new.js @@ -7,6 +7,7 @@ import { useDocumentData } from "react-firebase-hooks/firestore"; import Link from "next/link"; import Router from "next/router"; import { createExperiment, createProviderExperiment } from "../../lib/experiment-creation"; +import { pickDriveFolder } from "../../lib/google-picker"; import { STORAGE_PROVIDERS } from "../../lib/provider-config"; import { Button, @@ -48,6 +49,8 @@ function NewExperimentForm() { const [gdriveTitleError, setGdriveTitleError] = useState(false); const [gdriveSubmitting, setGdriveSubmitting] = useState(false); const [gdriveError, setGdriveError] = useState(null); + const [selectedFolder, setSelectedFolder] = useState(null); + const [folderPickerLoading, setFolderPickerLoading] = useState(false); const [data, loading, error] = useDocumentData(doc(db, "users", user.uid)); @@ -104,7 +107,11 @@ function NewExperimentForm() { } try { - const result = await createProviderExperiment("gdrive", gdriveTitle); + const result = await createProviderExperiment( + "gdrive", + gdriveTitle, + selectedFolder?.id + ); Router.push(`/admin/${result.experimentId}`); } catch (err) { console.error(err); @@ -113,6 +120,56 @@ function NewExperimentForm() { } }; + const handleChooseFolder = async () => { + setFolderPickerLoading(true); + setGdriveError(null); + + try { + const user = auth.currentUser; + if (!user) { + throw new Error("User not authenticated"); + } + const idToken = await user.getIdToken(); + + const response = await fetch("/api/getprovideraccesstoken", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "gdrive", + uid: user.uid, + idToken, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error( + data.error || `Failed to get Google Drive access: ${response.status}` + ); + } + + const folder = await pickDriveFolder({ + accessToken: data.accessToken, + apiKey: process.env.NEXT_PUBLIC_GOOGLE_PICKER_API_KEY, + appId: process.env.NEXT_PUBLIC_GDRIVE_PROJECT_NUMBER, + }); + + if (folder) { + setSelectedFolder(folder); + } + } catch (err) { + console.error(err); + setGdriveError(err.message); + } finally { + setFolderPickerLoading(false); + } + }; + + const handleClearFolder = () => { + setSelectedFolder(null); + }; + return ( <> {loading && <Spinner color="brandTeal.500" size={"xl"} />} @@ -272,6 +329,36 @@ function NewExperimentForm() { This field is required </Field.ErrorText> </Field.Root> + <Field.Root> + <Field.Label>Parent Drive Folder (optional)</Field.Label> + <HStack gap={3}> + <Button + variant="outline" + size="md" + loading={folderPickerLoading} + onClick={handleChooseFolder} + > + Choose Drive folder + </Button> + {selectedFolder && ( + <HStack gap={2}> + <Text fontSize="sm">{selectedFolder.name}</Text> + <Button + variant="ghost" + size="xs" + onClick={handleClearFolder} + > + Clear + </Button> + </HStack> + )} + </HStack> + <Field.HelperText color="gray"> + {selectedFolder + ? "The experiment's data folder will be created inside this folder." + : "If not set, the experiment's data folder will be created in My Drive/DataPipe."} + </Field.HelperText> + </Field.Root> <Button onClick={handleGdriveSubmit} loading={gdriveSubmitting}