Skip to content
Draft
351 changes: 351 additions & 0 deletions e2e/tests/urlParams.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
import { expect, test } from "@playwright/test";
import { clientUrl, registerNewUser, registerDandiset } from "../utils.ts";
import { faker } from "@faker-js/faker";

/**
* Tests that listing query parameters (page, sortOption, sortDir, showDrafts,
* showEmpty, search) do not leak into Dandiset Landing Page (DLP) URLs,
* and vice-versa. Note: ?pos= IS allowed on DLP URLs (position in result set).
* See https://github.com/dandi/dandi-archive/issues/1460
*/

// These listing-specific params must NOT appear on DLP URLs.
// Note: 'pos' and 'search' are allowed — pos for result set position, search for context.
const LISTING_PARAMS = ["page", "sortOption", "sortDir", "showDrafts", "showEmpty"];

/** Assert that none of the listing-specific params appear in the current URL. */
function expectNoLeakedParams(url: string) {
const parsed = new URL(url);
for (const param of LISTING_PARAMS) {
expect(parsed.searchParams.has(param), `URL should not contain '${param}': ${url}`).toBeFalsy();
}
}

test.describe("URL parameter isolation (issue #1460)", () => {
test("listing params do not leak to DLP when clicking a dandiset", async ({ page }) => {
await registerNewUser(page);
const name = faker.lorem.words();
const description = faker.lorem.sentences();
await registerDandiset(page, name, description);

// Go to the public dandisets listing
await page.goto(`${clientUrl}/dandiset`);
await page.waitForLoadState("networkidle");

// Click the first dandiset in the list
await page.locator(".v-list-item").first().click();
await page.waitForLoadState("networkidle");

// DLP URL must not contain listing params
expectNoLeakedParams(page.url());
// Should be on a dandiset page with pos param
expect(page.url()).toMatch(/\/dandiset\/\d+/);
const url = new URL(page.url());
expect(url.searchParams.has("pos")).toBeTruthy();
});

test("search context is preserved but listing params do not leak to DLP", async ({ page }) => {
await registerNewUser(page);
const name = `searchtest-${faker.lorem.word()}`;
const description = faker.lorem.sentences();
await registerDandiset(page, name, description);

// Navigate to search with a query
await page.goto(`${clientUrl}/dandiset/search?search=${name}`);
await page.waitForLoadState("networkidle");

// Verify search results appear
const items = page.locator(".v-list-item");
await expect(items.first()).toBeVisible();

// Click the first result
await items.first().click();
await page.waitForLoadState("networkidle");

// DLP URL must not have listing params
expectNoLeakedParams(page.url());
// But search and pos should be present for context
const url = new URL(page.url());
expect(url.searchParams.get("search")).toBe(name);
expect(url.searchParams.has("pos")).toBeTruthy();
});

test("direct DLP navigation produces clean URL", async ({ page }) => {
await registerNewUser(page);
const name = faker.lorem.words();
const description = faker.lorem.sentences();
const dandisetId = await registerDandiset(page, name, description);

// Navigate directly to the DLP
await page.goto(`${clientUrl}/dandiset/${dandisetId}`);
await page.waitForLoadState("networkidle");

expectNoLeakedParams(page.url());
});

test("DLP pagination updates pos but does not add listing params", async ({ page }) => {
test.slow();
await registerNewUser(page);

// Create multiple dandisets so pagination has something to page through
for (let i = 0; i < 3; i += 1) {
await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
}

// Go to listing and click a dandiset
await page.goto(`${clientUrl}/dandiset`);
await page.waitForLoadState("networkidle");
await page.locator(".v-list-item").first().click();
await page.waitForLoadState("networkidle");

const initialPos = new URL(page.url()).searchParams.get("pos");
expect(initialPos).toBeTruthy();

// If pagination is visible, click next and verify pos changes but no listing params leak
const nextButton = page.locator(".v-pagination__next button");
if (await nextButton.isVisible()) {
await nextButton.click();
await page.waitForLoadState("networkidle");
expectNoLeakedParams(page.url());
const newPos = new URL(page.url()).searchParams.get("pos");
expect(newPos).toBeTruthy();
expect(newPos).not.toBe(initialPos);
}
});

test("back button from DLP restores listing params", async ({ page }) => {
await registerNewUser(page);
await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());

// Go to listing
await page.goto(`${clientUrl}/dandiset`);
await page.waitForLoadState("networkidle");

// Capture listing URL
const listingUrl = page.url();

// Click into a dandiset
await page.locator(".v-list-item").first().click();
await page.waitForLoadState("networkidle");

// Go back
await page.goBack();
await page.waitForLoadState("networkidle");

// Should be back on listing with its own params intact
const backUrl = new URL(page.url());
expect(backUrl.pathname).toBe("/dandiset");
});

test("manually appended listing params are stripped from DLP URL", async ({ page }) => {
await registerNewUser(page);
const name = faker.lorem.words();
const description = faker.lorem.sentences();
const dandisetId = await registerDandiset(page, name, description);

// Navigate to DLP with manually injected listing params
// pos and search are allowed; page/sortOption/sortDir/showDrafts/showEmpty are not
await page.goto(
`${clientUrl}/dandiset/${dandisetId}?page=2&sortOption=1&sortDir=-1&showDrafts=true&showEmpty=false&search=test&pos=5`
);
await page.waitForLoadState("networkidle");

// The router guard should have stripped listing params but kept pos and search
expectNoLeakedParams(page.url());
const url = new URL(page.url());
expect(url.searchParams.get("pos")).toBe("5");
expect(url.searchParams.get("search")).toBe("test");
});
});

