maestro/src/bridge/auth.test.ts
oss-sync 29ccaf1e92
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (dfadcd5f)
2026-06-23 06:38:48 +00:00

339 lines
13 KiB
TypeScript

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<Request> = {}) {
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<Request>);
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<Request>);
requireAuth(req, res, next);
expect(res.redirect).toHaveBeenCalledWith('/auth/login');
});
it('redirects a top-level navigation to /auth/login?returnTo=<url> (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<Request>);
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<Request>);
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<Request>);
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<Request>) =>
({ 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<Request>);
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<Request>);
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<AuthConfig['providers']>,
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);
});
});