import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { generateResetToken, hashToken, createPasswordReset, executePasswordReset, } from '../../auth/reset.js';
import { verifyPassword } from '../../auth/password.js';
import { setEmailSender } from '../../email/sender.js';
function createFakeDb(initial = {}) {
    const users = initial.users ?? [];
    const tokens = [];
    const sessions = initial.sessions ?? [];
    const emailsSent = [];
    const db = {
        users,
        tokens,
        sessions,
        emailsSent,
        user: {
            findFirst: vi.fn(async ({ where }) => {
                const target = where.email.equals.toLowerCase();
                return users.find((u) => u.email.toLowerCase() === target) ?? null;
            }),
            update: vi.fn(async ({ where, data }) => {
                const u = users.find((u) => u.id === where.id);
                if (u)
                    Object.assign(u, data);
                return u;
            }),
        },
        passwordResetToken: {
            findFirst: vi.fn(async ({ where }) => {
                return (tokens.find((t) => t.tokenHash === where.tokenHash && t.usedAt === where.usedAt) ?? null);
            }),
            create: vi.fn(async ({ data }) => {
                const user = users.find((u) => u.id === data.userId);
                const t = {
                    id: `rt_${tokens.length + 1}`,
                    userId: data.userId,
                    tokenHash: data.tokenHash,
                    expiresAt: data.expiresAt,
                    usedAt: null,
                    user,
                };
                tokens.push(t);
                return t;
            }),
            update: vi.fn(async ({ where, data }) => {
                const t = tokens.find((t) => t.id === where.id);
                if (t)
                    Object.assign(t, data);
                return t;
            }),
            updateMany: vi.fn(async ({ where, data }) => {
                let count = 0;
                for (const t of tokens) {
                    if (t.userId === where.userId && t.usedAt === where.usedAt) {
                        Object.assign(t, data);
                        count++;
                    }
                }
                return { count };
            }),
        },
        session: {
            updateMany: vi.fn(async ({ where, data }) => {
                let count = 0;
                for (const s of sessions) {
                    if (s.userId === where.userId && s.revokedAt === where.revokedAt) {
                        Object.assign(s, data);
                        count++;
                    }
                }
                return { count };
            }),
        },
        $transaction: vi.fn(async (ops) => Promise.all(ops)),
    };
    return db;
}
const TEST_CONFIG = {
    siteUrl: 'https://example.com',
    platform: {
        email: {
            send: vi.fn(),
        },
    },
};
beforeEach(() => {
    TEST_CONFIG.platform.email.send.mockClear();
});
// ─── Token generation ───────────────────────────────────────────────────
describe('generateResetToken', () => {
    it('returns a 64-char hex raw token (32 random bytes)', () => {
        const t = generateResetToken();
        expect(t.raw).toMatch(/^[0-9a-f]{64}$/);
    });
    it('returns a SHA-256 hash of the raw token', () => {
        const t = generateResetToken();
        expect(t.hash).toBe(hashToken(t.raw));
        expect(t.hash).toMatch(/^[0-9a-f]{64}$/);
    });
    it('produces unique tokens (≥ 100 entropy bits)', () => {
        const set = new Set(Array.from({ length: 200 }, () => generateResetToken().raw));
        expect(set.size).toBe(200);
    });
});
// ─── createPasswordReset (request flow) ─────────────────────────────────
describe('createPasswordReset', () => {
    it('silently succeeds for an unknown email (user enumeration defence)', async () => {
        const db = createFakeDb();
        await expect(createPasswordReset(db, 'unknown@example.com', TEST_CONFIG)).resolves.toBeUndefined();
        expect(db.passwordResetToken.create).not.toHaveBeenCalled();
        expect(TEST_CONFIG.platform.email.send).not.toHaveBeenCalled();
    });
    it('silently succeeds for a deactivated user (no token, no email)', async () => {
        const db = createFakeDb({
            users: [
                {
                    id: 'u_off',
                    email: 'off@example.com',
                    name: 'Off',
                    isActive: false,
                    passwordHash: 'pbkdf2:600000:a:b',
                },
            ],
        });
        await createPasswordReset(db, 'off@example.com', TEST_CONFIG);
        expect(db.passwordResetToken.create).not.toHaveBeenCalled();
        expect(TEST_CONFIG.platform.email.send).not.toHaveBeenCalled();
    });
    it('creates a token, expires prior outstanding tokens, and sends an email', async () => {
        const db = createFakeDb({
            users: [
                {
                    id: 'u_1',
                    email: 'a@example.com',
                    name: 'A',
                    isActive: true,
                    passwordHash: 'pbkdf2:600000:a:b',
                },
            ],
        });
        await createPasswordReset(db, 'a@example.com', TEST_CONFIG);
        expect(db.passwordResetToken.updateMany).toHaveBeenCalledWith({
            where: { userId: 'u_1', usedAt: null },
            data: { usedAt: expect.any(Date) },
        });
        expect(db.passwordResetToken.create).toHaveBeenCalledOnce();
        expect(TEST_CONFIG.platform.email.send).toHaveBeenCalledOnce();
        const tokenRecord = db.tokens[0];
        // Stored value is the hash, not the raw token
        expect(tokenRecord.tokenHash).toMatch(/^[0-9a-f]{64}$/);
        // 1 hour expiry, give or take a few seconds
        const ttlMs = tokenRecord.expiresAt.getTime() - Date.now();
        expect(ttlMs).toBeGreaterThan(59 * 60 * 1000);
        expect(ttlMs).toBeLessThanOrEqual(60 * 60 * 1000 + 5_000);
    });
    it('puts a fresh raw token into the email URL (not the hash)', async () => {
        const db = createFakeDb({
            users: [
                {
                    id: 'u_1',
                    email: 'a@example.com',
                    name: 'A',
                    isActive: true,
                    passwordHash: null,
                },
            ],
        });
        await createPasswordReset(db, 'A@Example.com  ', TEST_CONFIG);
        const sent = TEST_CONFIG.platform.email.send.mock.calls[0][0];
        const tokenInUrl = sent.html.match(/token=([0-9a-f]{64})/)?.[1];
        expect(tokenInUrl).toBeDefined();
        expect(hashToken(tokenInUrl)).toBe(db.tokens[0].tokenHash);
    });
    it('normalises email casing/whitespace before lookup', async () => {
        const db = createFakeDb({
            users: [
                {
                    id: 'u_1',
                    email: 'a@example.com',
                    name: 'A',
                    isActive: true,
                    passwordHash: null,
                },
            ],
        });
        await createPasswordReset(db, '  A@Example.COM ', TEST_CONFIG);
        expect(db.passwordResetToken.create).toHaveBeenCalledOnce();
    });
    it('does nothing when the email is empty / whitespace-only', async () => {
        const db = createFakeDb();
        await createPasswordReset(db, '   ', TEST_CONFIG);
        expect(db.user.findFirst).not.toHaveBeenCalled();
    });
    it('skips email send gracefully when no email adapter is configured', async () => {
        const db = createFakeDb({
            users: [
                {
                    id: 'u_1',
                    email: 'a@example.com',
                    name: 'A',
                    isActive: true,
                    passwordHash: null,
                },
            ],
        });
        await createPasswordReset(db, 'a@example.com', { siteUrl: 'https://e.com' });
        expect(db.passwordResetToken.create).toHaveBeenCalledOnce();
        expect(TEST_CONFIG.platform.email.send).not.toHaveBeenCalled();
    });
    it('builds the reset link from the configured admin path (not a hardcoded /admin)', async () => {
        const db = createFakeDb({
            users: [{ id: 'u_1', email: 'a@example.com', name: 'A', isActive: true, passwordHash: null }],
        });
        await createPasswordReset(db, 'a@example.com', {
            siteUrl: 'https://example.com/',
            adminPath: '/securelogin',
            platform: TEST_CONFIG.platform,
        });
        const sent = TEST_CONFIG.platform.email.send.mock.calls[0][0];
        expect(sent.html).toContain('https://example.com/securelogin/reset-password?token=');
        expect(sent.html).not.toContain('//securelogin');
    });
});
// ─── Registered email sender fallback ───────────────────────────────────
//
// Regression: createPasswordReset previously only sent when an explicit
// `platform.email` adapter was passed. Apps that configure email purely via
// `emailPlugin()` (the common case) register a sender through the core email
// registry instead, so the reset flow must fall back to it.
describe('createPasswordReset — registered sender fallback', () => {
    afterEach(() => {
        setEmailSender(null);
    });
    it('sends via the registered sender when no platform adapter is passed', async () => {
        const send = vi.fn(async (_o) => { });
        setEmailSender({ send });
        const db = createFakeDb({
            users: [{ id: 'u_1', email: 'a@example.com', name: 'A', isActive: true, passwordHash: null }],
        });
        await createPasswordReset(db, 'a@example.com', { siteUrl: 'https://e.com' });
        expect(db.passwordResetToken.create).toHaveBeenCalledOnce();
        expect(send).toHaveBeenCalledOnce();
        const sent = send.mock.calls[0][0];
        expect(sent.to).toBe('a@example.com');
        expect(sent.html).toContain('/admin/reset-password?token=');
    });
    it('prefers an explicit platform adapter over the registered sender', async () => {
        const registered = vi.fn(async (_o) => { });
        setEmailSender({ send: registered });
        const db = createFakeDb({
            users: [{ id: 'u_1', email: 'a@example.com', name: 'A', isActive: true, passwordHash: null }],
        });
        await createPasswordReset(db, 'a@example.com', {
            siteUrl: 'https://e.com',
            platform: TEST_CONFIG.platform,
        });
        expect(TEST_CONFIG.platform.email.send).toHaveBeenCalledOnce();
        expect(registered).not.toHaveBeenCalled();
    });
});
// ─── executePasswordReset (consume flow) ────────────────────────────────
describe('executePasswordReset', () => {
    function seedDbWithToken(opts = {}) {
        const raw = '0'.repeat(64);
        const db = createFakeDb({
            users: [
                {
                    id: 'u_1',
                    email: 'a@example.com',
                    name: 'A',
                    isActive: opts.isActive ?? true,
                    passwordHash: null,
                },
            ],
            sessions: [
                { id: 's_active', userId: 'u_1', revokedAt: null },
                { id: 's_other', userId: 'u_1', revokedAt: null },
            ],
        });
        const t = {
            id: 'rt_1',
            userId: 'u_1',
            tokenHash: hashToken(raw),
            expiresAt: opts.expiresAt ?? new Date(Date.now() + 30 * 60 * 1000),
            usedAt: opts.usedAt ?? null,
            user: db.users[0],
        };
        db.tokens.push(t);
        return { db, raw };
    }
    it('rejects an unknown token without leaking which user it belonged to', async () => {
        const { db } = seedDbWithToken();
        const result = await executePasswordReset(db, 'a-token-that-does-not-exist', 'P@ssword1234');
        expect(result.success).toBe(false);
        expect(result.error).toBe('Invalid or expired reset link.');
        expect(db.user.update).not.toHaveBeenCalled();
    });
    it('rejects an expired token', async () => {
        const { db, raw } = seedDbWithToken({
            expiresAt: new Date(Date.now() - 60 * 1000),
        });
        const result = await executePasswordReset(db, raw, 'P@ssword1234');
        expect(result.success).toBe(false);
        expect(result.error).toMatch(/expired/i);
        expect(db.user.update).not.toHaveBeenCalled();
    });
    it('rejects a token that has already been used (single-use)', async () => {
        const { db, raw } = seedDbWithToken({ usedAt: new Date(Date.now() - 1000) });
        const result = await executePasswordReset(db, raw, 'P@ssword1234');
        expect(result.success).toBe(false);
        // The "usedAt: null" filter means the token isn't found -> generic error.
        expect(result.error).toBe('Invalid or expired reset link.');
    });
    it('rejects when the underlying user is deactivated', async () => {
        const { db, raw } = seedDbWithToken({ isActive: false });
        const result = await executePasswordReset(db, raw, 'P@ssword1234');
        expect(result.success).toBe(false);
        expect(result.error).toMatch(/deactivated/i);
    });
    it('rejects passwords that do not meet the default policy', async () => {
        const { db, raw } = seedDbWithToken();
        const tooShort = await executePasswordReset(db, raw, 'short1A');
        expect(tooShort.success).toBe(false);
        const noUpper = await executePasswordReset(db, raw, 'lowercase-only-1234');
        expect(noUpper.success).toBe(false);
        const noNumber = await executePasswordReset(db, raw, 'NoNumbersAtAll!');
        expect(noNumber.success).toBe(false);
    });
    it('updates the password, marks the token used, and revokes all sessions atomically', async () => {
        const { db, raw } = seedDbWithToken();
        const result = await executePasswordReset(db, raw, 'F#reshPassword2026!');
        expect(result.success).toBe(true);
        expect(db.$transaction).toHaveBeenCalledOnce();
        // 1. Password hash was updated
        const user = db.users[0];
        expect(user.passwordHash).toBeTruthy();
        expect(user.passwordHash).toMatch(/^pbkdf2:/);
        // 2. New password actually verifies against the stored hash
        expect(await verifyPassword('F#reshPassword2026!', user.passwordHash)).toBe(true);
        // 3. Token is marked used (single-use enforced for replay attempts)
        const t = db.tokens[0];
        expect(t.usedAt).toBeInstanceOf(Date);
        // 4. All non-revoked sessions revoked
        expect(db.sessions.every((s) => s.revokedAt !== null)).toBe(true);
    });
    it('a second use of the same token after a successful reset is rejected', async () => {
        const { db, raw } = seedDbWithToken();
        const first = await executePasswordReset(db, raw, 'F#reshPassword2026!');
        expect(first.success).toBe(true);
        const second = await executePasswordReset(db, raw, 'AnotherV@lidPwd2026!');
        expect(second.success).toBe(false);
        expect(second.error).toBe('Invalid or expired reset link.');
    });
});
//# sourceMappingURL=reset.test.js.map