test.describe("DLP tab URL state", () => {
test("clicking 'How to Cite' tab adds ?tab=how-to-cite to URL", async ({ page }) => {
await registerNewUser(page);
const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());

await page.goto(`${clientUrl}/dandiset/${dandisetId}`);
await page.waitForLoadState("networkidle");

// URL should have no tab param on Overview (default)
expect(new URL(page.url()).searchParams.has("tab")).toBeFalsy();

// Click "How to Cite" tab
await page.getByRole("tab", { name: "How to Cite" }).click();
await page.waitForTimeout(500);

// URL should now have ?tab=how-to-cite
const url = new URL(page.url());
expect(url.searchParams.get("tab")).toBe("how-to-cite");
});

test("switching back to Overview removes tab param", async ({ page }) => {
await registerNewUser(page);
const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());

await page.goto(`${clientUrl}/dandiset/${dandisetId}?tab=how-to-cite`);
await page.waitForLoadState("networkidle");

// Should be on How to Cite tab
await expect(page.getByText("How to Cite this Dataset")).toBeVisible();

// Switch to Overview
await page.getByRole("tab", { name: "Overview" }).click();
await page.waitForTimeout(500);

// tab param should be gone
expect(new URL(page.url()).searchParams.has("tab")).toBeFalsy();
});

test("navigating to ?tab=how-to-cite opens the correct tab", async ({ page }) => {
await registerNewUser(page);
const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());

// Navigate directly with tab param
await page.goto(`${clientUrl}/dandiset/${dandisetId}?tab=how-to-cite`);
await page.waitForLoadState("networkidle");

// The How to Cite content should be visible
await expect(page.getByText("How to Cite this Dataset")).toBeVisible();
});

test("tab param survives page reload", async ({ page }) => {
await registerNewUser(page);
const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());

await page.goto(`${clientUrl}/dandiset/${dandisetId}`);
await page.waitForLoadState("networkidle");

// Click How to Cite
await page.getByRole("tab", { name: "How to Cite" }).click();
await page.waitForTimeout(500);

// Reload
await page.reload();
await page.waitForLoadState("networkidle");

// Should still be on How to Cite
await expect(page.getByText("How to Cite this Dataset")).toBeVisible();
expect(new URL(page.url()).searchParams.get("tab")).toBe("how-to-cite");
});
});

test.describe("DLP citation format URL state", () => {
test("selecting a citation format adds ?format= to URL", async ({ page }) => {
await registerNewUser(page);
const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());

await page.goto(`${clientUrl}/dandiset/${dandisetId}?tab=how-to-cite`);
await page.waitForLoadState("networkidle");

// Default (APA) should have no format param
expect(new URL(page.url()).searchParams.has("format")).toBeFalsy();

// Select BibTeX format
await page.locator(".citation-format-select").click();
await page.getByRole("option", { name: "BibTeX" }).click();
await page.waitForTimeout(500);

const url = new URL(page.url());
expect(url.searchParams.get("format")).toBe("bibtex");
expect(url.searchParams.get("tab")).toBe("how-to-cite");
});

test("navigating to ?tab=how-to-cite&format=harvard opens correct format", async ({ page }) => {
await registerNewUser(page);
const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());

await page.goto(`${clientUrl}/dandiset/${dandisetId}?tab=how-to-cite&format=harvard`);
await page.waitForLoadState("networkidle");

// Harvard should be selected in the dropdown
await expect(page.locator(".citation-format-select")).toContainText("Harvard");
});

