diff --git a/.github/workflows/deploy-worker-preview.yml b/.github/workflows/deploy-worker-preview.yml index 0851a20..669365d 100644 --- a/.github/workflows/deploy-worker-preview.yml +++ b/.github/workflows/deploy-worker-preview.yml @@ -76,11 +76,12 @@ jobs: exit 1 - name: Compare with Pages + if: vars.LVBT_WORKERS_PRODUCTION_ENABLED != 'true' env: PREVIEW_URL: ${{ steps.upload.outputs.url }} run: >- pnpm worker:test:live - --pages https://lasvegasfortransit.org + --pages https://lvbt-website-5zh.pages.dev --worker "$PREVIEW_URL" --skip-api diff --git a/functions/sign-out/index.ts b/functions/sign-out/index.ts index 97360b3..a2d5bdd 100644 --- a/functions/sign-out/index.ts +++ b/functions/sign-out/index.ts @@ -2,12 +2,20 @@ // /sign-out: the header's "Sign out" button posts here. It ends the session // on the server, clears both sign-in cookies and shows the signed-out page. -// GET falls through to that built page. +// GET redirects permanently to the canonical trailing-slash page. import { clearedSessionCookies, fromThisSite, signOut } from '../../platform/sign-in'; -import { redirect } from '../join/_page'; +import { redirect, SECURITY_HEADERS } from '../join/_page'; import { appendCookies, platformSignIn, refused, type SignInPagesEnv } from '../sign-in/_shared'; +export const onRequestGet: PagesFunction = ({ request, next }) => + new URL(request.url).pathname === '/sign-out' + ? new Response(null, { + status: 308, + headers: { ...SECURITY_HEADERS, Location: '/sign-out/' }, + }) + : next(); + export const onRequestPost: PagesFunction = async ({ env, request }) => { if (!fromThisSite(request)) return refused(); const platform = platformSignIn(env); diff --git a/scripts/audit/worker-live-parity.ts b/scripts/audit/worker-live-parity.ts index 4d18801..5a76627 100644 --- a/scripts/audit/worker-live-parity.ts +++ b/scripts/audit/worker-live-parity.ts @@ -87,6 +87,8 @@ export function parityCases(includeApis = true): Array<{ method?: string; pathna { pathname: '/about/' }, { pathname: '/not-a-real-page' }, { pathname: '/get-involved' }, + { pathname: '/sign-out' }, + { pathname: '/sign-out/' }, { pathname: '/sitemap.xml' }, { pathname: '/week-without-driving' }, { pathname: '/projects/social-media-just-talking' }, diff --git a/tests/sign-out-route.test.ts b/tests/sign-out-route.test.ts new file mode 100644 index 0000000..2145ed5 --- /dev/null +++ b/tests/sign-out-route.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import * as signOutRoute from '../functions/sign-out/index'; + +void test('canonicalizes the sign-out GET with a permanent redirect', async () => { + const handler: unknown = Reflect.get(signOutRoute, 'onRequestGet'); + assert.equal(typeof handler, 'function'); + + const response = await ( + handler as (context: { + request: Request; + next: () => Promise; + }) => Response | Promise + )({ + request: new Request('https://lasvegasfortransit.org/sign-out'), + next: () => Promise.resolve(new Response('page')), + }); + assert.equal(response.status, 308); + assert.equal(response.headers.get('location'), '/sign-out/'); + assert.equal( + response.headers.get('strict-transport-security'), + 'max-age=63072000; includeSubDomains; preload', + ); +}); + +void test('serves the canonical sign-out page without redirecting it to itself', async () => { + const handler: unknown = Reflect.get(signOutRoute, 'onRequestGet'); + const page = new Response('signed out'); + const response = await ( + handler as (context: { + request: Request; + next: () => Promise; + }) => Response | Promise + )({ + request: new Request('https://lasvegasfortransit.org/sign-out/'), + next: () => Promise.resolve(page), + }); + assert.equal(response, page); +}); diff --git a/tests/worker-live-parity.test.ts b/tests/worker-live-parity.test.ts index 2bd84d4..7183051 100644 --- a/tests/worker-live-parity.test.ts +++ b/tests/worker-live-parity.test.ts @@ -87,6 +87,8 @@ void test('detects the shared analytics integration in built pages', async () => void test('keeps protected API parity out of pull request previews', () => { assert.equal(parityCases().filter(({ pathname }) => pathname.startsWith('/api/')).length, 2); + assert.ok(parityCases().some(({ pathname }) => pathname === '/sign-out')); + assert.ok(parityCases().some(({ pathname }) => pathname === '/sign-out/')); assert.equal( parityCases(false).some(({ pathname }) => pathname.startsWith('/api/')), false,