import { afterEach, describe, it, expect, vi } from 'vitest'; import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { Repository } from '../db/repository.js'; import { requireAuth, requireAdmin, fetchGiteaOrgsForUser, isProviderConfigured, isProviderActive, safeReturnTo, isTopLevelNavigation, consumeReturnTo } from './auth.js'; import type { AuthConfig } from '../config.js'; import type { Request, Response, NextFunction } from 'express'; function mockReqRes(overrides: Partial = {}) { const req = { isAuthenticated: () => false, user: undefined, originalUrl: '/api/test', headers: { accept: 'application/json' }, ...overrides, } as unknown as Request; const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), redirect: vi.fn().mockReturnThis(), } as unknown as Response; const next = vi.fn() as NextFunction; return { req, res, next }; } describe('requireAuth', () => { it('calls next() for authenticated active user', () => { const { req, res, next } = mockReqRes({ isAuthenticated: () => true, user: { id: '1', role: 'user', status: 'active' }, } as Partial); requireAuth(req, res, next); expect(next).toHaveBeenCalled(); }); it('returns 401 for unauthenticated API request', () => { const { req, res, next } = mockReqRes(); requireAuth(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(next).not.toHaveBeenCalled(); }); it('redirects to /auth/login for unauthenticated non-API request', () => { const { req, res, next } = mockReqRes({ originalUrl: '/ui', headers: { accept: 'text/html' }, } as Partial); requireAuth(req, res, next); expect(res.redirect).toHaveBeenCalledWith('/auth/login'); }); it('redirects a top-level navigation to /auth/login?returnTo= (even under /api/)', () => { const { req, res, next } = mockReqRes({ method: 'GET', originalUrl: '/api/local/tasks/1/files/raw?trusted=1&path=report.html', headers: { 'sec-fetch-mode': 'navigate', accept: 'text/html' }, } as Partial); requireAuth(req, res, next); expect(res.redirect).toHaveBeenCalledWith( '/auth/login?returnTo=' + encodeURIComponent('/api/local/tasks/1/files/raw?trusted=1&path=report.html'), ); expect(next).not.toHaveBeenCalled(); }); it('returns 401 JSON for an unauthenticated SPA fetch (sec-fetch-mode=cors), not a redirect', () => { const { req, res, next } = mockReqRes({ method: 'GET', originalUrl: '/api/local/tasks', headers: { 'sec-fetch-mode': 'cors', accept: 'application/json' }, } as Partial); requireAuth(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.redirect).not.toHaveBeenCalled(); }); it('returns 401 JSON for an unauthenticated POST even with an HTML Accept header', () => { const { req, res, next } = mockReqRes({ method: 'POST', originalUrl: '/api/local/tasks', headers: { accept: 'text/html' }, } as Partial); requireAuth(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.redirect).not.toHaveBeenCalled(); }); }); describe('safeReturnTo', () => { it('accepts a same-origin absolute path', () => { expect(safeReturnTo('/spaces/abc')).toBe('/spaces/abc'); expect(safeReturnTo('/api/local/tasks/1/files/raw?trusted=1&path=a.html')).toBe( '/api/local/tasks/1/files/raw?trusted=1&path=a.html', ); expect(safeReturnTo('/')).toBe('/'); }); it('rejects open-redirect and non-path inputs', () => { expect(safeReturnTo('//evil.com')).toBeNull(); // protocol-relative expect(safeReturnTo('/\\evil.com')).toBeNull(); // backslash smuggling expect(safeReturnTo('https://evil.com')).toBeNull(); // absolute URL expect(safeReturnTo('http://evil.com')).toBeNull(); expect(safeReturnTo('javascript:alert(1)')).toBeNull(); expect(safeReturnTo('spaces/abc')).toBeNull(); // not rooted expect(safeReturnTo('/foo\nbar')).toBeNull(); // control char expect(safeReturnTo('')).toBeNull(); expect(safeReturnTo(undefined)).toBeNull(); expect(safeReturnTo(123 as unknown)).toBeNull(); expect(safeReturnTo('/' + 'a'.repeat(3000))).toBeNull(); // too long }); }); describe('consumeReturnTo', () => { it('returns the saved value and clears it (single use)', () => { const session = { returnTo: '/spaces/once' } as { returnTo?: string }; const req = { session } as unknown as Request; expect(consumeReturnTo(req)).toBe('/spaces/once'); expect(session.returnTo).toBeUndefined(); expect(consumeReturnTo(req)).toBeNull(); // already consumed }); it('re-validates the stored value and rejects unsafe paths', () => { const req = { session: { returnTo: '//evil.com' } } as unknown as Request; expect(consumeReturnTo(req)).toBeNull(); }); it('returns null when there is no session', () => { expect(consumeReturnTo({} as unknown as Request)).toBeNull(); }); }); describe('isTopLevelNavigation', () => { const mk = (over: Partial) => ({ method: 'GET', headers: {}, ...over } as unknown as Request); it('treats GET with Sec-Fetch-Mode: navigate as a navigation', () => { expect(isTopLevelNavigation(mk({ headers: { 'sec-fetch-mode': 'navigate' } }))).toBe(true); }); it('falls back to Accept: text/html when Sec-Fetch-Mode is absent', () => { expect(isTopLevelNavigation(mk({ headers: { accept: 'text/html,*/*' } }))).toBe(true); }); it('does not treat a CORS fetch as a navigation', () => { expect( isTopLevelNavigation(mk({ headers: { 'sec-fetch-mode': 'cors', accept: 'text/html' } })), ).toBe(false); }); it('does not treat non-GET as a navigation', () => { expect(isTopLevelNavigation(mk({ method: 'POST', headers: { accept: 'text/html' } }))).toBe( false, ); }); }); describe('requireAdmin', () => { it('calls next() for admin user', () => { const { req, res, next } = mockReqRes({ isAuthenticated: () => true, user: { id: '1', role: 'admin', status: 'active' }, } as Partial); requireAdmin(req, res, next); expect(next).toHaveBeenCalled(); }); it('returns 403 for non-admin user', () => { const { req, res, next } = mockReqRes({ isAuthenticated: () => true, user: { id: '1', role: 'user', status: 'active' }, } as Partial); requireAdmin(req, res, next); expect(res.status).toHaveBeenCalledWith(403); }); it('returns 401 for unauthenticated request', () => { const { req, res, next } = mockReqRes(); requireAdmin(req, res, next); expect(res.status).toHaveBeenCalledWith(401); }); }); describe('fetchGiteaOrgsForUser', () => { let tempDir = ''; afterEach(() => { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } vi.restoreAllMocks(); }); function makeRepo(): Repository { tempDir = mkdtempSync(join(tmpdir(), 'auth-test-')); return new Repository(join(tempDir, 'db.sqlite')); } it('fetches and persists user orgs from Gitea API', async () => { const repo = makeRepo(); try { const user = repo.createUser({ email: 'a@gitea.local', name: 'alice', role: 'user', status: 'active', }); vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ ok: true, json: async () => [ { id: 10, username: 'marketing' }, { id: 20, username: 'platform' }, ], } as Response); const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token-xyz'); expect(orgIds.sort()).toEqual(['10', '20']); expect(repo.listUserGiteaOrgs(user.id).map(o => o.orgName).sort()) .toEqual(['marketing', 'platform']); } finally { repo.close(); } }); it('returns empty array on !res.ok', async () => { const repo = makeRepo(); try { const user = repo.createUser({ email: 'a@gitea.local', name: 'alice', role: 'user', status: 'active', }); vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ ok: false, status: 401, } as Response); const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'bad-token'); expect(orgIds).toEqual([]); expect(repo.listUserGiteaOrgs(user.id)).toEqual([]); } finally { repo.close(); } }); it('returns empty array on fetch rejection (network error)', async () => { const repo = makeRepo(); try { const user = repo.createUser({ email: 'a@gitea.local', name: 'alice', role: 'user', status: 'active', }); vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('ECONNREFUSED')); const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token'); expect(orgIds).toEqual([]); expect(repo.listUserGiteaOrgs(user.id)).toEqual([]); } finally { repo.close(); } }); it('clears stale cached orgs when fetch fails (prevents permission lag after org removal)', async () => { const repo = makeRepo(); try { const user = repo.createUser({ email: 'a@gitea.local', name: 'alice', role: 'user', status: 'active', }); repo.replaceUserGiteaOrgs(user.id, [ { orgId: '10', orgName: 'marketing' }, { orgId: '20', orgName: 'platform' }, ]); expect(repo.listUserGiteaOrgs(user.id)).toHaveLength(2); vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ ok: false, status: 503 } as Response); await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token'); expect(repo.listUserGiteaOrgs(user.id)).toEqual([]); repo.replaceUserGiteaOrgs(user.id, [{ orgId: '10', orgName: 'marketing' }]); vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('ECONNREFUSED')); await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token'); expect(repo.listUserGiteaOrgs(user.id)).toEqual([]); } finally { repo.close(); } }); }); // Audit 2026-06-09: provider completeness + primaryProvider gating. These pin // the security-critical fail-closed / restriction semantics behind the new // AuthForm (config editable from Settings UI). const COMPLETE = { clientId: 'id', clientSecret: 'sec', callbackUrl: 'https://x/cb' }; const COMPLETE_GITEA = { ...COMPLETE, baseUrl: 'https://gitea' }; function authCfg( providers: Partial, primaryProvider?: 'google' | 'gitea', ): AuthConfig { return { sessionSecret: 's', sessionMaxAge: 1, secureCookie: false, adminEmails: [], primaryProvider, providers, } as AuthConfig; } describe('isProviderConfigured', () => { it('true only when all required fields are present', () => { expect(isProviderConfigured(COMPLETE, 'google')).toBe(true); expect(isProviderConfigured(COMPLETE_GITEA, 'gitea')).toBe(true); }); it('gitea requires baseUrl', () => { expect(isProviderConfigured(COMPLETE, 'gitea')).toBe(false); }); it('false when any field is missing or provider undefined', () => { expect(isProviderConfigured({ ...COMPLETE, clientSecret: '' }, 'google')).toBe(false); expect(isProviderConfigured({ ...COMPLETE, callbackUrl: undefined }, 'google')).toBe(false); expect(isProviderConfigured(undefined, 'google')).toBe(false); }); }); describe('isProviderActive (primaryProvider restriction)', () => { it('both active when both complete and no primary', () => { const c = authCfg({ google: COMPLETE, gitea: COMPLETE_GITEA }); expect(isProviderActive(c, 'google')).toBe(true); expect(isProviderActive(c, 'gitea')).toBe(true); }); it('a valid primary restricts to that provider only', () => { const c = authCfg({ google: COMPLETE, gitea: COMPLETE_GITEA }, 'google'); expect(isProviderActive(c, 'google')).toBe(true); expect(isProviderActive(c, 'gitea')).toBe(false); }); it('an invalid primary (unconfigured provider) is ignored', () => { const c = authCfg({ gitea: COMPLETE_GITEA }, 'google'); expect(isProviderActive(c, 'gitea')).toBe(true); // gitea still usable expect(isProviderActive(c, 'google')).toBe(false); // google not configured }); it('does not throw when providers is entirely absent (local-only config)', () => { // Saving "local only" from the Settings UI can persist an auth block with // no providers key at all. The OAuth helpers + strategy registration must // treat that as "no OAuth provider configured", not crash. const c = { sessionSecret: 's', sessionMaxAge: 1, secureCookie: false, adminEmails: [], } as AuthConfig; expect(() => isProviderActive(c, 'google')).not.toThrow(); expect(isProviderActive(c, 'google')).toBe(false); expect(isProviderActive(c, 'gitea')).toBe(false); }); });