review fixes: seeded-auth hard gate, ci.yml build serialization, assets 404
ci/woodpecker/pr/ci Pipeline failed

- M1: E2E_REQUIRE_SEEDED_AUTH=1 in the CI e2e step makes login failures hard
  failures (loginAs throws, guards disabled, globalSetup refuses a pre-populated
  DB); auth.spec redirect test asserts outright under the flag; non-admin
  /admin test is now a real authorization assertion
- M2: ci.yml build step depends_on test — never two concurrent turbo builds
  on the shared workspace
- M3: unknown /assets/* paths 404 from the SPA catch-all instead of serving
  index.html with an immutable cache header; spec arm added
- minors: e2e step gets when: *image_build_when, health poll uses
  GATEWAY_PORT + AbortSignal.timeout, BETTER_AUTH_SECRET generated per run
  (no literal in tree), failure echoes artifact path, dev-guide documents the
  gate, stale verify-release comment fixed
This commit is contained in:
fred
2026-08-27 09:52:28 -05:00
parent 311b4dda59
commit 54adbd2b3d
14 changed files with 142 additions and 54 deletions
+15 -1
View File
@@ -11,7 +11,8 @@
* 3. Unknown backend paths (/api, /mcp, /socket.io) are JSON 404s, never the
* SPA page — including with a query string (`/api?x=1`).
* 4. Static files are served exactly; hashed /assets/ files get immutable
* cache headers, everything else revalidates (max-age=0).
* cache headers, everything else revalidates (max-age=0), and a missing
* /assets/ file is a 404 — never the SPA fallback.
* 5. WEB_DIST_DIR unset disables SPA serving entirely.
* 6. WEB_DIST_DIR pointing at a directory without index.html fails at boot.
*/
@@ -130,6 +131,19 @@ describe('SPA static serving — fixture dist dir', () => {
expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable');
});
it('missing /assets/ files are 404s, never the SPA page with an immutable header', async () => {
// The exact request a browser with a stale index.html makes after a
// deploy: the old hashed filename. Serving index.html here would poison
// caches with a year-long immutable entry whose body is HTML.
for (const missingAsset of ['/assets/app-old999.js', '/assets/app-old999.js?v=1']) {
const res = await request(app.getHttpServer()).get(missingAsset);
expect(res.status, missingAsset).toBe(404);
expect(res.text, missingAsset).not.toContain('mosaic spa fixture');
// The 404 carries no cache-control at all; ?? '' keeps the assertion valid.
expect(res.headers['cache-control'] ?? '', missingAsset).not.toContain('immutable');
}
});
it('index.html and non-asset files revalidate (no immutable caching)', async () => {
for (const revalidating of ['/', '/chat', '/favicon.svg']) {
const res = await request(app.getHttpServer()).get(revalidating);
+13
View File
@@ -75,6 +75,7 @@ export async function mountSpaStatic(app: NestFastifyApplication): Promise<void>
// this catch-all; non-GET unmatched requests keep Fastify's stock 404.
fastify.get('/*', (req, reply) => {
const url = req.raw.url ?? '';
const pathOnly = url.split('?', 1)[0] ?? url;
if (isBackendPath(url)) {
// An unknown backend path is an API 404, never the SPA page.
void reply.code(404).send({
@@ -84,6 +85,18 @@ export async function mountSpaStatic(app: NestFastifyApplication): Promise<void>
});
return;
}
if (pathOnly === '/assets' || pathOnly.startsWith('/assets/')) {
// A missing hashed asset — typically a browser holding a stale
// index.html after a deploy — must 404. Falling through to the SPA
// fallback would return index.html as the asset body, and the onSend
// hook above would stamp it with a year-long immutable cache-control.
void reply.code(404).send({
message: `Asset ${pathOnly} not found`,
error: 'Not Found',
statusCode: 404,
});
return;
}
// sendFile is decorated by @fastify/static; its type augmentation targets
// a different fastify copy in the pnpm tree than the Nest adapter's.
(reply as unknown as { sendFile: (file: string) => unknown }).sendFile('index.html');
+16 -20
View File
@@ -1,11 +1,14 @@
import { test, expect } from '@playwright/test';
import { loginAs, ADMIN_USER, TEST_USER } from './helpers/auth.js';
import { loginAs, ADMIN_USER, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
test.describe('Admin page — admin user', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, ADMIN_USER.email, ADMIN_USER.password);
const url = page.url();
test.skip(!url.includes('/chat'), 'No seeded admin user — skipping admin tests');
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded admin user — skipping admin tests',
);
});
test('admin page loads with the Admin Panel heading', async ({ page }) => {
@@ -43,26 +46,19 @@ test.describe('Admin page — non-admin user', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(!url.includes('/chat'), 'No seeded test user — skipping non-admin tests');
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping non-admin tests',
);
});
test('non-admin visiting /admin sees access denied or is redirected', async ({ page }) => {
test('non-admin visiting /admin never sees the admin panel', async ({ page }) => {
await page.goto('/admin');
// Either redirected away or shown an access-denied message
const onAdmin = page.url().includes('/admin');
if (onAdmin) {
// Should show some access-denied content rather than the full admin panel
const hasPanel = await page
.getByRole('heading', { name: /admin panel/i })
.isVisible()
.catch(() => false);
// If heading is visible, the guard allowed access (user may have admin role in this env)
// — not a failure, just informational
if (!hasPanel) {
// access denied message, redirect, or guard placeholder
const url = page.url();
expect(url).toBeTruthy(); // environment-dependent — no hard assertion
}
}
// Wait for the app shell to render (redirect and access-denied views both
// keep the sidebar), then assert the panel itself is absent. globalSetup
// seeds TEST_USER with role 'member', so this is a real authorization
// assertion, not environment-dependent.
await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('heading', { name: /admin panel/i })).not.toBeVisible();
});
});
+5 -9
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { TEST_USER } from './helpers/auth.js';
import { REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
// ── Login page ────────────────────────────────────────────────────────────────
@@ -49,18 +49,14 @@ test.describe('Login page', () => {
});
test('redirects to /chat after successful login', async ({ page }) => {
// Only meaningful with known-good credentials; against a live environment
// this would just probe someone else's user table.
test.skip(!REQUIRE_SEEDED_AUTH, 'needs seeded credentials (E2E_REQUIRE_SEEDED_AUTH=1)');
await page.goto('/login');
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password').fill(TEST_USER.password);
await page.getByRole('button', { name: /sign in/i }).click();
// Either reaches /chat or shows an error (if credentials are wrong in this env).
// We assert a navigation away from /login, or the alert is shown.
await Promise.race([
expect(page).toHaveURL(/\/chat/, { timeout: 10_000 }),
expect(page.getByRole('alert')).toBeVisible({ timeout: 10_000 }),
]).catch(() => {
// Acceptable — environment may not have seeded credentials
});
await expect(page).toHaveURL(/\/chat/, { timeout: 10_000 });
});
});
+5 -2
View File
@@ -1,12 +1,15 @@
import { test, expect } from '@playwright/test';
import { loginAs, TEST_USER } from './helpers/auth.js';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
test.describe('Chat page', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
// If login failed (no seeded user in env) we may be on /login — skip
const url = page.url();
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
});
test('chat page loads and shows the conversation area', async ({ page }) => {
+12 -2
View File
@@ -1,5 +1,5 @@
import type { FullConfig } from '@playwright/test';
import { ADMIN_USER, TEST_USER } from './helpers/auth.js';
import { ADMIN_USER, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
/**
* Seed the E2E users through the gateway's real APIs (#1445, P6).
@@ -10,7 +10,9 @@ import { ADMIN_USER, TEST_USER } from './helpers/auth.js';
*
* Against an environment that already has users (needsSetup=false), seeding is
* skipped entirely: the specs keep their own skip-when-login-fails guards, so
* a live environment stays usable as a test target without mutation.
* a live environment stays usable as a test target without mutation. Under
* E2E_REQUIRE_SEEDED_AUTH=1 (CI) that state is instead a hard failure and the
* guards are disabled — see helpers/auth.ts.
*
* On a fresh database, any seeding failure throws and fails the whole run: an
* E2E gate whose authenticated suites silently skip would pass while proving
@@ -25,6 +27,14 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
}
const status = (await statusRes.json()) as { needsSetup: boolean };
if (!status.needsSetup) {
if (REQUIRE_SEEDED_AUTH) {
// CI boots the gateway on a fresh HOME-isolated database, so an
// already-populated one means the isolation regressed — refuse to run
// against unknown data rather than skip-and-pass.
throw new Error(
'E2E_REQUIRE_SEEDED_AUTH=1 but the database already has users — gateway HOME isolation regressed?',
);
}
console.info('[e2e setup] users already exist; skipping seed');
return;
}
+17 -5
View File
@@ -12,17 +12,29 @@ export const ADMIN_USER = {
name: 'E2E Admin User',
};
/**
* Set when the database was seeded by global-setup (CI sets it in the
* publish.yml e2e step). Seeded credentials MUST work, so login failures are
* hard failures and the skip-when-login-fails guards are disabled — otherwise
* a login regression would skip every authenticated suite and the gate would
* pass while proving nothing. Unset (a live environment used as a test
* target), the guards stay on and unseeded credentials skip their suites.
*/
export const REQUIRE_SEEDED_AUTH = process.env['E2E_REQUIRE_SEEDED_AUTH'] === '1';
/**
* Fill the login form and submit, then wait for the post-login redirect to
* /chat. On failed login the wait times out and is swallowed: the page stays
* on /login, and the callers' `test.skip(!url.includes('/chat'))` guards see
* that. Without this wait, every guard read page.url() before the redirect
* happened and skipped its suite even when login succeeded (#1445).
* /chat. Under REQUIRE_SEEDED_AUTH a missed redirect throws (failing the
* test). Otherwise the timeout is swallowed: the page stays on /login and the
* callers' `test.skip(...)` guards see that. Without this wait, every guard
* read page.url() before the redirect happened and skipped its suite even
* when login succeeded (#1445).
*/
export async function loginAs(page: Page, email: string, password: string): Promise<void> {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForURL(/\/chat/, { timeout: 10_000 }).catch(() => {});
const redirect = page.waitForURL(/\/chat/, { timeout: 10_000 });
await (REQUIRE_SEEDED_AUTH ? redirect : redirect.catch(() => {}));
}
+9 -3
View File
@@ -1,11 +1,14 @@
import { test, expect } from '@playwright/test';
import { loginAs, TEST_USER } from './helpers/auth.js';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
test.describe('Sidebar navigation', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
});
test('sidebar shows the Mosaic brand', async ({ page }) => {
@@ -64,7 +67,10 @@ test.describe('Route transitions', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
});
test('navigating chat → projects → settings → chat works without errors', async ({ page }) => {
+5 -2
View File
@@ -1,11 +1,14 @@
import { test, expect } from '@playwright/test';
import { loginAs, TEST_USER } from './helpers/auth.js';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
test.describe('Projects page', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
});
test('projects page loads with heading', async ({ page }) => {
+5 -2
View File
@@ -1,11 +1,14 @@
import { test, expect } from '@playwright/test';
import { loginAs, TEST_USER } from './helpers/auth.js';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
test.describe('Settings page', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
});
test('settings page loads with heading', async ({ page }) => {