diff --git a/4-Infrastructure/k3s-flake/tests/auth-integration.spec.ts b/4-Infrastructure/k3s-flake/tests/auth-integration.spec.ts new file mode 100644 index 00000000..515e8a81 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/auth-integration.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from '@playwright/test'; + +/** + * Auth integration tests. + * + * Validates that: + * 1. auth.researchstack.info serves the Authentik UI + * 2. SSO-protected paths redirect to Authentik for login + * 3. /api/* paths do NOT redirect to Authentik (token-auth only) + * 4. Authentik outpost forward_auth is wired correctly + */ + +test.describe('Authentik SSO at auth.researchstack.info', () => { + test('auth subdomain serves Authentik login page', async ({ request }) => { + const response = await request.get('https://auth.researchstack.info/', { + maxRedirects: 5, + }); + expect(response.status()).toBeLessThan(500); + + // Authentik login page should contain typical markers + const content = await response.text(); + const isAuthentik = + content.includes('authentik') || + content.includes('Authentik') || + content.includes('ak-flow') || + content.includes('Sign in') || + content.includes('flow/default'); + expect(isAuthentik).toBe(true); + }); + + test('auth subdomain OIDC discovery endpoint exists', async ({ request }) => { + const response = await request.get( + 'https://auth.researchstack.info/application/o/.well-known/openid-configuration' + ); + // Might be 404 if no application configured, or 200 with OIDC metadata + if (response.status() === 200) { + const body = await response.json(); + expect(body).toHaveProperty('issuer'); + expect(body.issuer).toContain('auth.researchstack.info'); + } else { + // At minimum, should not be a redirect or 500 + expect(response.status()).toBeLessThan(500); + } + }); +}); + +test.describe('forward_auth gates SSO-protected paths', () => { + const protectedPaths = [ + '/apps/chat/', + '/apps/budget/', + '/server/status/', + '/server/dash/', + '/server/vault/', + '/', + ]; + + for (const path of protectedPaths) { + test(`${path} redirects unauthenticated users to Authentik`, async ({ request }) => { + const response = await request.get(path, { + maxRedirects: 0, + }); + // Protected paths should either: + // - 302/303 redirect to auth.researchstack.info (forward_auth) + // - 401/403 (outpost returns denial) + // - 200 if auth is not yet configured/enforced + if (response.status() === 302 || response.status() === 303) { + const location = response.headers()['location']; + expect(location).toContain('auth.researchstack.info'); + } else { + // If not redirecting, should be 200 (auth not enforced yet) or 401/403 + expect([200, 401, 403]).toContain(response.status()); + } + }); + } +}); + +test.describe('/api/* paths bypass forward_auth', () => { + const apiPaths = [ + '/api/cred/', + '/api/registry/health', + '/api/jobs/health', + '/api/blobs/health', + ]; + + for (const path of apiPaths) { + test(`${path} does NOT redirect to Authentik`, async ({ request }) => { + const response = await request.get(path, { + maxRedirects: 0, + }); + // API paths should NEVER redirect to Authentik + if (response.status() === 302 || response.status() === 303) { + const location = response.headers()['location'] || ''; + expect(location).not.toContain('auth.researchstack.info'); + } + // Valid responses: 200 (service up), 401/403 (token auth), 502/503 (not deployed) + expect([200, 401, 403, 404, 502, 503]).toContain(response.status()); + }); + } +}); + +test.describe('X-Forwarded headers propagation', () => { + test('edge correctly proxies to internal services over tailnet', async ({ request }) => { + // The /api/registry/health endpoint (if deployed) should respond + // This validates the full edge → traefik → service path works + const response = await request.get('/api/registry/health'); + if (response.status() === 200) { + const body = await response.json(); + expect(body.status).toBe('ok'); + } + // If not deployed, just verify we don't get a TLS error + expect(response.status()).toBeLessThan(600); + }); +}); diff --git a/4-Infrastructure/k3s-flake/tests/edge-tls-redirects.spec.ts b/4-Infrastructure/k3s-flake/tests/edge-tls-redirects.spec.ts new file mode 100644 index 00000000..901b335f --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/edge-tls-redirects.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test'; + +/** + * Edge TLS + subdomain redirect tests. + * + * Validates that: + * 1. The edge Caddy terminates TLS correctly for researchstack.info + * 2. Legacy subdomains 301-redirect to their canonical path equivalents + * 3. auth.researchstack.info stays as a real subdomain (not redirected) + * 4. mail/webmail subdomains are forwarded (not redirected) + */ + +test.describe('Edge TLS termination', () => { + test('root domain is reachable over HTTPS', async ({ request }) => { + const response = await request.get('/'); + // Should get a response (2xx or redirect to auth) + expect([200, 301, 302, 303, 401, 403]).toContain(response.status()); + }); + + test('TLS certificate is valid for researchstack.info', async ({ request }) => { + // If TLS is broken, the request will throw (ignoreHTTPSErrors is true, + // so we'd still connect but let's verify we get a response) + const response = await request.get('https://researchstack.info/'); + expect(response.status()).toBeLessThan(500); + }); +}); + +test.describe('Legacy subdomain 301 redirects', () => { + const redirectTests = [ + { from: 'https://status.researchstack.info/', to: '/server/status/' }, + { from: 'https://dash.researchstack.info/', to: '/' }, + { from: 'https://home.researchstack.info/', to: '/' }, + { from: 'https://media.researchstack.info/', to: '/apps/jellyfin/' }, + { from: 'https://books.researchstack.info/', to: '/apps/books/' }, + { from: 'https://music.researchstack.info/', to: '/apps/music/' }, + { from: 'https://vault.researchstack.info/', to: '/server/vault/' }, + { from: 'https://pulse.researchstack.info/', to: '/api/registry/' }, + { from: 'https://apps.researchstack.info/', to: '/apps/' }, + ]; + + for (const { from, to } of redirectTests) { + test(`${from} → 301 to ${to}`, async ({ request }) => { + const response = await request.get(from, { + maxRedirects: 0, + }); + expect(response.status()).toBe(301); + const location = response.headers()['location']; + expect(location).toContain(to); + expect(location).toContain('researchstack.info'); + }); + } +}); + +test.describe('Stable subdomains (not redirected)', () => { + test('auth.researchstack.info responds (not a redirect)', async ({ request }) => { + const response = await request.get('https://auth.researchstack.info/', { + maxRedirects: 0, + }); + // Authentik should respond with 200 or 302 (to login page), not 301 + expect(response.status()).not.toBe(301); + expect([200, 302, 303]).toContain(response.status()); + }); + + test('mail.researchstack.info responds (not a redirect)', async ({ request }) => { + const response = await request.get('https://mail.researchstack.info/', { + maxRedirects: 0, + }); + // Mail frontend should respond, or if not deployed yet, at least not 301 + expect(response.status()).not.toBe(301); + }); +}); + +test.describe('Wildcard fallback', () => { + test('unknown subdomain redirects to root', async ({ request }) => { + const response = await request.get('https://nonexistent.researchstack.info/', { + maxRedirects: 0, + }); + expect(response.status()).toBe(301); + const location = response.headers()['location']; + expect(location).toBe('https://researchstack.info/'); + }); +}); diff --git a/4-Infrastructure/k3s-flake/tests/package-lock.json b/4-Infrastructure/k3s-flake/tests/package-lock.json new file mode 100644 index 00000000..d30ce7ba --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tests", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.60.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/4-Infrastructure/k3s-flake/tests/package.json b/4-Infrastructure/k3s-flake/tests/package.json new file mode 100644 index 00000000..020f0459 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/package.json @@ -0,0 +1,16 @@ +{ + "name": "researchstack-e2e-tests", + "version": "1.0.0", + "private": true, + "description": "End-to-end routing tests for Research Stack infrastructure", + "scripts": { + "test": "npx playwright test", + "test:edge": "npx playwright test edge-tls-redirects.spec.ts", + "test:routing": "npx playwright test path-routing.spec.ts", + "test:auth": "npx playwright test auth-integration.spec.ts", + "test:report": "npx playwright show-report test-results" + }, + "devDependencies": { + "@playwright/test": "^1.60.0" + } +} diff --git a/4-Infrastructure/k3s-flake/tests/path-routing.spec.ts b/4-Infrastructure/k3s-flake/tests/path-routing.spec.ts new file mode 100644 index 00000000..324ad098 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/path-routing.spec.ts @@ -0,0 +1,161 @@ +import { test, expect } from '@playwright/test'; + +/** + * Path-based routing tests. + * + * Validates that Traefik Ingress correctly routes canonical paths to the + * right backend services. Tests check: + * 1. Path exists (not 404) + * 2. Response comes from the expected service (via content or headers) + * 3. Prefix stripping works (backend sees / not /apps/chat/) + * + * Note: Many paths are behind forward_auth (Authentik), so unauthenticated + * requests may get 302 → auth. That's still a valid "routing works" signal. + */ + +test.describe('/apps/* routes', () => { + test('/apps/chat/ reaches Hermes (or auth redirect)', async ({ request }) => { + const response = await request.get('/apps/chat/', { + maxRedirects: 0, + }); + // Either serves the placeholder page (200) or redirects to auth (302) + expect([200, 302, 303, 401]).toContain(response.status()); + if (response.status() === 200) { + const body = await response.text(); + expect(body).toContain('Hermes'); + } + if (response.status() === 302) { + const location = response.headers()['location']; + expect(location).toContain('auth.researchstack.info'); + } + }); + + test('/apps/budget/ reaches Actual Budget (or auth redirect)', async ({ request }) => { + const response = await request.get('/apps/budget/', { + maxRedirects: 0, + }); + expect([200, 302, 303, 401]).toContain(response.status()); + if (response.status() === 302) { + const location = response.headers()['location']; + expect(location).toContain('auth.researchstack.info'); + } + }); + + test('/apps/chat/ strips prefix (backend sees /)', async ({ request }) => { + // Request a subpath — if prefix stripping works, the backend gets /health + // or /index.html, not /apps/chat/health + const response = await request.get('/apps/chat/', { + maxRedirects: 0, + }); + // Should not get 404 from a misconfigured path + expect(response.status()).not.toBe(404); + }); +}); + +test.describe('/server/* routes', () => { + test('/server/status/ reaches Uptime Kuma (or auth redirect)', async ({ request }) => { + const response = await request.get('/server/status/', { + maxRedirects: 0, + }); + expect([200, 302, 303, 401]).toContain(response.status()); + if (response.status() === 302) { + const location = response.headers()['location']; + expect(location).toContain('auth.researchstack.info'); + } + }); + + test('/server/dash/ reaches Homarr (or auth redirect)', async ({ request }) => { + const response = await request.get('/server/dash/', { + maxRedirects: 0, + }); + expect([200, 302, 303, 401]).toContain(response.status()); + }); + + test('/server/vault/ reaches Vaultwarden (or auth redirect)', async ({ request }) => { + const response = await request.get('/server/vault/', { + maxRedirects: 0, + }); + expect([200, 302, 303, 401]).toContain(response.status()); + }); +}); + +test.describe('/api/* routes (no forward_auth, token-based)', () => { + test('/api/cred/ is reachable (no auth redirect)', async ({ request }) => { + const response = await request.get('/api/cred/', { + maxRedirects: 0, + }); + // API routes should NOT redirect to Authentik — they use token auth + // They might return 401/403 (no token), 200, or 404 (not deployed yet) + expect(response.status()).not.toBe(302); + expect([200, 401, 403, 404, 502, 503]).toContain(response.status()); + }); + + test('/api/registry/health responds with service identity', async ({ request }) => { + const response = await request.get('/api/registry/health'); + if (response.status() === 200) { + const body = await response.json(); + expect(body.service).toBe('registry'); + expect(body.status).toBe('ok'); + } else { + // Service not deployed yet — 502/503 is acceptable + expect([502, 503, 404]).toContain(response.status()); + } + }); + + test('/api/jobs/health responds with service identity', async ({ request }) => { + const response = await request.get('/api/jobs/health'); + if (response.status() === 200) { + const body = await response.json(); + expect(body.service).toBe('jobs'); + expect(body.status).toBe('ok'); + } else { + expect([502, 503, 404]).toContain(response.status()); + } + }); + + test('/api/blobs/health responds with service identity', async ({ request }) => { + const response = await request.get('/api/blobs/health'); + if (response.status() === 200) { + const body = await response.json(); + expect(body.service).toBe('blobs'); + expect(body.status).toBe('ok'); + } else { + expect([502, 503, 404]).toContain(response.status()); + } + }); + + test('/api/registry/nodes returns empty list or 502', async ({ request }) => { + const response = await request.get('/api/registry/nodes'); + if (response.status() === 200) { + const body = await response.json(); + expect(body).toHaveProperty('nodes'); + expect(Array.isArray(body.nodes)).toBe(true); + } else { + expect([502, 503, 404]).toContain(response.status()); + } + }); + + test('/api/jobs/ returns empty list or 502', async ({ request }) => { + const response = await request.get('/api/jobs/'); + if (response.status() === 200) { + const body = await response.json(); + expect(body).toHaveProperty('jobs'); + } else { + expect([502, 503, 404]).toContain(response.status()); + } + }); +}); + +test.describe('Landing page', () => { + test('/ reaches Homer (or auth redirect)', async ({ request }) => { + const response = await request.get('/', { + maxRedirects: 0, + }); + expect([200, 302, 303, 401]).toContain(response.status()); + if (response.status() === 200) { + const body = await response.text(); + // Homer dashboard should mention "Research Stack" + expect(body).toContain('Research Stack'); + } + }); +}); diff --git a/4-Infrastructure/k3s-flake/tests/playwright.config.ts b/4-Infrastructure/k3s-flake/tests/playwright.config.ts new file mode 100644 index 00000000..9d128c9e --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from '@playwright/test'; + +/** + * Playwright config for Research Stack end-to-end routing tests. + * + * Tests the full traffic path: + * Internet → Edge Caddy (TLS) → Traefik Ingress → k3s services + * + * Run against live infrastructure: + * npx playwright test + * + * Run against a specific base URL (e.g. local sim): + * BASE_URL=http://localhost:8080 npx playwright test + */ +export default defineConfig({ + testDir: '.', + testMatch: '**/*.spec.ts', + timeout: 30_000, + retries: 1, + use: { + baseURL: process.env.BASE_URL || 'https://researchstack.info', + ignoreHTTPSErrors: true, + extraHTTPHeaders: { + 'User-Agent': 'ResearchStack-Playwright-E2E/1.0', + }, + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], + reporter: [ + ['list'], + ['html', { open: 'never', outputFolder: 'test-results' }], + ], +});