Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
115 changes: 115 additions & 0 deletions __tests__/connect-callback-page.test.jsx
Original file line number Diff line number Diff line change
@@ -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 <UserContext.Provider value={...}>
// 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(
<ChakraProvider value={system}>
<UserContext.Provider value={{ user, loading: false }}>
<ConnectCallbackPage />
</UserContext.Provider>
</ChakraProvider>
);
}

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();
});
});
51 changes: 51 additions & 0 deletions __tests__/experiment-info.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<ChakraProvider value={system}>
<ExperimentInfo data={data} />
</ChakraProvider>
);
}

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();
});
});
146 changes: 146 additions & 0 deletions __tests__/firestore-rules.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
}));
});
});
});
Loading