From 27274171fe054a791c070e0a196652eb8424eba4 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:25:22 +0100 Subject: [PATCH] test(pokedex): cover shared dex privacy --- tests/bdd/features/pokedex-sharing.feature | 19 +++ tests/bdd/fixtures.ts | 4 +- tests/bdd/steps/sharing.steps.ts | 65 +++++++++ tests/integration/sharing.integration.test.ts | 138 ++++++++++++++++++ tests/unit/sharePreviewService.test.ts | 52 +++++++ tests/unit/sharedPokedexService.test.ts | 34 +++++ 6 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 tests/bdd/features/pokedex-sharing.feature create mode 100644 tests/bdd/steps/sharing.steps.ts create mode 100644 tests/integration/sharing.integration.test.ts create mode 100644 tests/unit/sharePreviewService.test.ts create mode 100644 tests/unit/sharedPokedexService.test.ts diff --git a/tests/bdd/features/pokedex-sharing.feature b/tests/bdd/features/pokedex-sharing.feature new file mode 100644 index 0000000..9b650bf --- /dev/null +++ b/tests/bdd/features/pokedex-sharing.feature @@ -0,0 +1,19 @@ +Feature: Share a Pokédex + As a trainer + I want to share my progress without granting edit access + So that friends can follow my collection safely + + Background: + Given I am signed in + + Scenario: Share a live read-only Pokédex + Given I have a Living Dex named "Public Journey" + When I mark the first Pokémon as caught + And I add the note "share-secret-note" to the first Pokémon + When I open the Pokédex share dialog + Then I receive an unguessable read-only link + When I visit the shared link while signed out + Then I can browse the shared Pokédex without editing it + And the shared page does not expose the private note + And the shared page advertises a social progress image + And the social progress image is a PNG diff --git a/tests/bdd/fixtures.ts b/tests/bdd/fixtures.ts index 0c23723..dc647e7 100644 --- a/tests/bdd/fixtures.ts +++ b/tests/bdd/fixtures.ts @@ -12,6 +12,7 @@ export type ScenarioState = { lastMessage: string | null; caughtEntryLabel: string | null; legacyServiceWorkerRequested: boolean; + shareUrl: string | null; }; type Fixtures = { state: ScenarioState; providerMock: void }; @@ -53,7 +54,8 @@ export const test = base.extend({ lastResponseStatus: null, lastMessage: null, caughtEntryLabel: null, - legacyServiceWorkerRequested: false + legacyServiceWorkerRequested: false, + shareUrl: null }); } }); diff --git a/tests/bdd/steps/sharing.steps.ts b/tests/bdd/steps/sharing.steps.ts new file mode 100644 index 0000000..82ea817 --- /dev/null +++ b/tests/bdd/steps/sharing.steps.ts @@ -0,0 +1,65 @@ +import { createBdd } from 'playwright-bdd'; +import { test, expect } from '../fixtures'; + +const { When, Then } = createBdd(test); + +When('I open the Pokédex share dialog', async ({ page, state }) => { + const detailsDialog = page.getByRole('dialog').filter({ hasText: 'Notes:' }); + if (await detailsDialog.count()) { + await detailsDialog.getByRole('button', { name: 'Close', exact: true }).click(); + } + await page.getByRole('button', { name: 'Share', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: /Share Public Journey/ }); + await expect(dialog).toBeVisible(); + state.shareUrl = await dialog.getByLabel('Read-only link').inputValue(); +}); + +Then('I receive an unguessable read-only link', async ({ state }) => { + expect(state.shareUrl).toMatch( + /^https?:\/\/[^/]+\/shared\/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); +}); + +When('I visit the shared link while signed out', async ({ page, context, state }) => { + if (!state.shareUrl) throw new Error('A share URL is required'); + await context.clearCookies(); + await page.goto(state.shareUrl); +}); + +Then('I can browse the shared Pokédex without editing it', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Public Journey' })).toBeVisible(); + await expect(page.getByText('Read-only shared Pokédex')).toBeVisible(); + await expect(page.getByLabel('Choose box view layout density')).toBeVisible(); + await expect(page.getByText('Filters:', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Open bulk actions menu' })).toHaveCount(0); + await expect(page.getByRole('button', { name: /Create Pokédex data/ })).toHaveCount(0); + await expect(page.getByText('Personal notes')).toHaveCount(0); +}); + +Then('the shared page does not expose the private note', async ({ page }) => { + await expect(page.getByText('share-secret-note')).toHaveCount(0); +}); + +Then('the shared page advertises a social progress image', async ({ page }) => { + const canonical = await page.locator('link[rel="canonical"]').getAttribute('href'); + const image = await page.locator('meta[property="og:image"]').getAttribute('content'); + expect(canonical).toBe(page.url()); + expect(image).toBe(`${page.url()}/preview.png`); + await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute( + 'content', + 'summary_large_image' + ); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex, nofollow'); + await expect(page.locator('meta[name="referrer"]')).toHaveAttribute('content', 'no-referrer'); +}); + +Then('the social progress image is a PNG', async ({ page }) => { + const image = await page.locator('meta[property="og:image"]').getAttribute('content'); + if (!image) throw new Error('Open Graph image URL is required'); + const response = await page.request.get(image); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('image/png'); + expect(response.headers()['cache-control']).toContain('max-age=300'); + const body = await response.body(); + expect([...body.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); +}); diff --git a/tests/integration/sharing.integration.test.ts b/tests/integration/sharing.integration.test.ts new file mode 100644 index 0000000..5afd914 --- /dev/null +++ b/tests/integration/sharing.integration.test.ts @@ -0,0 +1,138 @@ +import { createClient } from '@supabase/supabase-js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { requireLoopbackUrl } from '../support/loopback'; + +const url = requireLoopbackUrl( + process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321', + 'TEST_SUPABASE_URL' +); +const anonKey = process.env.TEST_SUPABASE_ANON_KEY; +const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY; + +describe('read-only Pokédex sharing', () => { + const createdUserIds: string[] = []; + + beforeAll(() => { + if (!anonKey || !serviceKey) { + throw new Error('Integration tests require TEST_SUPABASE_ANON_KEY and E2E_SERVICE_ROLE_KEY'); + } + }); + + afterAll(async () => { + if (!serviceKey) return; + const admin = createClient(url, serviceKey); + await Promise.all(createdUserIds.map((id) => admin.auth.admin.deleteUser(id))); + }); + + it('generates a stable token and exposes only sanitized data through the RPC', async () => { + const admin = createClient(url, serviceKey!); + const email = `integration-sharing-${Date.now()}@example.test`; + const password = 'Integration123!'; + const created = await admin.auth.admin.createUser({ email, password, email_confirm: true }); + expect(created.error).toBeNull(); + const userId = created.data.user!.id; + createdUserIds.push(userId); + + const { data: dex, error: dexError } = await admin + .from('pokedexes') + .insert({ + userId, + name: 'Shared & Safe', + description: 'Public description', + isLivingDex: true + }) + .select('id, shareToken') + .single(); + expect(dexError).toBeNull(); + expect(dex!.shareToken).toMatch(/^[0-9a-f-]{36}$/i); + + const { data: pokemon } = await admin + .from('pokemon') + .select('id') + .order('id') + .limit(1) + .single(); + expect( + ( + await admin.from('catch_records').insert({ + userId, + pokedexId: dex!.id, + pokemonId: pokemon!.id, + caught: true, + inHome: true, + personalNotes: 'This must remain private' + }) + ).error + ).toBeNull(); + + const anonymous = createClient(url, anonKey!); + const direct = await anonymous.from('pokedexes').select('*').eq('id', dex!.id); + expect(direct.error).toBeNull(); + expect(direct.data).toEqual([]); + const directCatchRecords = await anonymous + .from('catch_records') + .select('*') + .eq('pokedexId', dex!.id); + expect(directCatchRecords.error).toBeNull(); + expect(directCatchRecords.data).toEqual([]); + + const result = await anonymous.rpc('get_shared_pokedex', { + p_share_token: dex!.shareToken + }); + expect(result.error).toBeNull(); + expect(result.data).toMatchObject({ + name: 'Shared & Safe', + description: 'Public description', + catchStatuses: [ + { + pokemonId: String(pokemon!.id), + caught: true, + inHome: true + } + ] + }); + const serialized = JSON.stringify(result.data); + expect(serialized).not.toContain(userId); + expect(serialized).not.toContain(dex!.shareToken); + expect(serialized).not.toContain('This must remain private'); + expect(serialized).not.toContain('personalNotes'); + + const attemptedWrite = await anonymous + .from('catch_records') + .update({ caught: false }) + .eq('pokedexId', dex!.id) + .eq('pokemonId', pokemon!.id) + .select(); + expect(attemptedWrite.error).toBeNull(); + expect(attemptedWrite.data).toEqual([]); + const unchanged = await admin + .from('catch_records') + .select('caught') + .eq('pokedexId', dex!.id) + .eq('pokemonId', pokemon!.id) + .single(); + expect(unchanged.data?.caught).toBe(true); + + const owner = createClient(url, anonKey!); + expect((await owner.auth.signInWithPassword({ email, password })).error).toBeNull(); + const ownerDex = await owner.from('pokedexes').select('shareToken').eq('id', dex!.id).single(); + expect(ownerDex.data?.shareToken).toBe(dex!.shareToken); + expect( + (await owner.from('pokedexes').update({ shareToken: crypto.randomUUID() }).eq('id', dex!.id)) + .error + ).not.toBeNull(); + + const missing = await anonymous.rpc('get_shared_pokedex', { + p_share_token: crypto.randomUUID() + }); + expect(missing.error).toBeNull(); + expect(missing.data).toBeNull(); + + expect((await admin.from('pokedexes').delete().eq('id', dex!.id)).error).toBeNull(); + const deleted = await anonymous.rpc('get_shared_pokedex', { + p_share_token: dex!.shareToken + }); + expect(deleted.error).toBeNull(); + expect(deleted.data).toBeNull(); + }); +}); diff --git a/tests/unit/sharePreviewService.test.ts b/tests/unit/sharePreviewService.test.ts new file mode 100644 index 0000000..09d1530 --- /dev/null +++ b/tests/unit/sharePreviewService.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import sharp from 'sharp'; +import { + buildSharePreviewSvg, + escapeXml, + renderSharePreview, + SHARE_PREVIEW_HEIGHT, + SHARE_PREVIEW_WIDTH, + truncatePreviewText +} from '$lib/services/SharePreviewService'; +import type { SharedPokedexData } from '$lib/models/SharedPokedex'; + +const shared: SharedPokedexData = { + name: 'Johto & ', + description: 'A shared collection', + isLivingDex: true, + isShinyDex: false, + isOriginDex: false, + isFormDex: false, + gameScope: null, + dexScopes: [], + combinedData: [], + total: 100, + caught: 42, + completionPercentage: 42 +}; + +describe('share preview rendering', () => { + it('escapes XML and truncates normalized user text', () => { + expect(escapeXml(`Tom & Jerry's`)).toBe( + '<tag attr="x">Tom & Jerry's</tag>' + ); + expect(truncatePreviewText(' lots of\nspace ', 20)).toBe('lots of space'); + expect(truncatePreviewText('abcdefghij', 6)).toBe('abcde…'); + }); + + it('builds a branded progress card without raw user markup', () => { + const svg = buildSharePreviewSvg(shared); + expect(svg).toContain('Johto & <Friends>'); + expect(svg).not.toContain('Johto & '); + expect(svg).toContain('42 of 100 Pokémon caught'); + expect(svg).toContain('42%'); + }); + + it('renders a valid 1200 by 630 PNG', async () => { + const png = await renderSharePreview(shared); + const metadata = await sharp(png).metadata(); + expect(metadata.format).toBe('png'); + expect(metadata.width).toBe(SHARE_PREVIEW_WIDTH); + expect(metadata.height).toBe(SHARE_PREVIEW_HEIGHT); + }); +}); diff --git a/tests/unit/sharedPokedexService.test.ts b/tests/unit/sharedPokedexService.test.ts new file mode 100644 index 0000000..24c4097 --- /dev/null +++ b/tests/unit/sharedPokedexService.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { calculateSharedProgress, isShareToken } from '$lib/services/SharedPokedexService'; + +describe('shared Pokédex helpers', () => { + it('accepts UUID capability tokens and rejects malformed route values', () => { + expect(isShareToken('123e4567-e89b-42d3-a456-426614174000')).toBe(true); + expect(isShareToken('not-a-token')).toBe(false); + expect(isShareToken('123e4567-e89b-12d3-a456-426614174000/extra')).toBe(false); + }); + + it('counts caught and needs-to-evolve entries as progress', () => { + const progress = calculateSharedProgress( + [ + { + pokemonId: '1', + caught: true, + haveToEvolve: false, + inHome: false, + hasGigantamaxed: false + }, + { + pokemonId: '2', + caught: false, + haveToEvolve: true, + inHome: false, + hasGigantamaxed: false + } + ], + 4 + ); + expect(progress).toEqual({ caught: 2, completionPercentage: 50 }); + expect(calculateSharedProgress([], 0)).toEqual({ caught: 0, completionPercentage: 0 }); + }); +});