import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import * as jose from 'jose';
import { generateCodeVerifier, generateCodeChallenge, generateState, generateOAuthNonce, verifyState, getAuthorizationUrl, handleOAuthCallback, InvalidOAuthStateError, } from '../../auth/oauth.js';
const TEST_SECRET = 'a-secret-key-that-is-at-least-32-chars-long!!';
const RETURN_TO = '/admin/dashboard';
const PROVIDER_CONFIG = {
    clientId: 'client-id-123',
    clientSecret: 'client-secret-abc',
    redirectUri: 'https://example.com/api/cms/auth/oauth/google/callback',
};
// ─── PKCE primitives ────────────────────────────────────────────────────
describe('generateCodeVerifier', () => {
    it('returns a base64url string with no padding', () => {
        const v = generateCodeVerifier();
        // RFC 7636 §4.1: verifiers are 43..128 chars from the unreserved alphabet.
        expect(v).toMatch(/^[A-Za-z0-9_-]{43,128}$/);
        expect(v).not.toContain('=');
        expect(v).not.toContain('+');
        expect(v).not.toContain('/');
    });
    it('produces a unique value per call', () => {
        const a = generateCodeVerifier();
        const b = generateCodeVerifier();
        const c = generateCodeVerifier();
        expect(new Set([a, b, c]).size).toBe(3);
    });
});
describe('generateCodeChallenge', () => {
    it('returns a base64url SHA-256 of the verifier (RFC 7636 §4.2 S256 method)', async () => {
        // Test vector from RFC 7636 §4.2: verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
        // → challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
        const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
        const challenge = await generateCodeChallenge(verifier);
        expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
    });
    it('produces base64url output (no padding)', async () => {
        const challenge = await generateCodeChallenge('any-verifier-value-for-testing-only');
        expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
        expect(challenge).not.toContain('=');
    });
});
describe('generateOAuthNonce', () => {
    it('returns a base64url 16-byte nonce', () => {
        const n = generateOAuthNonce();
        expect(n).toMatch(/^[A-Za-z0-9_-]+$/);
        // 16 bytes → 22 base64url chars (no padding)
        expect(n.length).toBe(22);
    });
    it('produces a unique value per call', () => {
        const set = new Set(Array.from({ length: 50 }, () => generateOAuthNonce()));
        expect(set.size).toBe(50);
    });
});
// ─── State token (signed JWT with PKCE verifier) ────────────────────────
describe('generateState / verifyState', () => {
    it('round-trips a valid state through sign + verify', async () => {
        const verifier = generateCodeVerifier();
        const state = await generateState('google', verifier, RETURN_TO, TEST_SECRET);
        const decoded = await verifyState(state, TEST_SECRET);
        expect(decoded.provider).toBe('google');
        expect(decoded.codeVerifier).toBe(verifier);
        expect(decoded.returnTo).toBe(RETURN_TO);
        expect(decoded.nonce).toBeUndefined();
    });
    it('round-trips a nonce when provided', async () => {
        const nonce = generateOAuthNonce();
        const state = await generateState('github', generateCodeVerifier(), '/admin', TEST_SECRET, nonce);
        const decoded = await verifyState(state, TEST_SECRET);
        expect(decoded.nonce).toBe(nonce);
    });
    it('strips standard JWT claims from the returned payload', async () => {
        const verifier = generateCodeVerifier();
        const state = await generateState('google', verifier, RETURN_TO, TEST_SECRET);
        const decoded = await verifyState(state, TEST_SECRET);
        // jose injects iat/exp/iss into the JWT but verifyState should only return
        // the validated OAuthState fields.
        expect(decoded.iat).toBeUndefined();
        expect(decoded.exp).toBeUndefined();
        expect(decoded.iss).toBeUndefined();
    });
    it('rejects a token signed with a different secret', async () => {
        const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
        await expect(verifyState(state, 'a-different-secret-of-at-least-32-chars-x')).rejects.toThrow();
    });
    it('rejects a tampered token', async () => {
        const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
        // Flip a character in the signature (last segment)
        const parts = state.split('.');
        parts[2] = parts[2].replace(/[A-Za-z0-9_-]/, (c) => (c === 'a' ? 'b' : 'a'));
        const tampered = parts.join('.');
        await expect(verifyState(tampered, TEST_SECRET)).rejects.toThrow();
    });
    it('rejects a token issued by a different application (issuer mismatch)', async () => {
        // Sign a state-shaped JWT with the right key but a wrong `iss`.
        const key = new TextEncoder().encode(TEST_SECRET);
        const evil = await new jose.SignJWT({
            provider: 'google',
            codeVerifier: 'x'.repeat(43),
            returnTo: '/',
        })
            .setProtectedHeader({ alg: 'HS256' })
            .setIssuedAt()
            .setExpirationTime('10m')
            .setIssuer('not-actuate-cms')
            .sign(key);
        await expect(verifyState(evil, TEST_SECRET)).rejects.toThrow();
    });
    it('rejects a state with a missing provider field', async () => {
        const key = new TextEncoder().encode(TEST_SECRET);
        const malformed = await new jose.SignJWT({
            codeVerifier: 'x'.repeat(43),
            returnTo: '/',
        })
            .setProtectedHeader({ alg: 'HS256' })
            .setIssuedAt()
            .setExpirationTime('10m')
            .setIssuer('actuate-cms')
            .sign(key);
        await expect(verifyState(malformed, TEST_SECRET)).rejects.toThrow(InvalidOAuthStateError);
    });
    it('rejects a state with a missing codeVerifier field', async () => {
        const key = new TextEncoder().encode(TEST_SECRET);
        const malformed = await new jose.SignJWT({
            provider: 'google',
            returnTo: '/',
        })
            .setProtectedHeader({ alg: 'HS256' })
            .setIssuedAt()
            .setExpirationTime('10m')
            .setIssuer('actuate-cms')
            .sign(key);
        await expect(verifyState(malformed, TEST_SECRET)).rejects.toThrow(InvalidOAuthStateError);
    });
    it('rejects a state where nonce is not a string', async () => {
        const key = new TextEncoder().encode(TEST_SECRET);
        const malformed = await new jose.SignJWT({
            provider: 'google',
            codeVerifier: 'x'.repeat(43),
            returnTo: '/',
            nonce: 12345,
        })
            .setProtectedHeader({ alg: 'HS256' })
            .setIssuedAt()
            .setExpirationTime('10m')
            .setIssuer('actuate-cms')
            .sign(key);
        await expect(verifyState(malformed, TEST_SECRET)).rejects.toThrow(InvalidOAuthStateError);
    });
});
// ─── Authorization URL builder ──────────────────────────────────────────
describe('getAuthorizationUrl', () => {
    it('builds a Google authorize URL with PKCE params', () => {
        const url = new URL(getAuthorizationUrl('google', PROVIDER_CONFIG, 'state-jwt', 'challenge-x'));
        expect(url.origin + url.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth');
        expect(url.searchParams.get('response_type')).toBe('code');
        expect(url.searchParams.get('client_id')).toBe(PROVIDER_CONFIG.clientId);
        expect(url.searchParams.get('redirect_uri')).toBe(PROVIDER_CONFIG.redirectUri);
        expect(url.searchParams.get('state')).toBe('state-jwt');
        expect(url.searchParams.get('code_challenge')).toBe('challenge-x');
        expect(url.searchParams.get('code_challenge_method')).toBe('S256');
        expect(url.searchParams.get('scope')).toContain('openid');
    });
    it('uses a github-specific authorize endpoint', () => {
        const url = new URL(getAuthorizationUrl('github', PROVIDER_CONFIG, 's', 'c'));
        expect(url.origin + url.pathname).toBe('https://github.com/login/oauth/authorize');
    });
});
function createFakeDb(initial = {}) {
    const users = initial.users ?? [];
    const oauthAccounts = initial.oauthAccounts ?? [];
    const sessions = [];
    return {
        users,
        oauthAccounts,
        sessions,
        user: {
            findFirst: vi.fn(async ({ where }) => {
                const target = where.email.equals.toLowerCase();
                return users.find((u) => u.email.toLowerCase() === target) ?? null;
            }),
            create: vi.fn(async ({ data }) => {
                const u = { id: `u_${users.length + 1}`, ...data };
                users.push(u);
                return u;
            }),
        },
        oAuthAccount: {
            findUnique: vi.fn(async ({ where, }) => {
                const w = where.provider_providerAccountId;
                const acc = oauthAccounts.find((a) => a.provider === w.provider && a.providerAccountId === w.providerAccountId);
                if (!acc)
                    return null;
                const user = users.find((u) => u.id === acc.userId) ?? null;
                return { ...acc, user };
            }),
            upsert: vi.fn(async ({ where, create }) => {
                const w = where.provider_providerAccountId;
                const existing = oauthAccounts.find((a) => a.provider === w.provider && a.providerAccountId === w.providerAccountId);
                if (existing)
                    return existing;
                oauthAccounts.push({
                    provider: w.provider,
                    providerAccountId: w.providerAccountId,
                    userId: create.userId,
                });
                return oauthAccounts[oauthAccounts.length - 1];
            }),
        },
        session: {
            create: vi.fn(async ({ data }) => {
                const s = { id: `s_${sessions.length + 1}`, ...data };
                sessions.push(s);
                return s;
            }),
        },
    };
}
describe('handleOAuthCallback', () => {
    const PROVIDERS = { google: PROVIDER_CONFIG };
    const originalFetch = globalThis.fetch;
    const originalEncryptionKey = process.env.CMS_ENCRYPTION_KEY;
    beforeEach(() => {
        // Set a 64-hex-char encryption key so encryptSecret() doesn't throw.
        process.env.CMS_ENCRYPTION_KEY = 'a'.repeat(64);
    });
    afterEach(() => {
        globalThis.fetch = originalFetch;
        if (originalEncryptionKey === undefined) {
            delete process.env.CMS_ENCRYPTION_KEY;
        }
        else {
            process.env.CMS_ENCRYPTION_KEY = originalEncryptionKey;
        }
        vi.restoreAllMocks();
    });
    function mockProviderResponses(opts) {
        globalThis.fetch = vi.fn(async (input) => {
            const url = typeof input === 'string' ? input : (input.url ?? input.toString());
            if (url.includes('oauth2.googleapis.com/token')) {
                return new Response(JSON.stringify({ access_token: opts.accessToken ?? 'access-tok', token_type: 'Bearer' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
            }
            if (url.includes('googleapis.com/oauth2/v3/userinfo')) {
                return new Response(JSON.stringify({
                    sub: opts.profile.id,
                    email: opts.profile.email,
                    name: opts.profile.name,
                }), { status: 200, headers: { 'Content-Type': 'application/json' } });
            }
            throw new Error(`Unexpected fetch in test: ${url}`);
        });
    }
    it('rejects when the state.provider does not match the URL provider', async () => {
        const state = await generateState('github', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
        const db = createFakeDb();
        await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db)).rejects.toThrow(/Provider mismatch/);
    });
    it('rejects when the nonce in state does not match the cookie nonce', async () => {
        const nonce = generateOAuthNonce();
        const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET, nonce);
        const db = createFakeDb();
        await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db, {
            expectedNonce: 'a-different-nonce',
        })).rejects.toThrow(/nonce mismatch/i);
    });
    it('rejects when state has a nonce but no cookie nonce was provided', async () => {
        const nonce = generateOAuthNonce();
        const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET, nonce);
        const db = createFakeDb();
        await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db, {
            expectedNonce: null,
        })).rejects.toThrow(/nonce mismatch/i);
    });
    it('rejects when the provider is not configured', async () => {
        const state = await generateState('github', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
        const db = createFakeDb();
        await expect(handleOAuthCallback('github', 'code-x', state, PROVIDERS, TEST_SECRET, db)).rejects.toThrow(/not configured/);
    });
    it('rejects when the OAuth provider returns no email', async () => {
        mockProviderResponses({ profile: { id: '111', email: '', name: 'Nobody' } });
        const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
        const db = createFakeDb();
        await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db)).rejects.toThrow(/email/);
    });
    it('rejects self-signup when allowSelfSignup is false (default)', async () => {
        mockProviderResponses({ profile: { id: '222', email: 'new@example.com', name: 'New User' } });
        const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
        const db = createFakeDb();
        await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db)).rejects.toThrow(/No account found/);
        expect(db.user.create).not.toHaveBeenCalled();
    });
    it('refuses to silently link an OAuth login to a password-protected account', async () => {
        mockProviderResponses({
            profile: { id: '333', email: 'pwd@example.com', name: 'Pwd User' },
        });
        const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
        const db = createFakeDb({
            users: [
                {
                    id: 'u_existing',
                    email: 'pwd@example.com',
                    name: 'Pwd User',
                    role: 'CLIENT',
                    passwordHash: 'pbkdf2:600000:abc:def',
                },
            ],
        });
        await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db)).rejects.toThrow(/already exists/);
    });
    it('reuses an existing OAuth-only account (passwordHash null) when emails match', async () => {
        mockProviderResponses({
            profile: { id: '444', email: 'oauth@example.com', name: 'OAuth User' },
        });
        const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
        const db = createFakeDb({
            users: [
                {
                    id: 'u_oauth_only',
                    email: 'oauth@example.com',
                    name: 'OAuth User',
                    role: 'CLIENT',
                    passwordHash: null,
                },
            ],
        });
        const result = await handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db);
        expect(result.user.id).toBe('u_oauth_only');
        expect(db.user.create).not.toHaveBeenCalled();
        expect(db.session.create).toHaveBeenCalledOnce();
    });
    it('provisions a new user when allowSelfSignup is true and no account exists', async () => {
        mockProviderResponses({
            profile: { id: '555', email: 'fresh@example.com', name: 'Fresh User' },
        });
        const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
        const db = createFakeDb();
        const onProvision = vi.fn();
        const result = await handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db, { allowSelfSignup: true, onProvision });
        expect(onProvision).toHaveBeenCalledOnce();
        expect(db.user.create).toHaveBeenCalledOnce();
        expect(db.user.create.mock.calls[0][0].data.passwordHash).toBeNull();
        expect(result.user.email).toBe('fresh@example.com');
        expect(result.user.role).toBe('CLIENT');
    });
    it('lets onProvision throw to reject self-signup', async () => {
        mockProviderResponses({
            profile: { id: '666', email: 'blocked@evil.com', name: 'Blocked' },
        });
        const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
        const db = createFakeDb();
        const onProvision = vi.fn(() => {
            throw new Error('Email domain not allowed');
        });
        await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db, {
            allowSelfSignup: true,
            onProvision,
        })).rejects.toThrow(/domain not allowed/);
        expect(db.user.create).not.toHaveBeenCalled();
    });
    it('returns an existing user found via OAuthAccount even if email differs', async () => {
        mockProviderResponses({
            profile: { id: '777', email: 'now@example.com', name: 'Linked User' },
        });
        const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
        const db = createFakeDb({
            users: [
                {
                    id: 'u_linked',
                    email: 'old@example.com', // different from current OAuth email
                    name: 'Linked',
                    role: 'ADMIN',
                    passwordHash: 'pbkdf2:600000:a:b',
                },
            ],
            oauthAccounts: [{ provider: 'google', providerAccountId: '777', userId: 'u_linked' }],
        });
        const result = await handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db);
        // Even though the email matches a *different* user, the existing
        // OAuthAccount link wins and we return the linked user.
        expect(result.user.id).toBe('u_linked');
        expect(result.user.role).toBe('ADMIN');
    });
    it('creates a session row for the authenticated user', async () => {
        mockProviderResponses({
            profile: { id: '888', email: 'session@example.com', name: 'Session User' },
        });
        const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
        const db = createFakeDb({
            users: [
                {
                    id: 'u_sess',
                    email: 'session@example.com',
                    name: 'Session',
                    role: 'CLIENT',
                    passwordHash: null,
                },
            ],
        });
        await handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db);
        expect(db.session.create).toHaveBeenCalledOnce();
        const call = db.session.create.mock.calls[0][0];
        expect(call.data.userId).toBe('u_sess');
        // Default 7-day expiry — verify the date is in the future
        expect(call.data.expiresAt.getTime()).toBeGreaterThan(Date.now());
    });
});
//# sourceMappingURL=oauth.test.js.map