mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-14 17:42:17 +00:00
fix: harden review findings and Netlify install
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
import { requireLoopbackUrl } from '../support/loopback';
|
||||
|
||||
/**
|
||||
* Deletes the users each BDD run provisions, so repeated local runs do not need a full
|
||||
* `supabase db reset` to stay clean. Pokédexes, catch records and integrations follow via the
|
||||
* schema's cascades. Only the suite's own synthetic addresses are touched.
|
||||
*/
|
||||
const SUPABASE_URL = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
||||
const SUPABASE_URL = requireLoopbackUrl(
|
||||
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||
'TEST_SUPABASE_URL'
|
||||
);
|
||||
const OWNED_EMAIL = /^(bdd|other|integration)-.*@example\.test$/;
|
||||
|
||||
type AdminUser = { id: string; email?: string };
|
||||
|
||||
@@ -104,7 +104,7 @@ When('I request a password reset', async ({ page, state }) => {
|
||||
});
|
||||
|
||||
When('I visit the password recovery page directly', async ({ page }) => {
|
||||
await page.goto('/reset-password');
|
||||
await page.goto('/reset-password?code=arbitrary-code');
|
||||
});
|
||||
|
||||
When('I enter two different replacement passwords', async ({ page, state }) => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { createBdd } from 'playwright-bdd';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { createDexThroughUi, firstPokemon, openFirstPokemon } from '../support/app';
|
||||
import { requireLoopbackUrl } from '../../support/loopback';
|
||||
|
||||
const { Given, When, Then } = createBdd(test);
|
||||
const MOCK_URL = process.env.MOCK_PROVIDER_URL ?? 'http://127.0.0.1:4199';
|
||||
const SUPABASE_URL = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
||||
const SUPABASE_URL = requireLoopbackUrl(
|
||||
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||
'TEST_SUPABASE_URL'
|
||||
);
|
||||
|
||||
type Provider = 'google_drive' | 'dropbox';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createBdd } from 'playwright-bdd';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { createDexThroughUi, deleteAllPokedexes } from '../support/app';
|
||||
import { requireLoopbackUrl } from '../../support/loopback';
|
||||
|
||||
const { Given, When, Then } = createBdd(test);
|
||||
|
||||
@@ -37,7 +38,10 @@ Given('I have a Shiny Dex named {string}', async ({ page, state }, name: string)
|
||||
});
|
||||
|
||||
Given('another trainer has a Pokédex', async ({ state }) => {
|
||||
const url = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
||||
const url = requireLoopbackUrl(
|
||||
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||
'TEST_SUPABASE_URL'
|
||||
);
|
||||
const key = process.env.E2E_SERVICE_ROLE_KEY;
|
||||
if (!key) throw new Error('E2E_SERVICE_ROLE_KEY is required');
|
||||
const headers = {
|
||||
|
||||
@@ -8,6 +8,20 @@ async function ensurePokemonModal(page: Parameters<typeof firstPokemon>[0]) {
|
||||
if ((await page.getByRole('dialog').count()) === 0) await openFirstPokemon(page);
|
||||
}
|
||||
|
||||
async function persistCatchChange(
|
||||
page: Parameters<typeof firstPokemon>[0],
|
||||
action: () => Promise<void>
|
||||
) {
|
||||
const persistence = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === 'POST' &&
|
||||
new URL(response.url()).pathname.endsWith('/catch-records')
|
||||
);
|
||||
await action();
|
||||
const response = await persistence;
|
||||
expect(response.ok(), `catch-record persistence failed: ${response.status()}`).toBe(true);
|
||||
}
|
||||
|
||||
async function settleAndReload(page: Parameters<typeof firstPokemon>[0]) {
|
||||
await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 });
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
@@ -21,7 +35,7 @@ When('I mark the first Pokémon as caught', async ({ page }) => {
|
||||
.getByText('Caught:', { exact: true })
|
||||
.locator('..')
|
||||
.getByRole('checkbox');
|
||||
await checkbox.check();
|
||||
await persistCatchChange(page, () => checkbox.check());
|
||||
});
|
||||
|
||||
When('I mark the first Pokémon as needing evolution', async ({ page }) => {
|
||||
@@ -31,23 +45,25 @@ When('I mark the first Pokémon as needing evolution', async ({ page }) => {
|
||||
.getByText('Needs to evolve:', { exact: true })
|
||||
.locator('..')
|
||||
.getByRole('checkbox');
|
||||
await checkbox.check();
|
||||
await persistCatchChange(page, () => checkbox.check());
|
||||
});
|
||||
|
||||
When('I mark the first Pokémon as in HOME', async ({ page }) => {
|
||||
await ensurePokemonModal(page);
|
||||
await page
|
||||
const checkbox = page
|
||||
.getByRole('dialog')
|
||||
.getByText('In Home:', { exact: true })
|
||||
.locator('..')
|
||||
.getByRole('checkbox')
|
||||
.check();
|
||||
.getByRole('checkbox');
|
||||
await persistCatchChange(page, () => checkbox.check());
|
||||
});
|
||||
|
||||
When('I add the note {string} to the first Pokémon', async ({ page }, note: string) => {
|
||||
await ensurePokemonModal(page);
|
||||
await page.getByRole('dialog').getByLabel('Notes:').fill(note);
|
||||
await page.getByRole('dialog').getByLabel('Notes:').blur();
|
||||
await persistCatchChange(page, async () => {
|
||||
await page.getByRole('dialog').getByLabel('Notes:').fill(note);
|
||||
await page.getByRole('dialog').getByLabel('Notes:').blur();
|
||||
});
|
||||
});
|
||||
|
||||
function boxContainer(page: Parameters<typeof firstPokemon>[0], box: number) {
|
||||
@@ -60,7 +76,9 @@ function boxContainer(page: Parameters<typeof firstPokemon>[0], box: number) {
|
||||
When('I mark box {int} as caught', async ({ page }, box: number) => {
|
||||
const container = boxContainer(page, box);
|
||||
await container.getByRole('button', { name: 'Open bulk actions menu' }).click();
|
||||
await container.getByRole('button', { name: 'Mark box as Caught' }).click();
|
||||
await persistCatchChange(page, () =>
|
||||
container.getByRole('button', { name: 'Mark box as Caught' }).click()
|
||||
);
|
||||
});
|
||||
|
||||
When('I filter to Pokémon that are not caught', async ({ page, state }) => {
|
||||
|
||||
@@ -40,6 +40,10 @@ When('I open the built application', async ({ page, state }) => {
|
||||
recordLegacyWorkerRequest(page, state);
|
||||
await page.goto('/');
|
||||
await waitForServiceWorker(page);
|
||||
if (!(await page.evaluate(() => !!navigator.serviceWorker.controller))) {
|
||||
await page.reload();
|
||||
await waitForServiceWorker(page);
|
||||
}
|
||||
});
|
||||
|
||||
Given('I have opened the built application online', async ({ page, state }) => {
|
||||
@@ -79,12 +83,13 @@ Given('my offline copy is synchronized', async ({ page, state }) => {
|
||||
const metaResponse = await (
|
||||
await caches.open('livingdex-offline-meta-v1')
|
||||
).match('/__offline/current');
|
||||
if (!metaResponse) return '';
|
||||
if (!metaResponse) throw new Error('Offline snapshot metadata was not cached');
|
||||
const meta = await metaResponse.json();
|
||||
const snapshotResponse = await (
|
||||
await caches.open(meta.dataCache)
|
||||
).match(`/__offline/snapshot/${encodeURIComponent(meta.userId)}`);
|
||||
return snapshotResponse ? await snapshotResponse.text() : '';
|
||||
if (!snapshotResponse) throw new Error('Offline snapshot payload was not cached');
|
||||
return snapshotResponse.text();
|
||||
});
|
||||
expect(serializedSnapshot).not.toMatch(/access_token|refresh_token/i);
|
||||
});
|
||||
@@ -104,7 +109,9 @@ When('I go offline and then return online', async ({ page }) => {
|
||||
});
|
||||
|
||||
Then('a service worker controls the page', async ({ page }) => {
|
||||
expect(await waitForServiceWorker(page)).toMatch(/\/(?:sw|prompt-sw)\.js$/);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.serviceWorker.controller?.scriptURL ?? null))
|
||||
.toMatch(/\/(?:sw|prompt-sw)\.js$/);
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
navigator.serviceWorker.getRegistrations().then((items) => items.length)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import type { ScenarioState } from '../fixtures';
|
||||
import { requireLoopbackUrl } from '../../support/loopback';
|
||||
|
||||
const SUPABASE_URL = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
||||
const SUPABASE_URL = requireLoopbackUrl(
|
||||
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||
'TEST_SUPABASE_URL'
|
||||
);
|
||||
const SERVICE_ROLE_KEY = process.env.E2E_SERVICE_ROLE_KEY;
|
||||
|
||||
function requireServiceRoleKey(): string {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { requireLoopbackUrl } from '../support/loopback';
|
||||
|
||||
const url = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
||||
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;
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
||||
|
||||
export function isLoopbackUrl(value: string): boolean {
|
||||
try {
|
||||
return LOOPBACK_HOSTS.has(new URL(value).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function requireLoopbackUrl(value: string, label: string): string {
|
||||
if (!isLoopbackUrl(value))
|
||||
throw new Error(`Refusing to send credentials to non-loopback ${label}`);
|
||||
return value;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from '$lib/services/providerEndpoints';
|
||||
|
||||
const localOverrides = {
|
||||
TEST_SUPABASE_URL: 'http://127.0.0.1:54321',
|
||||
E2E_SERVICE_ROLE_KEY: 'local-test-service-key',
|
||||
GOOGLE_OAUTH_AUTHORIZE_URL: 'http://127.0.0.1:4199/google/authorize',
|
||||
GOOGLE_OAUTH_TOKEN_URL: 'http://127.0.0.1:4199/google/token',
|
||||
GOOGLE_DRIVE_API_URL: 'http://127.0.0.1:4199/google/drive',
|
||||
@@ -49,6 +51,19 @@ describe('provider endpoints', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses HTTP overrides without a verified local test stack', () => {
|
||||
const { TEST_SUPABASE_URL, E2E_SERVICE_ROLE_KEY, ...deployedOverrides } = localOverrides;
|
||||
expect(TEST_SUPABASE_URL).toBeTruthy();
|
||||
expect(E2E_SERVICE_ROLE_KEY).toBeTruthy();
|
||||
expect(
|
||||
resolveProviderEndpoints({
|
||||
...deployedOverrides,
|
||||
NODE_ENV: 'production',
|
||||
ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true'
|
||||
})
|
||||
).toEqual(PROVIDER_ENDPOINT_DEFAULTS);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://attacker.example/token',
|
||||
'http://127.0.0.1.attacker.example/token',
|
||||
@@ -58,6 +73,8 @@ describe('provider endpoints', () => {
|
||||
''
|
||||
])('refuses the non-loopback override %j even when overrides are allowed', (value) => {
|
||||
const endpoints = resolveProviderEndpoints({
|
||||
TEST_SUPABASE_URL: localOverrides.TEST_SUPABASE_URL,
|
||||
E2E_SERVICE_ROLE_KEY: localOverrides.E2E_SERVICE_ROLE_KEY,
|
||||
ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true',
|
||||
GOOGLE_OAUTH_TOKEN_URL: value,
|
||||
DROPBOX_OAUTH_TOKEN_URL: value
|
||||
@@ -69,6 +86,8 @@ describe('provider endpoints', () => {
|
||||
it('accepts localhost as well as 127.0.0.1', () => {
|
||||
expect(
|
||||
resolveProviderEndpoints({
|
||||
TEST_SUPABASE_URL: localOverrides.TEST_SUPABASE_URL,
|
||||
E2E_SERVICE_ROLE_KEY: localOverrides.E2E_SERVICE_ROLE_KEY,
|
||||
ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true',
|
||||
GOOGLE_OAUTH_TOKEN_URL: 'http://localhost:4199/google/token'
|
||||
}).google.token
|
||||
|
||||
Reference in New Issue
Block a user