diff --git a/e2e/tests/urlParams.spec.ts b/e2e/tests/urlParams.spec.ts new file mode 100644 index 000000000..bdb9e8f7e --- /dev/null +++ b/e2e/tests/urlParams.spec.ts @@ -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(); + }); +}); diff --git a/web/src/components/DLP/HowToCiteTab.vue b/web/src/components/DLP/HowToCiteTab.vue index 0a480aaf8..8e4e27bc0 100644 --- a/web/src/components/DLP/HowToCiteTab.vue +++ b/web/src/components/DLP/HowToCiteTab.vue @@ -231,7 +231,8 @@ diff --git a/web/src/views/DandisetLandingView/DandisetMain.vue b/web/src/views/DandisetLandingView/DandisetMain.vue index e179b0ff2..6da07b773 100644 --- a/web/src/views/DandisetLandingView/DandisetMain.vue +++ b/web/src/views/DandisetLandingView/DandisetMain.vue @@ -267,6 +267,7 @@ import { computed, ref, + watch, watchEffect, type ComputedRef, } from 'vue'; @@ -276,6 +277,7 @@ import { marked } from 'marked'; import moment from 'moment'; import DOMPurify from 'dompurify'; import { useDisplay } from 'vuetify'; +import { useRoute, useRouter } from 'vue-router'; import { useDandisetStore } from '@/stores/dandiset'; import { getDoiMetadata } from '@/utils/doi'; @@ -292,11 +294,13 @@ const MAX_DESCRIPTION_LENGTH = 400; const tabs = [ { name: 'Overview', + slug: 'overview', component: OverviewTab, icon: 'mdi-information-outline', }, { name: 'How to Cite', + slug: 'how-to-cite', component: HowToCiteTab, icon: 'mdi-format-quote-close', }, @@ -358,7 +362,27 @@ const subjectMatter: ComputedRef = computed () => meta.value?.about, ); -const currentTab = ref(0); +const route = useRoute(); +const router = useRouter(); + +// Resolve the initial tab from the ?tab= query param (default to 0 / Overview). +const initialTabIndex = tabs.findIndex((t) => t.slug === route.query.tab); +const currentTab = ref(initialTabIndex >= 0 ? initialTabIndex : 0); + +// Sync tab selection → URL query param. +watch(currentTab, (index) => { + const slug = tabs[index]?.slug; + const currentSlug = route.query.tab as string | undefined; + if (slug === currentSlug) return; + // Use the default tab (overview) without a query param to keep the base URL clean. + const query = { ...route.query }; + if (index === 0) { + delete query.tab; + } else { + query.tab = slug; + } + router.replace({ ...route, query }); +}); function formatDate(date: string): string { return moment(date).format('LL');