Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Post-deploy production @smoke suite (#288, item 2).
#
# #287 proved the whole test suite can be green while the DEPLOYED product is
# unusable (placeholder OAuth client_ids, site_url=localhost, dead SMTP). This
# job hits the LIVE origin AFTER each deploy (and daily) and asserts the deployed
# thing a customer touches works: providers enabled, OAuth authorize redirects
# carry real client_ids (#287 detector), sign-in loads with zero console errors,
# and canonical origin + github.io redirect. (This suite caught a real prod defect
# on its first run — #348, a CSS emitted as <script> — fixed in the same PR.)
#
# All checks are READ-ONLY against prod (no signup / user creation). Like
# conformance.yml it needs no shared-backend mutex — nothing is mutated.

name: Production Smoke

on:
# Fire AFTER a successful deploy (the missing post-deploy verification).
workflow_run:
workflows: ['Deploy to GitHub Pages']
types: [completed]
# Daily drift/uptime detection.
schedule:
- cron: '30 15 * * *'
workflow_dispatch:

# NOTE: no `pull_request` trigger — this is a POST-DEPLOY monitor that tests the
# LIVE origin. A PR's changes aren't deployed yet, so running it on a PR would
# assert against the OLD deploy and red the PR for something it hasn't shipped.
# It runs after Deploy completes (where the PR's changes ARE live), on a daily
# cron, and on demand. Validate suite changes locally or via workflow_dispatch.

concurrency:
group: smoke-${{ github.ref }}
cancel-in-progress: true

jobs:
smoke:
name: Production @smoke (live origin)
# Post-deploy runs proceed only if the deploy succeeded; other triggers always run.
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 15
env:
BASE_URL: https://scripthammer.com
NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ vars.NEXT_PUBLIC_SUPABASE_ANON_KEY }}

steps:
- name: Checkout code
uses: actions/checkout@v5

- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 10.16.1

- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 20.x
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install Playwright (chromium)
run: |
for i in 1 2 3; do
pnpm exec playwright install --with-deps chromium && break
echo "Retry $i: playwright install failed, retrying in 10s..."
sleep 10
done

