/** * otp() plugin — plug-time fail-loud, app-mode resend / sealed lifetime, provider-mode boot. */ import { afterEach, describe, expect, test } from "bun:test"; import { createVerificationStore, findActiveVerification, type VerificationStore, } from "../auth/verification.ts"; import { openConsoleChannel } from "../drivers/channel-console.ts"; import type { ChannelDriver, SmsOtpTransport, SmsTransport } from "../drivers/channel-types.ts"; import { oke } from "../kernel/app.ts"; import { resetFlowSeq } from "../kernel/flow.ts"; import { resetBindings } from "../kernel/on.ts"; import { otp } from "./otp.ts"; afterEach(() => { resetBindings(); resetFlowSeq(); }); const SECRET = "test-secret-at-least-16"; function jsonPost(path: string, body: unknown): Request { return new Request(`http://localhost${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); } function mockOtpSmsDriver(): ChannelDriver { const codes = new Map(); const otpTransport: SmsTransport & SmsOtpTransport = { provider: "mock-otp", async send() { return { messageId: "m", to: "+", status: "ok", response: "ok" }; }, async sendOtp(opts) { codes.set(opts.requestId, "pending"); return { requestId: opts.requestId, to: opts.to, code: 5, response: "ok", provider: "mock-otp", }; }, async verifyOtp(opts) { if (!codes.has(opts.requestId)) throw new Error("unknown request"); return { ok: true as const, code: 10, message: "ok", response: "ok", provider: "mock-otp" }; }, }; return { id: "taqnyat", smsTransport: otpTransport, channel: { provider: "mock-otp", mediums: ["sms"], async send() { return { ok: true, messageId: "m", driverId: "mock-otp", attempts: [{ driverId: "mock-otp", ok: true, at: Date.now() }], }; }, }, }; } describe("otp() plug-time fail-loud", () => { test("missing mode throws", () => { expect(() => otp({} as never)).toThrow(/mode is required/); }); test('mode "provider" with channels throws', () => { expect(() => otp({ mode: "provider", channels: ["sms"] } as never)).toThrow( /channels are forbidden/, ); }); test('mode "provider" with exposeDevOtp throws', () => { expect(() => otp({ mode: "provider", exposeDevOtp: true } as never)).toThrow( /exposeDevOtp is forbidden/, ); }); test('mode "app" without channels throws', () => { expect(() => otp({ mode: "app" } as never)).toThrow(/channels is required/); }); }); describe("otp() provider mode boot", () => { test("fails loud without Verify-capable SMS driver", async () => { const app = oke({ name: `otp-provider-${crypto.randomUUID()}`, env: "test", registry: "ignore", gate: { auth: { secret: SECRET } }, }).plug(otp({ mode: "provider" })); await expect(app.boot({ env: "test" })).rejects.toThrow(/mode: "provider"/); }); test("boots when Verify-capable SMS driver is bound", async () => { const app = oke({ name: `otp-provider-ok-${crypto.randomUUID()}`, env: "test", registry: "ignore", gate: { auth: { secret: SECRET } }, channel: { drivers: [mockOtpSmsDriver()] }, }).plug(otp({ mode: "provider" })); await app.boot({ env: "test" }); const res = await app.fetch(jsonPost("/auth/otp/request", { phone: "+15551234567" })); expect(res.status).toBe(200); const body = (await res.json()) as { data: { ok: true; devOtp?: string } }; expect(body.data.ok).toBe(true); expect(body.data.devOtp).toBeUndefined(); expect(app.router.match("POST", "/auth/otp/resend")).toBeTruthy(); await app.stop(); }); test("SMS resend issues a new request id, honors cooldown, and rejects another channel", async () => { let now = 1_000_000; const requestIds: string[] = []; const verifications = createVerificationStore(); const driver = mockOtpSmsDriver(); const transport = driver.smsTransport as unknown as SmsOtpTransport; const original = transport.sendOtp.bind(transport); transport.sendOtp = async (opts) => { requestIds.push(opts.requestId); return original(opts); }; const app = oke({ name: `otp-provider-resend-${crypto.randomUUID()}`, env: "test", registry: "ignore", gate: { auth: { secret: SECRET } }, channel: { drivers: [driver] }, fx: { now: () => now }, }).plug( otp({ mode: "provider", resendCooldownMs: 60_000, verifications, now: () => now, }), ); await app.boot({ env: "test" }); const phone = "+15551234567"; expect((await app.fetch(jsonPost("/auth/otp/request", { phone }))).status).toBe(200); const first = findActiveVerification(verifications, `otp:${phone}`, now); expect(first?.value.startsWith("provider:")).toBe(true); expect(first?.sealedOtp).toBeNull(); const firstId = first?.value.slice("provider:".length); const cooled = await app.fetch(jsonPost("/auth/otp/resend", { phone })); const cooledBody = (await cooled.json()) as { error?: { data?: { reason?: string } } }; expect(cooledBody.error?.data?.reason).toBe("resend_cooldown"); const other = await app.fetch(jsonPost("/auth/otp/resend", { phone, channel: "email" })); const otherBody = (await other.json()) as { error?: { data?: { reason?: string } } }; expect(otherBody.error?.data?.reason).toBe("invalid_channel"); now += 60_001; const resent = await app.fetch(jsonPost("/auth/otp/resend", { phone, channel: "sms" })); expect(resent.status).toBe(200); const resentBody = (await resent.json()) as { data: { channel: string; devOtp?: string } }; expect(resentBody.data.channel).toBe("sms"); expect(resentBody.data.devOtp).toBeUndefined(); const second = findActiveVerification(verifications, `otp:${phone}`, now); const secondId = second?.value.slice("provider:".length); expect(secondId).toBeDefined(); expect(secondId).not.toBe(firstId); if (firstId === undefined || secondId === undefined) { throw new Error("expected provider request ids"); } expect(requestIds).toEqual([firstId, secondId]); expect(findActiveVerification(verifications, `otp:${phone}`, now)?.sealedOtp).toBeNull(); await app.stop(); }); }); describe("otp() app mode resend + sealed lifetime", () => { test("cross-channel resend keeps same code; cooldown then verify wipes seal", async () => { let now = 1_000_000; const verifications: VerificationStore = createVerificationStore(); const app = oke({ name: `otp-app-${crypto.randomUUID()}`, env: "test", registry: "ignore", gate: { auth: { secret: SECRET } }, channel: { drivers: [openConsoleChannel()] }, fx: { now: () => now }, }).plug( otp({ mode: "app", channels: ["sms", "email"], exposeDevOtp: true, resendCooldownMs: 60_000, verifications, now: () => now, secret: SECRET, }), ); await app.boot({ env: "test" }); const req = await app.fetch( jsonPost("/auth/otp/request", { phone: "+15551234567", email: "a@example.com", }), ); expect(req.status).toBe(200); const requested = (await req.json()) as { data: { ok: true; devOtp: string; channel: string }; }; expect(requested.data.devOtp).toMatch(/^\d{6}$/); const code = requested.data.devOtp; const expiresAt = findActiveVerification(verifications, "otp:+15551234567", now)?.expiresAt; expect(expiresAt).toBeDefined(); expect(findActiveVerification(verifications, "otp:+15551234567", now)?.sealedOtp).toBeTruthy(); const cool = await app.fetch( jsonPost("/auth/otp/resend", { phone: "+15551234567", email: "a@example.com", channel: "email", }), ); const coolBody = (await cool.json()) as { error?: { data?: { reason?: string } } }; expect(coolBody.error?.data?.reason).toBe("resend_cooldown"); now += 60_001; const resent = await app.fetch( jsonPost("/auth/otp/resend", { phone: "+15551234567", email: "a@example.com", channel: "email", }), ); expect(resent.status).toBe(200); const resentBody = (await resent.json()) as { data: { channel: string; devOtp?: string } }; expect(resentBody.data.channel).toBe("email"); expect(resentBody.data.devOtp).toBe(code); expect(findActiveVerification(verifications, "otp:+15551234567", now)?.expiresAt).toBe( expiresAt, ); const verify = await app.fetch( jsonPost("/auth/otp/verify", { phone: "+15551234567", otp: code }), ); expect(verify.status).toBe(200); const row = [...verifications.rows.values()].find((r) => r.identifier === "otp:+15551234567"); expect(row?.sealedOtp).toBeNull(); expect(row?.consumedAt).toBeTruthy(); await app.stop(); }); test("expired challenge wipes sealed OTP", async () => { let now = 1_000_000; const verifications = createVerificationStore(); const app = oke({ name: `otp-ttl-${crypto.randomUUID()}`, env: "test", registry: "ignore", gate: { auth: { secret: SECRET } }, fx: { now: () => now }, }).plug( otp({ mode: "app", channels: ["email"], exposeDevOtp: true, ttlMs: 1_000, verifications, now: () => now, secret: SECRET, }), ); await app.boot({ env: "test" }); await app.fetch(jsonPost("/auth/otp/request", { email: "ttl@example.com" })); const active = findActiveVerification(verifications, "otp:ttl@example.com", now); expect(active?.sealedOtp).toBeTruthy(); now += 2_000; expect(findActiveVerification(verifications, "otp:ttl@example.com", now)).toBeUndefined(); const row = [...verifications.rows.values()].find( (r) => r.identifier === "otp:ttl@example.com", ); expect(row?.sealedOtp).toBeNull(); await app.stop(); }); });