diff --git a/app/src/plugins/auth/otp.plugin.spec.ts b/app/src/plugins/auth/otp.plugin.spec.ts index ff8bd9b..2578ad2 100644 --- a/app/src/plugins/auth/otp.plugin.spec.ts +++ b/app/src/plugins/auth/otp.plugin.spec.ts @@ -1,6 +1,10 @@ -import { describe, expect, mock, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; import { otp } from "./otp.plugin"; import { createApp } from "core/test/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("otp plugin", () => { test("should not work if auth is not enabled", async () => { @@ -20,6 +24,42 @@ describe("otp plugin", () => { expect(res.status).toBe(404); }); + test("should prevent mutations of the OTP entity", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + drivers: { + email: { + send: async () => {}, + }, + }, + plugins: [otp({ showActualErrors: true })], + }, + }); + await app.build(); + + const payload = { + email: "test@test.com", + code: "123456", + action: "login", + created_at: new Date(), + expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24), + used_at: null, + }; + + expect(app.em.mutator("users_otp").insertOne(payload)).rejects.toThrow(); + expect( + await app + .getApi() + .data.createOne("users_otp", payload) + .then((r) => r.ok), + ).toBe(false); + }); + test("should generate a token", async () => { const called = mock(() => null); const app = createApp({ @@ -53,17 +93,23 @@ describe("otp plugin", () => { body: JSON.stringify({ email: "test@test.com" }), }); expect(res.status).toBe(201); - const data = (await res.json()) as any; - expect(data.data.code).toBeDefined(); - expect(data.data.code.length).toBe(6); - expect(data.data.code.split("").every((char: string) => Number.isInteger(Number(char)))).toBe( + expect(await res.json()).toEqual({ sent: true, action: "login" } as any); + + const { data } = await app + .getApi() + .data.readOneBy("users_otp", { where: { email: "test@test.com" } }); + expect(data?.code).toBeDefined(); + expect(data?.code?.length).toBe(6); + expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe( true, ); - expect(data.data.email).toBe("test@test.com"); + expect(data?.email).toBe("test@test.com"); expect(called).toHaveBeenCalled(); }); test("should login with a code", async () => { + let code = ""; + const app = createApp({ config: { auth: { @@ -74,10 +120,18 @@ describe("otp plugin", () => { }, }, options: { - plugins: [otp({ showActualErrors: true })], + plugins: [ + otp({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], drivers: { email: { - send: async () => {}, + send: async (to, subject, body) => { + expect(to).toBe("test@test.com"); + code = String(body); + }, }, }, seed: async (ctx) => { @@ -87,14 +141,13 @@ describe("otp plugin", () => { }); await app.build(); - const res = await app.server.request("/api/auth/otp/login", { + await app.server.request("/api/auth/otp/login", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email: "test@test.com" }), }); - const data = (await res.json()) as any; { const res = await app.server.request("/api/auth/otp/login", { @@ -102,7 +155,7 @@ describe("otp plugin", () => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ email: "test@test.com", code: data.data.code }), + body: JSON.stringify({ email: "test@test.com", code }), }); expect(res.status).toBe(200); expect(res.headers.get("set-cookie")).toBeDefined(); @@ -113,6 +166,8 @@ describe("otp plugin", () => { }); test("should register with a code", async () => { + let code = ""; + const app = createApp({ config: { auth: { @@ -123,10 +178,18 @@ describe("otp plugin", () => { }, }, options: { - plugins: [otp({ showActualErrors: true })], + plugins: [ + otp({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], drivers: { email: { - send: async () => {}, + send: async (to, subject, body) => { + expect(to).toBe("test@test.com"); + code = String(body); + }, }, }, }, @@ -140,7 +203,7 @@ describe("otp plugin", () => { }, body: JSON.stringify({ email: "test@test.com" }), }); - const data = (await res.json()) as any; + expect(await res.json()).toEqual({ sent: true, action: "register" } as any); { const res = await app.server.request("/api/auth/otp/register", { @@ -148,7 +211,7 @@ describe("otp plugin", () => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ email: "test@test.com", code: data.data.code }), + body: JSON.stringify({ email: "test@test.com", code }), }); expect(res.status).toBe(200); expect(res.headers.get("set-cookie")).toBeDefined(); @@ -157,4 +220,8 @@ describe("otp plugin", () => { expect(userData.token).toBeDefined(); } }); + + // @todo: test invalid codes + // @todo: test codes with different actions + // @todo: test code expiration }); diff --git a/app/src/plugins/auth/otp.plugin.ts b/app/src/plugins/auth/otp.plugin.ts index 133541a..e456456 100644 --- a/app/src/plugins/auth/otp.plugin.ts +++ b/app/src/plugins/auth/otp.plugin.ts @@ -10,6 +10,7 @@ import { type DB, type FieldSchema, type MaybePromise, + type EntityConfig, } from "bknd"; import { invariant, s, jsc, HttpStatus, threwAsync, randomString } from "bknd/utils"; import { MutatorDeleteBefore, MutatorInsertBefore, MutatorUpdateBefore } from "data/events"; @@ -39,6 +40,11 @@ export type OtpPluginOptions = { */ entity?: string; + /** + * The config for the OTP entity. + */ + entityConfig?: EntityConfig; + /** * Customize email content. If not provided, a default email will be sent. */ @@ -71,6 +77,7 @@ export function otp({ apiBasePath = "/api/auth/otp", ttl = 600, entity: entityName = "users_otp", + entityConfig, generateEmail: _generateEmail, showActualErrors = false, }: OtpPluginOptions = {}): AppPlugin { @@ -80,7 +87,15 @@ export function otp({ schema: () => em( { - [entityName]: entity(entityName, otpFields), + [entityName]: entity( + entityName, + otpFields, + entityConfig ?? { + name: "Users OTP", + sort_dir: "desc", + }, + "generated", + ), }, ({ index }, schema) => { const otp = schema[entityName]!; @@ -131,14 +146,14 @@ export function otp({ // @ts-expect-error private method return auth.authenticator.respondWithUser(c, { user, token: jwt }); } else { - const otpData = await generateAndSendCode( + await generateAndSendCode( app, { generateCode, generateEmail, ttl, entity: entityName }, user, "login", ); - return c.json({ data: otpData }, HttpStatus.CREATED); + return c.json({ sent: true, action: "login" }, HttpStatus.CREATED); } }, ) @@ -178,14 +193,14 @@ export function otp({ // @ts-expect-error private method return auth.authenticator.respondWithUser(c, { user, token: jwt }); } else { - const otpData = await generateAndSendCode( + await generateAndSendCode( app, { generateCode, generateEmail, ttl, entity: entityName }, { email }, "register", ); - return c.json({ data: otpData }, HttpStatus.CREATED); + return c.json({ sent: true, action: "register" }, HttpStatus.CREATED); } }, ) @@ -194,7 +209,7 @@ export function otp({ throw err; } - throw new Exception("Invalid code", HttpStatus.BAD_REQUEST); + throw new Exception("Invalid credentials", HttpStatus.BAD_REQUEST); }); app.server.route(apiBasePath, hono); @@ -282,7 +297,7 @@ async function invalidateAllUserCodes(app: App, entityName: string, email: strin function registerListeners(app: App, entityName: string) { app.emgr.onAny( - async (event) => { + (event) => { let allowed = true; let action = ""; if (event instanceof MutatorInsertBefore) {