test("citation format survives page reload", async ({ page }) => {
await registerNewUser(page);
const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());

await page.goto(`${clientUrl}/dandiset/${dandisetId}?tab=how-to-cite`);
await page.waitForLoadState("networkidle");

// Select Harvard
await page.locator(".citation-format-select").click();
await page.getByRole("option", { name: "Harvard" }).click();
await page.waitForTimeout(500);

// Reload
await page.reload();
await page.waitForLoadState("networkidle");

// Should still be on How to Cite with Harvard selected
await expect(page.getByText("How to Cite this Dataset")).toBeVisible();
await expect(page.locator(".citation-format-select")).toContainText("Harvard");
const url = new URL(page.url());
expect(url.searchParams.get("tab")).toBe("how-to-cite");
expect(url.searchParams.get("format")).toBe("harvard");
});

test("switching back to APA removes format param", async ({ page }) => {
await registerNewUser(page);
const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());

await page.goto(`${clientUrl}/dandiset/${dandisetId}?tab=how-to-cite&format=bibtex`);
await page.waitForLoadState("networkidle");

// Select APA (default)
await page.locator(".citation-format-select").click();
await page.getByRole("option", { name: "APA 7th" }).click();
await page.waitForTimeout(500);

// format param should be gone, tab should remain
const url = new URL(page.url());
expect(url.searchParams.has("format")).toBeFalsy();
expect(url.searchParams.get("tab")).toBe("how-to-cite");
});
});

test.describe("DLP meditor overlay URL state", () => {
test("opening meditor adds ?overlay=meditor to URL", async ({ page }) => {
await registerNewUser(page);
await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
await page.waitForLoadState("networkidle");

// No overlay param initially
expect(new URL(page.url()).searchParams.has("overlay")).toBeFalsy();

// Click the metadata edit button to open meditor
await page.getByText("Metadata", { exact: true }).click();
await page.waitForTimeout(500);

expect(new URL(page.url()).searchParams.get("overlay")).toBe("meditor");
});

test("closing meditor removes overlay param", async ({ page }) => {
await registerNewUser(page);
const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());

await page.goto(`${clientUrl}/dandiset/${dandisetId}?overlay=meditor`);
await page.waitForLoadState("networkidle");

// Meditor dialog should be open
await expect(page.locator(".v-overlay--active .v-card")).toBeVisible();

// Close the dialog (press Escape)
await page.keyboard.press("Escape");
await page.waitForTimeout(500);

// overlay param should be gone
expect(new URL(page.url()).searchParams.has("overlay")).toBeFalsy();
});

test("navigating to ?overlay=meditor opens the meditor", async ({ page }) => {
await registerNewUser(page);
const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());

await page.goto(`${clientUrl}/dandiset/${dandisetId}?overlay=meditor`);
await page.waitForLoadState("networkidle");

// Meditor dialog should be visible
await expect(page.locator(".v-overlay--active .v-card")).toBeVisible();
});
});
25 changes: 22 additions & 3 deletions web/src/components/DLP/HowToCiteTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,8 @@
</template>

<script setup lang="ts">
import { computed, type PropType, ref } from 'vue';
import { computed, type PropType, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';

import { useDandisetStore } from '@/stores/dandiset';
import type { DandisetMetadata } from '@/types';
Expand Down Expand Up @@ -273,8 +274,26 @@ const latestPublishedVersionLink = computed(() => {
// Define citation format types
type CitationFormat = 'apa' | 'mla' | 'chicago' | 'harvard' | 'vancouver' | 'ieee' | 'bibtex' | 'ris' | 'cff';

// Citation format state
const selectedCitationFormat = ref<CitationFormat>('apa'); // For the dropdown in Full Citation section
// Sync citation format with ?format= URL query param.
const route = useRoute();
const router = useRouter();
const validFormats: CitationFormat[] = ['apa', 'mla', 'chicago', 'harvard', 'vancouver', 'ieee', 'bibtex', 'ris', 'cff'];
const initialFormat = validFormats.includes(route.query.format as CitationFormat)
? route.query.format as CitationFormat
: 'apa';
const selectedCitationFormat = ref<CitationFormat>(initialFormat);

watch(selectedCitationFormat, (fmt) => {
const currentFmt = route.query.format as string | undefined;
if (fmt === currentFmt) return;
const query = { ...route.query };
if (fmt === 'apa') {
delete query.format;
} else {
query.format = fmt;
}
router.replace({ ...route, query });
});

// Generate CFF object from metadata
const cffObject = computed(() => {
Expand Down
Loading
Loading