fix: harden review findings and Netlify install

This commit is contained in:
Josh Creek
2026-09-14 12:56:09 +01:00
parent 86f1c21e4d
commit 3ea194f87c
18 changed files with 171 additions and 7759 deletions
+3 -3
View File
@@ -9,9 +9,9 @@ DROPBOX_OAUTH_CLIENT_ID="your-dropbox-client-id"
DROPBOX_OAUTH_CLIENT_SECRET="your-dropbox-client-secret" DROPBOX_OAUTH_CLIENT_SECRET="your-dropbox-client-secret"
# Endpoint overrides for deterministic local provider tests. They are ignored unless # Endpoint overrides for deterministic local provider tests. They are ignored unless
# ALLOW_PROVIDER_ENDPOINT_OVERRIDES is exactly "true", and only loopback URLs are accepted - # ALLOW_PROVIDER_ENDPOINT_OVERRIDES is exactly "true", the local BDD stack variables are present,
# these endpoints receive the OAuth client secret and refresh token, so never set this in a # and every URL is loopback. These endpoints receive OAuth secrets, so never set this in a deployed
# deployed environment. `npm run test:bdd` sets it for you. # environment. `npm run test:bdd` supplies the complete test context.
ALLOW_PROVIDER_ENDPOINT_OVERRIDES="" ALLOW_PROVIDER_ENDPOINT_OVERRIDES=""
GOOGLE_OAUTH_AUTHORIZE_URL="" GOOGLE_OAUTH_AUTHORIZE_URL=""
GOOGLE_OAUTH_TOKEN_URL="" GOOGLE_OAUTH_TOKEN_URL=""
+4 -3
View File
@@ -75,9 +75,10 @@ reviewed with product stakeholders, but does not skip it. Missing or ambiguous s
Google Drive and Dropbox scenarios use a local provider server (`scripts/mock-provider-server.mjs`) Google Drive and Dropbox scenarios use a local provider server (`scripts/mock-provider-server.mjs`)
and never contact real provider accounts. The endpoint overrides that point at it are refused unless and never contact real provider accounts. The endpoint overrides that point at it are refused unless
`ALLOW_PROVIDER_ENDPOINT_OVERRIDES=true` **and** the override is a loopback URL - these endpoints `ALLOW_PROVIDER_ENDPOINT_OVERRIDES=true`, the local test-stack/service-role variables are present,
receive the OAuth client secret and refresh token, so they must not be redirectable in a deployed and the override is a loopback URL. These endpoints receive the OAuth client secret and refresh
environment. `npm run test:bdd` sets the flag; nothing else should. token, so they must not be redirectable in a deployed environment. `npm run test:bdd` supplies the
complete test context.
The mock's recorded requests, refresh counter and fail-uploads switch are reset before every scenario The mock's recorded requests, refresh counter and fail-uploads switch are reset before every scenario
by an auto fixture in `tests/bdd/fixtures.ts`. That reset is also why the suite runs with a single by an auto fixture in `tests/bdd/fixtures.ts`. That reset is also why the suite runs with a single
-7717
View File
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -23,12 +23,10 @@ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
/** /**
* These endpoints receive the OAuth client secret and the user's refresh token, so an override * These endpoints receive the OAuth client secret and the user's refresh token, so an override
* is only ever a local test seam - never a deployment knob. Two guards, because the env is * is only ever a local test seam - never a deployment knob. The guards require the explicit
* read at runtime (`$env/dynamic/private`) and a single injected variable would otherwise be * override flag, the BDD service-role context, and a loopback test stack. Each override must also
* enough to redirect those credentials to an arbitrary host: * be loopback, so a single injected variable cannot redirect credentials to an arbitrary host.
* * Values are read at runtime from `$env/dynamic/private`.
* 1. overrides are ignored unless ALLOW_PROVIDER_ENDPOINT_OVERRIDES is exactly "true", and
* 2. even then, only loopback URLs are accepted.
*/ */
function isLocalOverride(value: string): boolean { function isLocalOverride(value: string): boolean {
try { try {
@@ -44,7 +42,11 @@ function isLocalOverride(value: string): boolean {
export function resolveProviderEndpoints( export function resolveProviderEndpoints(
env: Record<string, string | undefined> env: Record<string, string | undefined>
): ProviderEndpoints { ): ProviderEndpoints {
const overridesAllowed = env.ALLOW_PROVIDER_ENDPOINT_OVERRIDES === 'true'; const overridesAllowed =
env.ALLOW_PROVIDER_ENDPOINT_OVERRIDES === 'true' &&
!!env.E2E_SERVICE_ROLE_KEY &&
!!env.TEST_SUPABASE_URL &&
isLocalOverride(env.TEST_SUPABASE_URL);
const pick = (override: string | undefined, fallback: string) => const pick = (override: string | undefined, fallback: string) =>
overridesAllowed && override && isLocalOverride(override) ? override : fallback; overridesAllowed && override && isLocalOverride(override) ? override : fallback;
+1
View File
@@ -122,6 +122,7 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
) )
) )
); );
if (getUserId() !== userId) throw new Error('Session changed during offline synchronization');
const result = await workerMessage({ const result = await workerMessage({
type: 'SYNC_OFFLINE_SNAPSHOT', type: 'SYNC_OFFLINE_SNAPSHOT',
snapshot, snapshot,
+31 -7
View File
@@ -5,15 +5,35 @@ import type { CookieSerializeOptions } from 'cookie';
export const load: LayoutLoad = async ({ fetch, data, depends }) => { export const load: LayoutLoad = async ({ fetch, data, depends }) => {
depends('supabase:auth'); depends('supabase:auth');
const recoveryIntent = let recoveryExchangeSucceeded = false;
isBrowser() && let hashRecoveryCallback = false;
window.location.pathname === '/reset-password' && let codeRecoveryCallback = false;
(new URLSearchParams(window.location.hash.slice(1)).get('type') === 'recovery' || if (isBrowser() && window.location.pathname === '/reset-password') {
new URL(window.location.href).searchParams.has('code')); const hash = new URLSearchParams(window.location.hash.slice(1));
hashRecoveryCallback =
hash.get('type') === 'recovery' && hash.has('access_token') && hash.has('refresh_token');
codeRecoveryCallback = new URL(window.location.href).searchParams.has('code');
}
const authFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
if (codeRecoveryCallback && response.ok) {
const requestUrl = new URL(
typeof input === 'string' || input instanceof URL ? input : input.url,
window.location.origin
);
if (
requestUrl.pathname.endsWith('/auth/v1/token') &&
requestUrl.searchParams.get('grant_type') === 'pkce'
) {
recoveryExchangeSucceeded = true;
}
}
return response;
};
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: { global: {
fetch fetch: authFetch
}, },
cookies: { cookies: {
get(key: string) { get(key: string) {
@@ -44,5 +64,9 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
data: { session } data: { session }
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
return { supabase, session, recoveryIntent }; return {
supabase,
session,
recoveryIntent: !!session && (hashRecoveryCallback || recoveryExchangeSucceeded)
};
}; };
+15 -4
View File
@@ -108,10 +108,7 @@
successMessage = 'Password updated successfully! Redirecting to sign in...'; successMessage = 'Password updated successfully! Redirecting to sign in...';
sessionStorage.removeItem(recoveryMarkerKey); sessionStorage.removeItem(recoveryMarkerKey);
setTimeout(async () => { setTimeout(() => void finishPasswordReset(), 1_000);
await supabase.auth.signOut();
await goto('/signin');
}, 1_000);
} catch (err) { } catch (err) {
console.error('Update password error:', err); console.error('Update password error:', err);
errorMessage = 'An unexpected error occurred. Please try again.'; errorMessage = 'An unexpected error occurred. Please try again.';
@@ -120,6 +117,20 @@
} }
} }
async function finishPasswordReset() {
try {
const { error } = await supabase.auth.signOut();
if (error) {
errorMessage = `Password updated, but sign out failed: ${error.message}`;
return;
}
await goto('/signin');
} catch (error) {
console.error('Sign out after password reset failed:', error);
errorMessage = 'Password updated, but sign out failed. Please try again.';
}
}
function handleKeyPress(event: KeyboardEvent) { function handleKeyPress(event: KeyboardEvent) {
if (event.key === 'Enter') { if (event.key === 'Enter') {
updatePassword(); updatePassword();
+11 -1
View File
@@ -3,6 +3,7 @@ const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
const OFFLINE_META_URL = '/__offline/current'; const OFFLINE_META_URL = '/__offline/current';
let offlineEpoch = 0; let offlineEpoch = 0;
let offlineOperation = Promise.resolve(); let offlineOperation = Promise.resolve();
let claimedUserId = null;
function queueOfflineOperation(operation) { function queueOfflineOperation(operation) {
const result = offlineOperation.then(operation, operation); const result = offlineOperation.then(operation, operation);
@@ -63,6 +64,7 @@ async function cacheArtwork(cache, urls) {
self.addEventListener('message', (event) => { self.addEventListener('message', (event) => {
const reply = (value) => event.ports[0]?.postMessage(value); const reply = (value) => event.ports[0]?.postMessage(value);
if (event.data?.type === 'CLEAR_OFFLINE_DATA') { if (event.data?.type === 'CLEAR_OFFLINE_DATA') {
claimedUserId = null;
offlineEpoch++; offlineEpoch++;
event.waitUntil( event.waitUntil(
queueOfflineOperation(async () => { queueOfflineOperation(async () => {
@@ -75,10 +77,14 @@ self.addEventListener('message', (event) => {
return; return;
} }
if (event.data?.type === 'CLAIM_OFFLINE_USER') { if (event.data?.type === 'CLAIM_OFFLINE_USER') {
if (typeof event.data.userId !== 'string') {
reply({ ok: false, error: 'Invalid offline cache owner' });
return;
}
claimedUserId = event.data.userId;
offlineEpoch++; offlineEpoch++;
event.waitUntil( event.waitUntil(
queueOfflineOperation(async () => { queueOfflineOperation(async () => {
if (typeof event.data.userId !== 'string') throw new Error('Invalid offline cache owner');
const meta = await currentOfflineMeta(); const meta = await currentOfflineMeta();
if (meta?.userId && meta.userId !== event.data.userId) { if (meta?.userId && meta.userId !== event.data.userId) {
await clearOfflineData(); await clearOfflineData();
@@ -91,6 +97,7 @@ self.addEventListener('message', (event) => {
} }
if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return; if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return;
const syncEpoch = offlineEpoch; const syncEpoch = offlineEpoch;
const syncUserId = claimedUserId;
event.waitUntil( event.waitUntil(
queueOfflineOperation(async () => { queueOfflineOperation(async () => {
@@ -101,6 +108,9 @@ self.addEventListener('message', (event) => {
if (!snapshot || snapshot.version !== 1 || typeof snapshot.userId !== 'string') { if (!snapshot || snapshot.version !== 1 || typeof snapshot.userId !== 'string') {
throw new Error('Unsupported offline snapshot'); throw new Error('Unsupported offline snapshot');
} }
if (!syncUserId || snapshot.userId !== syncUserId) {
throw new Error('Offline snapshot owner did not match the claimed account');
}
const previousMeta = await currentOfflineMeta(); const previousMeta = await currentOfflineMeta();
if (previousMeta?.userId && previousMeta.userId !== snapshot.userId) if (previousMeta?.userId && previousMeta.userId !== snapshot.userId)
await clearOfflineData(); await clearOfflineData();
+6 -1
View File
@@ -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 * 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 * `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. * 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$/; const OWNED_EMAIL = /^(bdd|other|integration)-.*@example\.test$/;
type AdminUser = { id: string; email?: string }; type AdminUser = { id: string; email?: string };
+1 -1
View File
@@ -104,7 +104,7 @@ When('I request a password reset', async ({ page, state }) => {
}); });
When('I visit the password recovery page directly', async ({ page }) => { 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 }) => { When('I enter two different replacement passwords', async ({ page, state }) => {
+5 -1
View File
@@ -1,10 +1,14 @@
import { createBdd } from 'playwright-bdd'; import { createBdd } from 'playwright-bdd';
import { test, expect } from '../fixtures'; import { test, expect } from '../fixtures';
import { createDexThroughUi, firstPokemon, openFirstPokemon } from '../support/app'; import { createDexThroughUi, firstPokemon, openFirstPokemon } from '../support/app';
import { requireLoopbackUrl } from '../../support/loopback';
const { Given, When, Then } = createBdd(test); const { Given, When, Then } = createBdd(test);
const MOCK_URL = process.env.MOCK_PROVIDER_URL ?? 'http://127.0.0.1:4199'; 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'; type Provider = 'google_drive' | 'dropbox';
+5 -1
View File
@@ -1,6 +1,7 @@
import { createBdd } from 'playwright-bdd'; import { createBdd } from 'playwright-bdd';
import { test, expect } from '../fixtures'; import { test, expect } from '../fixtures';
import { createDexThroughUi, deleteAllPokedexes } from '../support/app'; import { createDexThroughUi, deleteAllPokedexes } from '../support/app';
import { requireLoopbackUrl } from '../../support/loopback';
const { Given, When, Then } = createBdd(test); 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 }) => { 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; const key = process.env.E2E_SERVICE_ROLE_KEY;
if (!key) throw new Error('E2E_SERVICE_ROLE_KEY is required'); if (!key) throw new Error('E2E_SERVICE_ROLE_KEY is required');
const headers = { const headers = {
+26 -8
View File
@@ -8,6 +8,20 @@ async function ensurePokemonModal(page: Parameters<typeof firstPokemon>[0]) {
if ((await page.getByRole('dialog').count()) === 0) await openFirstPokemon(page); 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]) { async function settleAndReload(page: Parameters<typeof firstPokemon>[0]) {
await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 }); await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 });
await page.reload({ waitUntil: 'networkidle' }); await page.reload({ waitUntil: 'networkidle' });
@@ -21,7 +35,7 @@ When('I mark the first Pokémon as caught', async ({ page }) => {
.getByText('Caught:', { exact: true }) .getByText('Caught:', { exact: true })
.locator('..') .locator('..')
.getByRole('checkbox'); .getByRole('checkbox');
await checkbox.check(); await persistCatchChange(page, () => checkbox.check());
}); });
When('I mark the first Pokémon as needing evolution', async ({ page }) => { 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 }) .getByText('Needs to evolve:', { exact: true })
.locator('..') .locator('..')
.getByRole('checkbox'); .getByRole('checkbox');
await checkbox.check(); await persistCatchChange(page, () => checkbox.check());
}); });
When('I mark the first Pokémon as in HOME', async ({ page }) => { When('I mark the first Pokémon as in HOME', async ({ page }) => {
await ensurePokemonModal(page); await ensurePokemonModal(page);
await page const checkbox = page
.getByRole('dialog') .getByRole('dialog')
.getByText('In Home:', { exact: true }) .getByText('In Home:', { exact: true })
.locator('..') .locator('..')
.getByRole('checkbox') .getByRole('checkbox');
.check(); await persistCatchChange(page, () => checkbox.check());
}); });
When('I add the note {string} to the first Pokémon', async ({ page }, note: string) => { When('I add the note {string} to the first Pokémon', async ({ page }, note: string) => {
await ensurePokemonModal(page); await ensurePokemonModal(page);
await page.getByRole('dialog').getByLabel('Notes:').fill(note); await persistCatchChange(page, async () => {
await page.getByRole('dialog').getByLabel('Notes:').blur(); await page.getByRole('dialog').getByLabel('Notes:').fill(note);
await page.getByRole('dialog').getByLabel('Notes:').blur();
});
}); });
function boxContainer(page: Parameters<typeof firstPokemon>[0], box: number) { 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) => { When('I mark box {int} as caught', async ({ page }, box: number) => {
const container = boxContainer(page, box); const container = boxContainer(page, box);
await container.getByRole('button', { name: 'Open bulk actions menu' }).click(); 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 }) => { When('I filter to Pokémon that are not caught', async ({ page, state }) => {
+10 -3
View File
@@ -40,6 +40,10 @@ When('I open the built application', async ({ page, state }) => {
recordLegacyWorkerRequest(page, state); recordLegacyWorkerRequest(page, state);
await page.goto('/'); await page.goto('/');
await waitForServiceWorker(page); 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 }) => { 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 ( const metaResponse = await (
await caches.open('livingdex-offline-meta-v1') await caches.open('livingdex-offline-meta-v1')
).match('/__offline/current'); ).match('/__offline/current');
if (!metaResponse) return ''; if (!metaResponse) throw new Error('Offline snapshot metadata was not cached');
const meta = await metaResponse.json(); const meta = await metaResponse.json();
const snapshotResponse = await ( const snapshotResponse = await (
await caches.open(meta.dataCache) await caches.open(meta.dataCache)
).match(`/__offline/snapshot/${encodeURIComponent(meta.userId)}`); ).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); 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 }) => { 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( expect(
await page.evaluate(() => await page.evaluate(() =>
navigator.serviceWorker.getRegistrations().then((items) => items.length) navigator.serviceWorker.getRegistrations().then((items) => items.length)
+5 -1
View File
@@ -1,7 +1,11 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import type { ScenarioState } from '../fixtures'; 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; const SERVICE_ROLE_KEY = process.env.E2E_SERVICE_ROLE_KEY;
function requireServiceRoleKey(): string { function requireServiceRoleKey(): string {
@@ -1,7 +1,11 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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 anonKey = process.env.TEST_SUPABASE_ANON_KEY;
const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY; const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
+15
View File
@@ -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;
}
+19
View File
@@ -5,6 +5,8 @@ import {
} from '$lib/services/providerEndpoints'; } from '$lib/services/providerEndpoints';
const localOverrides = { 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_AUTHORIZE_URL: 'http://127.0.0.1:4199/google/authorize',
GOOGLE_OAUTH_TOKEN_URL: 'http://127.0.0.1:4199/google/token', GOOGLE_OAUTH_TOKEN_URL: 'http://127.0.0.1:4199/google/token',
GOOGLE_DRIVE_API_URL: 'http://127.0.0.1:4199/google/drive', 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([ it.each([
'https://attacker.example/token', 'https://attacker.example/token',
'http://127.0.0.1.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) => { ])('refuses the non-loopback override %j even when overrides are allowed', (value) => {
const endpoints = resolveProviderEndpoints({ const endpoints = resolveProviderEndpoints({
TEST_SUPABASE_URL: localOverrides.TEST_SUPABASE_URL,
E2E_SERVICE_ROLE_KEY: localOverrides.E2E_SERVICE_ROLE_KEY,
ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true', ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true',
GOOGLE_OAUTH_TOKEN_URL: value, GOOGLE_OAUTH_TOKEN_URL: value,
DROPBOX_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', () => { it('accepts localhost as well as 127.0.0.1', () => {
expect( expect(
resolveProviderEndpoints({ resolveProviderEndpoints({
TEST_SUPABASE_URL: localOverrides.TEST_SUPABASE_URL,
E2E_SERVICE_ROLE_KEY: localOverrides.E2E_SERVICE_ROLE_KEY,
ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true', ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true',
GOOGLE_OAUTH_TOKEN_URL: 'http://localhost:4199/google/token' GOOGLE_OAUTH_TOKEN_URL: 'http://localhost:4199/google/token'
}).google.token }).google.token