diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1934124..3d101d1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -46,6 +46,16 @@ if (!devBypassActive) { } // Protected API routes (behind oidcAuthMiddleware) + +// GET /api/login — login entry point for the PWA. +// Flow (production): unauthenticated top-level nav hits the OIDC guard above, +// which 302-redirects to Authelia. After login, Authelia POSTs to /callback, +// the middleware sets a `continue` cookie pointing back to /api/login, and the +// browser follows it here — now authenticated. The handler then redirects to / +// so the SPA boots with a valid session. Under DEV_AUTH_BYPASS the guard is not +// mounted, so /api/login reaches this handler directly and still redirects to /. +app.get('/api/login', (c) => c.redirect('/')) + app.route('/api/me', meRouter) app.route('/api/events', eventsRouter) app.route('/api/sse', sseRouter) diff --git a/apps/api/tests/routes/login.test.ts b/apps/api/tests/routes/login.test.ts new file mode 100644 index 0000000..8b5ea4f --- /dev/null +++ b/apps/api/tests/routes/login.test.ts @@ -0,0 +1,109 @@ +/** + * GET /api/login — tests for the top-level-nav login entry point. + * + * Covers: + * 1. DEV_AUTH_BYPASS=true: GET /api/login returns 302 → '/'. + * Guard is not mounted; handler fires directly. + * 2. Without DEV_AUTH_BYPASS: oidcAuthMiddleware passthrough mock lets the + * request through; handler returns 302 → '/' (mirroring me.test.ts OIDC path). + * + * Flow (production): + * Unauthenticated top-level nav → guard 302 → Authelia → /callback → `continue` + * cookie returns browser to /api/login (now authenticated) → handler 302 → /. + * Under DEV_AUTH_BYPASS, guard is not mounted, so /api/login reaches the handler + * directly and redirects to '/'. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// --------------------------------------------------------------------------- +// Shared mock: DB — avoids real DB connections across all tests in this file. +// Hoisted so it applies to every dynamic import below. +// --------------------------------------------------------------------------- +vi.mock('../../src/db/client.js', () => ({ + db: { + execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]), + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + }), + }), + }, +})) + +// --------------------------------------------------------------------------- +// Track whether oidcAuthMiddleware was registered on the app. +// --------------------------------------------------------------------------- +const oidcMiddlewareSpy = vi.fn( + () => async (_c: unknown, next: () => Promise) => next(), +) + +vi.mock('@hono/oidc-auth', () => ({ + oidcAuthMiddleware: () => oidcMiddlewareSpy(), + processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => + c.json({ ok: true }), + getAuth: vi.fn().mockResolvedValue(null), +})) + +// --------------------------------------------------------------------------- +// Env snapshot — restored after each test. +// --------------------------------------------------------------------------- +const originalNodeEnv = process.env.NODE_ENV +const originalBypassFlag = process.env.DEV_AUTH_BYPASS + +afterEach(() => { + process.env.NODE_ENV = originalNodeEnv + if (originalBypassFlag === undefined) { + delete process.env.DEV_AUTH_BYPASS + } else { + process.env.DEV_AUTH_BYPASS = originalBypassFlag + } + vi.resetModules() + oidcMiddlewareSpy.mockClear() +}) + +// --------------------------------------------------------------------------- + +describe('GET /api/login — dev-auth bypass (DEV_AUTH_BYPASS=true)', () => { + beforeEach(() => { + process.env.NODE_ENV = 'test' + process.env.DEV_AUTH_BYPASS = 'true' + }) + + it('returns 302 with location "/" (bypass active, guard not mounted)', async () => { + const { app } = await import('../../src/index.js') + + const res = await app.request('/api/login') + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('/') + }) + + it('does not invoke oidcAuthMiddleware when bypass is active', async () => { + const { app } = await import('../../src/index.js') + + await app.request('/api/login') + + expect(oidcMiddlewareSpy).not.toHaveBeenCalled() + }) +}) + +describe('GET /api/login — OIDC path (no DEV_AUTH_BYPASS)', () => { + beforeEach(() => { + process.env.NODE_ENV = 'test' + delete process.env.DEV_AUTH_BYPASS + }) + + it('returns 302 with location "/" when OIDC passthrough allows the request', async () => { + const { app } = await import('../../src/index.js') + + // oidcAuthMiddleware is mocked as a passthrough — request reaches the handler. + const res = await app.request('/api/login') + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('/') + }) +})