Skip to content
Open
39 changes: 39 additions & 0 deletions __tests__/base-url.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @jest-environment node
*
* The deployment URL shown in the API reference and threaded into the code
* samples (#246). The test site used to hand out samples that wrote to
* production, because nothing read NEXT_PUBLIC_BASE_URL.
*/

import {
PRODUCTION_BASE_URL,
resolveBaseURL,
sampleBaseURL,
} from "../lib/base-url";

describe("resolveBaseURL", () => {
it("uses the configured deployment", () => {
expect(resolveBaseURL("https://datapipe-test.web.app")).toBe("https://datapipe-test.web.app");
});

it("drops trailing slashes so `${base}/api/...` never doubles one", () => {
expect(resolveBaseURL("https://datapipe-test.web.app//")).toBe("https://datapipe-test.web.app");
});

it("falls back to production when unset or blank", () => {
expect(resolveBaseURL(undefined)).toBe(PRODUCTION_BASE_URL);
expect(resolveBaseURL("")).toBe(PRODUCTION_BASE_URL);
expect(resolveBaseURL(" / ")).toBe(PRODUCTION_BASE_URL);
});
});

describe("sampleBaseURL", () => {
it("adds no override on production, where the packages' default is right", () => {
expect(sampleBaseURL(PRODUCTION_BASE_URL)).toBeNull();
});

it("names every other deployment explicitly", () => {
expect(sampleBaseURL("https://datapipe-test.web.app")).toBe("https://datapipe-test.web.app");
});
});
7 changes: 7 additions & 0 deletions __tests__/extension-snippet.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ describe("the jsPsych code sample", () => {
expect(calls.run).toHaveLength(1);
});

it("passes base_url to the extension when the build is not production", async () => {
const { calls } = await execute(extensionSnippet("EXP123", "https://datapipe-test.web.app"));
expect(calls.initialize).toEqual([
{ experiment_id: "EXP123", filename: "p42.csv", base_url: "https://datapipe-test.web.app" },
]);
});

it("appends no save trial: the extension owns the submission", async () => {
const { calls } = await execute(code);
expect(calls.run[0]).toHaveLength(0);
Expand Down
24 changes: 16 additions & 8 deletions components/dashboard/CodeHints.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import { ChevronDown } from "lucide-react";
import CodeBlock from "../CodeBlock";
import { extensionSnippet } from "./extension-snippet";
import { EXTENSION_PIPE_SCRIPT, DATAPIPE_CLIENT_SCRIPT } from "./script-tags";
import { SAMPLE_BASE_URL } from "../../lib/base-url";

// Off production, every sample names this build's deployment. The published
// packages default to pipe.jspsych.org, so a sample copied from the test site
// without an override sends its data to production (#246).
const EXTENSION_OPTIONS = SAMPLE_BASE_URL ? `, { base_url: "${SAMPLE_BASE_URL}" }` : "";
const CLIENT_OPTION = SAMPLE_BASE_URL ? `\n baseURL: "${SAMPLE_BASE_URL}",` : "";
const CLIENT_INLINE_OPTION = SAMPLE_BASE_URL ? `, baseURL: "${SAMPLE_BASE_URL}"` : "";

export default function CodeHints({ expId }) {
const [language, setLanguage] = useState("jsPsych v8");
Expand Down Expand Up @@ -92,7 +100,7 @@ export default function CodeHints({ expId }) {
<CodeBlock language="html">
{EXTENSION_PIPE_SCRIPT}
</CodeBlock>
<CodeBlock>{extensionSnippet(expId)}</CodeBlock>
<CodeBlock>{extensionSnippet(expId, SAMPLE_BASE_URL)}</CodeBlock>
<Text fontSize="sm" color="fg.muted">
Each trial is sent as it happens, so a participant who closes the tab partway through does not take all of their data with them: their completed trials arrive as a separate file ending in .partial.json, and do not count toward your session limit. A participant who finishes produces one ordinary file.
</Text>
Expand All @@ -117,7 +125,7 @@ export default function CodeHints({ expId }) {
recording_duration: 15000,
on_finish: async function(data){
const filename = \`\${subject_id}_\${jsPsych.getProgress().current_trial_global}_audio.webm\`;
await jsPsychExtensionPipe.saveBase64Data("${expId}", filename, data.response);
await jsPsychExtensionPipe.saveBase64Data("${expId}", filename, data.response${EXTENSION_OPTIONS});
data.response = filename;
}
};`}
Expand All @@ -140,7 +148,7 @@ export default function CodeHints({ expId }) {
async function createExperiment(){
let condition;
try {
condition = await jsPsychExtensionPipe.getCondition("${expId}");
condition = await jsPsychExtensionPipe.getCondition("${expId}"${EXTENSION_OPTIONS});
} catch (error) {
document.body.innerHTML = "<p>The experiment could not be started.</p>";
throw error;
Expand Down Expand Up @@ -181,7 +189,7 @@ export default function CodeHints({ expId }) {
const result = await DataPipe.saveData({
experiment_id: "${expId}",
filename: "UNIQUE_FILENAME.csv",
data: dataAsString,
data: dataAsString,${CLIENT_OPTION}
});

if (!result.ok) {
Expand Down Expand Up @@ -209,7 +217,7 @@ export default function CodeHints({ expId }) {
const filename = "UNIQUE_FILENAME.csv";
const session = DataPipe.createSession({
experiment_id: "${expId}",
filename: filename,
filename: filename,${CLIENT_OPTION}
});

// ...after each trial:
Expand All @@ -220,7 +228,7 @@ export default function CodeHints({ expId }) {
experiment_id: "${expId}",
filename: filename,
data: dataAsString,
session: session,
session: session,${CLIENT_OPTION}
});
await session.close({ submitted: result.ok });`}
</CodeBlock>
Expand All @@ -242,7 +250,7 @@ export default function CodeHints({ expId }) {
const result = await DataPipe.saveBase64Data({
experiment_id: "${expId}",
filename: "UNIQUE_FILENAME.webm",
data: base64DataString,
data: base64DataString,${CLIENT_OPTION}
});`}
</CodeBlock>
</VStack>
Expand All @@ -259,7 +267,7 @@ export default function CodeHints({ expId }) {
{`
let condition;
try {
condition = await DataPipe.getCondition({ experiment_id: "${expId}" });
condition = await DataPipe.getCondition({ experiment_id: "${expId}"${CLIENT_INLINE_OPTION} });
} catch (error) {
document.body.innerHTML = "<p>The experiment could not be started.</p>";
throw error;
Expand Down
9 changes: 7 additions & 2 deletions components/dashboard/extension-snippet.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,21 @@
// which time the declaration has run. The test covers this, because a reader
// copying the sample is likely to "simplify" it back into a string.
//
// `baseURL` is set only on builds that are not production (lib/base-url.js),
// where the extension's built-in default would send the data to the wrong
// deployment.
//
// Flush-left on purpose: CodeBlock strips the first line's indentation from
// every line, and there is none here to strip.
export function extensionSnippet(expId) {
export function extensionSnippet(expId, baseURL = null) {
const baseURLParam = baseURL ? `,\n base_url: "${baseURL}"` : "";
return `const jsPsych = initJsPsych({
extensions: [
{
type: jsPsychExtensionPipe,
params: {
experiment_id: "${expId}",
filename: () => \`\${subject_id}.csv\`
filename: () => \`\${subject_id}.csv\`${baseURLParam}
}
}
]
Expand Down
5 changes: 4 additions & 1 deletion components/docs/ApiPrimitives.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Stack, Heading, Text, Table, Badge, Code } from "@chakra-ui/react";
import { BASE_URL } from "../../lib/base-url";

/**
* ApiPrimitives
Expand All @@ -12,8 +13,10 @@ import { Stack, Heading, Text, Table, Badge, Code } from "@chakra-ui/react";
*
* Package D (docs IA plan §4) reuses these unchanged for /docs/api's moved
* API reference content.
*
* BASE_URL is the deployment this build serves (lib/base-url.js), so the test
* site's reference names the test site rather than production.
*/
export const BASE_URL = "https://pipe.jspsych.org";

export function EndpointHeading({ method, path, children }) {
return (
Expand Down
58 changes: 58 additions & 0 deletions functions/src/__tests__/create-experiment-log-redaction.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* @jest-environment node
*
* createExperimentHandler logs a failed createDataContainer to Cloud Logging.
* Auth rides in request headers, but a provider can still echo the token in
* its error text (a Dataverse installation answering "Bad api key <key>"), so
* the logged stack must be scrubbed. The 502 detail still goes back to the
* researcher unchanged: it is their own token, on their own request.
*/

const TOKEN = "dv-secret-token-1234";

jest.mock("../../lib/app.js", () => ({
db: { doc: () => ({ get: async () => ({ data: () => ({}) }) }) },
}));
jest.mock("../../lib/connect-provider.js", () => ({
verifyOwnership: async () => ({ ok: true }),
}));
jest.mock("../../lib/resolve-token.js", () => ({
__esModule: true,
default: async () => ({ success: true, token: TOKEN, serverUrl: "https://dataverse.mock.test" }),
}));
jest.mock("../../lib/providers/index.js", () => ({
listProviders: () => ["dataverse"],
getProvider: () => ({
containerInput: [],
createDataContainer: async () => {
throw new Error(`Dataverse dataset creation failed: 401 Bad api key ${TOKEN}`);
},
}),
}));

const { createExperimentHandler } = require("../../lib/create-experiment.js");

function mockRes() {
const res = {};
res.status = jest.fn(() => res);
res.json = jest.fn(() => res);
return res;
}

it("scrubs the provider token from the logged error", async () => {
const error = jest.spyOn(console, "error").mockImplementation(() => {});
const res = mockRes();

await createExperimentHandler(
{ method: "POST", body: { provider: "dataverse", title: "t", uid: "u1", idToken: "x" } },
res
);

expect(res.status).toHaveBeenCalledWith(502);
expect(error).toHaveBeenCalledTimes(1);
const logged = JSON.stringify(error.mock.calls[0]);
expect(logged).not.toContain(TOKEN);
expect(logged).toContain("Bad api key [redacted]");
expect(logged).toContain("create-experiment"); // the stack survives
error.mockRestore();
});
Loading
Loading