test(pokedex): cover shared dex privacy

This commit is contained in:
Josh Creek
2026-09-14 14:25:22 +01:00
parent 7240376e00
commit 27274171fe
6 changed files with 311 additions and 1 deletions
@@ -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
+3 -1
View File
@@ -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<Fixtures>({
lastResponseStatus: null,
lastMessage: null,
caughtEntryLabel: null,
legacyServiceWorkerRequested: false
legacyServiceWorkerRequested: false,
shareUrl: null
});
}
});
+65
View File
@@ -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]);
});
@@ -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();
});
});
+52
View File
@@ -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 & <Friends>',
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(`<tag attr="x">Tom & Jerry's</tag>`)).toBe(
'&lt;tag attr=&quot;x&quot;&gt;Tom &amp; Jerry&apos;s&lt;/tag&gt;'
);
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 &amp; &lt;Friends&gt;');
expect(svg).not.toContain('Johto & <Friends>');
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);
});
});
+34
View File
@@ -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 });
});
});