- name: Run the production @smoke suite
run: pnpm test:e2e:smoke
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,15 @@
"supabase:auth-config": "tsx scripts/supabase/set-auth-config.ts",
"supabase:auth-config:check": "tsx scripts/supabase/set-auth-config.ts --check",
"test:e2e:signup-mailer": "playwright test --config playwright.signup-mailer.config.ts",
"test:e2e:smoke": "playwright test --config playwright.smoke.config.ts",
"supabase:secrets": "tsx scripts/supabase/set-edge-function-secrets.ts",
"db:reset": "tsx scripts/reset-database.ts",
"generate:sitemap": "node scripts/generate-sitemap.js",
"generate:rss": "node scripts/generate-rss.js",
"sync:wireframes": "bash scripts/sync-wireframes.sh",
"sync:cesium": "bash scripts/sync-cesium.sh",
"prebuild": "node scripts/detect-project.js && node scripts/generate-author-config.js && node scripts/generate-manifest.js && node scripts/generate-sitemap.js && node scripts/generate-rss.js && bash scripts/sync-wireframes.sh && bash scripts/sync-cesium.sh",
"build": "NODE_ENV=production next build",
"build": "NODE_ENV=production next build && node scripts/strip-css-script-tags.mjs",
"start": "next start",
"lint": "eslint",
"test": "vitest",
Expand Down
5 changes: 5 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export default defineConfig({
'**/sign-up.spec.ts',
'**/basepath/**', // basePath project only (root-anchored build here)
'**/signup-mailer/**', // local-Mailpit only — playwright.signup-mailer.config.ts
'**/smoke/**', // live-prod only — playwright.smoke.config.ts
],
dependencies: ['setup'],
use: {
Expand Down Expand Up @@ -279,6 +280,7 @@ export default defineConfig({
'**/sign-up.spec.ts',
'**/basepath/**', // basePath project only (root-anchored build here)
'**/signup-mailer/**', // local-Mailpit only — playwright.signup-mailer.config.ts
'**/smoke/**', // live-prod only — playwright.smoke.config.ts
],
dependencies: ['setup'],
use: {
Expand Down Expand Up @@ -319,6 +321,7 @@ export default defineConfig({
'**/sign-up.spec.ts',
'**/basepath/**', // basePath project only (root-anchored build here)
'**/signup-mailer/**', // local-Mailpit only — playwright.signup-mailer.config.ts
'**/smoke/**', // live-prod only — playwright.smoke.config.ts
],
dependencies: ['setup'],
use: {
Expand All @@ -338,6 +341,7 @@ export default defineConfig({
'**/sign-up.spec.ts',
'**/basepath/**', // basePath project only (root-anchored build here)
'**/signup-mailer/**', // local-Mailpit only — playwright.signup-mailer.config.ts
'**/smoke/**', // live-prod only — playwright.smoke.config.ts
],
dependencies: ['setup'],
use: {
Expand All @@ -358,6 +362,7 @@ export default defineConfig({
'**/sign-up.spec.ts',
'**/basepath/**', // basePath project only (root-anchored build here)
'**/signup-mailer/**', // local-Mailpit only — playwright.signup-mailer.config.ts
'**/smoke/**', // live-prod only — playwright.smoke.config.ts
],
dependencies: ['setup'],
use: {
Expand Down
37 changes: 37 additions & 0 deletions playwright.smoke.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { defineConfig, devices } from '@playwright/test';

/**
* Dedicated Playwright config for the post-deploy production @smoke suite (#288).
*
* Runs against the LIVE deployed origin — `BASE_URL` (e.g. https://scripthammer.com)
* and `NEXT_PUBLIC_SUPABASE_URL`/`NEXT_PUBLIC_SUPABASE_ANON_KEY` (the prod project).
* It has **no `globalSetup`** (the main config's globalSetup hard-requires a
* service-role key + shared test users and would abort — and we never want that
* firing against prod) and **no `webServer`** (the site is already deployed).
* All checks are read-only. Driven by `.github/workflows/smoke.yml`.
*/
require('dotenv').config();

export default defineConfig({
testDir: './tests/e2e/smoke',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [
['list'],
process.env.CI ? ['github'] : ['line'],
['json', { outputFile: 'test-results/smoke-results.json' }],
],
use: {
baseURL: process.env.BASE_URL || 'https://scripthammer.com',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
serviceWorkers: 'block',
navigationTimeout: 60000,
actionTimeout: 15000,
contextOptions: { ignoreHTTPSErrors: true },
},
projects: [{ name: 'smoke', use: { ...devices['Desktop Chrome'] } }],
outputDir: 'test-results/',
});
60 changes: 60 additions & 0 deletions scripts/strip-css-script-tags.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env node
/**
* Post-export fix for a Next.js 15 App-Router static-export bug (#348).
*
* The exported HTML emits the SAME stylesheet twice — correctly as
* `<link rel="stylesheet" href="….css">` AND, wrongly, as
* `<script src="….css" async>`. The browser fetches the `.css` as a script and
* throws `SyntaxError: Invalid or unexpected token`, partially breaking the
* client on every page. The build's sloppy-mode `check-chunks-parse.mjs` never
* sees it (it only parses JS chunks), so it shipped to prod green — exactly the
* #287/#288 "deployed product broken while tests pass" class.
*
* Fix: strip the bogus `<script src="*.css">` tags from every exported HTML file
* (the `<link rel="stylesheet">` for the same file is left intact, so styling is
* unaffected). Wired into `pnpm build` (after `next build`) so CI, deploy, and
* local builds all get it. Idempotent; logs how many it removed so a future Next
* upgrade that fixes this upstream shows `0` and this can be retired.
*/
import { readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

const OUT = join(process.cwd(), 'out');

// A <script> whose src ends in `.css` (with its optional empty close tag).
const CSS_SCRIPT = /<script\b[^>]*\bsrc=["'][^"']*\.css["'][^>]*>\s*(?:<\/script>)?/gi;

function walkHtml(dir) {
let files = [];
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) files = files.concat(walkHtml(p));
else if (entry.endsWith('.html')) files.push(p);
}
return files;
}

let htmlFiles;
try {
htmlFiles = walkHtml(OUT);
} catch {
console.error(`strip-css-script-tags: no out/ directory at ${OUT} — skipping.`);
process.exit(0);
}

let removed = 0;
let touched = 0;
for (const file of htmlFiles) {
const html = readFileSync(file, 'utf8');
const matches = html.match(CSS_SCRIPT);
if (matches) {
writeFileSync(file, html.replace(CSS_SCRIPT, ''));
removed += matches.length;
touched++;
}
}

console.log(
`✅ strip-css-script-tags (#348): removed ${removed} <script src="*.css"> tag(s) ` +
`from ${touched}/${htmlFiles.length} HTML file(s).`
);
36 changes: 1 addition & 35 deletions tests/e2e/security/oauth-csrf.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { test, expect, Page } from '@playwright/test';
import { dismissCookieBanner } from '../utils/test-user-factory';
import { assertValidOAuthClientId } from '../utils/oauth-validity';

/**
* Clicks OAuth button and captures the OAuth authorize request URL.
Expand Down Expand Up @@ -53,41 +54,6 @@ async function clickOAuthAndCaptureRequest(
};
}

/**
* Assert an OAuth authorize URL carries a REAL, valid-shaped `client_id` — not a
* placeholder. This is the #287 detector: production shipped
* `client_id=placeholder_google_client_id` and EVERY test passed, because they
* only checked *presence* (`toBeTruthy()`) — and a literal placeholder string is
* non-empty. Presence ≠ configured. See #288 (config-validity, not presence).
*
* Provider is detected from the authorize host so the same helper works for the
* GitHub and Google flows.
*/
function assertValidOAuthClientId(oauthUrl: string): void {
expect(oauthUrl, 'OAuth authorize URL was never captured').toBeTruthy();
const url = new URL(oauthUrl);
const clientId = url.searchParams.get('client_id');
expect(clientId, 'OAuth authorize URL must carry a client_id').toBeTruthy();
// Reject the literal placeholders that shipped to prod in #287.
expect(
clientId,
`client_id "${clientId}" looks like an unconfigured placeholder (#287)`
).not.toMatch(/^(placeholder|changeme|your[-_]|example|test[-_]?client)/i);
// Valid-shaped per provider.
if (url.hostname.includes('google')) {
expect(
clientId,
'Google client_id must be a real *.apps.googleusercontent.com id'
).toMatch(/\.apps\.googleusercontent\.com$/);
} else if (url.hostname.includes('github')) {
// Classic 20-hex OAuth app id, or GitHub App client ids (Iv1.<hex> / Ov23…).
expect(
clientId,
'GitHub client_id must be a real OAuth/App client id, not a placeholder'
).toMatch(/^(Iv1\.[0-9a-f]+|Ov23[A-Za-z0-9]+|[0-9a-f]{16,})$/i);
}
}

test.describe('OAuth CSRF Protection - REQ-SEC-002', () => {
test('OAuth buttons should be visible and enabled on sign-in page', async ({
page,
Expand Down
105 changes: 105 additions & 0 deletions tests/e2e/smoke/production.smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { test, expect } from '@playwright/test';
import { assertValidOAuthClientId } from '../utils/oauth-validity';

/**
* Post-deploy production @smoke suite (#288, item 2).
*
* Runs against the LIVE deployed origin after each deploy (and daily). #287
* proved the whole test suite can be green while the *deployed product* is
* unusable — placeholder OAuth client_ids, `site_url=localhost`, dead SMTP.
* These checks assert the thing a customer actually touches works. All are
* READ-ONLY (no signup / no user creation), safe to run repeatedly.
*
* Runs only via `playwright.smoke.config.ts` (BASE_URL = the live origin +
* NEXT_PUBLIC_SUPABASE_URL/ANON_KEY = the prod project); excluded from the main
* suite's projects. Shares the #287 client_id validator with the pre-merge E2E
* (`tests/e2e/utils/oauth-validity.ts`).
*/

const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '';

test.describe('@smoke production deploy (#288)', () => {
test('the auth providers we ship are enabled (/auth/v1/settings)', async ({
request,
}) => {
expect(SUPABASE_URL, 'NEXT_PUBLIC_SUPABASE_URL must be set').toBeTruthy();
const res = await request.get(`${SUPABASE_URL}/auth/v1/settings`, {
headers: { apikey: ANON_KEY },
});
expect(res.ok(), `/auth/v1/settings → ${res.status()}`).toBeTruthy();
const { external } = (await res.json()) as {
external: Record<string, boolean>;
};
expect(external.github, 'GitHub provider must be enabled').toBe(true);
expect(external.google, 'Google provider must be enabled').toBe(true);
expect(external.email, 'Email provider must be enabled').toBe(true);
});

// The #287 detector against LIVE prod: a placeholder client_id would appear in
// the real authorize redirect. Read it from the Location header — no flaky
// navigation to the real provider.
for (const provider of ['github', 'google'] as const) {
test(`${provider} authorize redirect carries a real, non-placeholder client_id`, async ({
request,
}) => {
expect(SUPABASE_URL).toBeTruthy();
const res = await request.get(
`${SUPABASE_URL}/auth/v1/authorize?provider=${provider}`,
{ maxRedirects: 0, headers: { apikey: ANON_KEY } }
);
expect(
res.status(),
`authorize should redirect to the provider, got ${res.status()}`
).toBeGreaterThanOrEqual(300);
const location = res.headers()['location'] ?? '';
expect(location, 'authorize must issue a Location redirect').toBeTruthy();
assertValidOAuthClientId(location);
});
}

test('the sign-in page loads with no console errors', async ({ page }) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', (err) => errors.push(`pageerror: ${err.message}`));

await page.goto('/sign-in');
// The real app rendered (OAuth buttons are part of the sign-in page).
await expect(
page.getByRole('button', { name: /continue with github/i })
).toBeVisible();

// Benign third-party noise (analytics, Cloudflare bot cookie, favicon).
const IGNORE =
/favicon|analytics|gtag|googletagmanager|chrome-extension|__cf_bm|cf_bm|cloudflare/i;
const relevant = errors.filter((e) => !IGNORE.test(e));
expect(
relevant,
`console errors on /sign-in:\n${relevant.join('\n')}`
).toEqual([]);
});

test('canonical origin serves the app + github.io redirects to it', async ({
request,
}) => {
// GitHub Pages guarantee: the project github.io URL 301s to the custom domain.
const gh = await request.get(
'https://tortoisewolfe.github.io/ScriptHammer/',
{ maxRedirects: 0 }
);
expect(
gh.status(),
`github.io should redirect, got ${gh.status()}`
).toBeGreaterThanOrEqual(300);
expect(
gh.headers()['location'] ?? '',
'github.io must redirect to the scripthammer.com custom domain'
).toContain('scripthammer.com');

// The canonical origin (BASE_URL) serves the real app (follow redirects).
const home = await request.get('/sign-in');
expect(home.ok(), `BASE_URL/sign-in → ${home.status()}`).toBeTruthy();
});
});
Loading
Loading