diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 611bdfd..9faddb2 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -59,4 +59,7 @@ jobs: - name: Select project run: firebase use test - name: Launch firestore emulator and test - run: firebase emulators:exec 'npm run test-ci' + # maxWorkers=2: the emulator-backed suites contend under full + # parallel load (pre-existing data-emulator timing flake); capping + # workers reproduces consistently-green runs. + run: firebase emulators:exec 'npm run test-ci -- --maxWorkers=2' 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..90ece43 --- /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: /Open folder/ }); + 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__/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/__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