otp: add entity config, reduce result

This commit is contained in:
dswbx
2025-11-06 20:38:16 +01:00
parent ff86240b0e
commit 8b36985252
2 changed files with 104 additions and 22 deletions

View File

@@ -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 { otp } from "./otp.plugin";
import { createApp } from "core/test/utils"; import { createApp } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("otp plugin", () => { describe("otp plugin", () => {
test("should not work if auth is not enabled", async () => { test("should not work if auth is not enabled", async () => {
@@ -20,6 +24,42 @@ describe("otp plugin", () => {
expect(res.status).toBe(404); 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 () => { test("should generate a token", async () => {
const called = mock(() => null); const called = mock(() => null);
const app = createApp({ const app = createApp({
@@ -53,17 +93,23 @@ describe("otp plugin", () => {
body: JSON.stringify({ email: "test@test.com" }), body: JSON.stringify({ email: "test@test.com" }),
}); });
expect(res.status).toBe(201); expect(res.status).toBe(201);
const data = (await res.json()) as any; expect(await res.json()).toEqual({ sent: true, action: "login" } as any);
expect(data.data.code).toBeDefined();
expect(data.data.code.length).toBe(6); const { data } = await app
expect(data.data.code.split("").every((char: string) => Number.isInteger(Number(char)))).toBe( .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, true,
); );
expect(data.data.email).toBe("test@test.com"); expect(data?.email).toBe("test@test.com");
expect(called).toHaveBeenCalled(); expect(called).toHaveBeenCalled();
}); });
test("should login with a code", async () => { test("should login with a code", async () => {
let code = "";
const app = createApp({ const app = createApp({
config: { config: {
auth: { auth: {
@@ -74,10 +120,18 @@ describe("otp plugin", () => {
}, },
}, },
options: { options: {
plugins: [otp({ showActualErrors: true })], plugins: [
otp({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: { drivers: {
email: { email: {
send: async () => {}, send: async (to, subject, body) => {
expect(to).toBe("test@test.com");
code = String(body);
},
}, },
}, },
seed: async (ctx) => { seed: async (ctx) => {
@@ -87,14 +141,13 @@ describe("otp plugin", () => {
}); });
await app.build(); await app.build();
const res = await app.server.request("/api/auth/otp/login", { await app.server.request("/api/auth/otp/login", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ email: "test@test.com" }), body: JSON.stringify({ email: "test@test.com" }),
}); });
const data = (await res.json()) as any;
{ {
const res = await app.server.request("/api/auth/otp/login", { const res = await app.server.request("/api/auth/otp/login", {
@@ -102,7 +155,7 @@ describe("otp plugin", () => {
headers: { headers: {
"Content-Type": "application/json", "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.status).toBe(200);
expect(res.headers.get("set-cookie")).toBeDefined(); expect(res.headers.get("set-cookie")).toBeDefined();
@@ -113,6 +166,8 @@ describe("otp plugin", () => {
}); });
test("should register with a code", async () => { test("should register with a code", async () => {
let code = "";
const app = createApp({ const app = createApp({
config: { config: {
auth: { auth: {
@@ -123,10 +178,18 @@ describe("otp plugin", () => {
}, },
}, },
options: { options: {
plugins: [otp({ showActualErrors: true })], plugins: [
otp({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: { drivers: {
email: { 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" }), 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", { const res = await app.server.request("/api/auth/otp/register", {
@@ -148,7 +211,7 @@ describe("otp plugin", () => {
headers: { headers: {
"Content-Type": "application/json", "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.status).toBe(200);
expect(res.headers.get("set-cookie")).toBeDefined(); expect(res.headers.get("set-cookie")).toBeDefined();
@@ -157,4 +220,8 @@ describe("otp plugin", () => {
expect(userData.token).toBeDefined(); expect(userData.token).toBeDefined();
} }
}); });
// @todo: test invalid codes
// @todo: test codes with different actions
// @todo: test code expiration
}); });

View File

@@ -10,6 +10,7 @@ import {
type DB, type DB,
type FieldSchema, type FieldSchema,
type MaybePromise, type MaybePromise,
type EntityConfig,
} from "bknd"; } from "bknd";
import { invariant, s, jsc, HttpStatus, threwAsync, randomString } from "bknd/utils"; import { invariant, s, jsc, HttpStatus, threwAsync, randomString } from "bknd/utils";
import { MutatorDeleteBefore, MutatorInsertBefore, MutatorUpdateBefore } from "data/events"; import { MutatorDeleteBefore, MutatorInsertBefore, MutatorUpdateBefore } from "data/events";
@@ -39,6 +40,11 @@ export type OtpPluginOptions = {
*/ */
entity?: string; entity?: string;
/**
* The config for the OTP entity.
*/
entityConfig?: EntityConfig;
/** /**
* Customize email content. If not provided, a default email will be sent. * Customize email content. If not provided, a default email will be sent.
*/ */
@@ -71,6 +77,7 @@ export function otp({
apiBasePath = "/api/auth/otp", apiBasePath = "/api/auth/otp",
ttl = 600, ttl = 600,
entity: entityName = "users_otp", entity: entityName = "users_otp",
entityConfig,
generateEmail: _generateEmail, generateEmail: _generateEmail,
showActualErrors = false, showActualErrors = false,
}: OtpPluginOptions = {}): AppPlugin { }: OtpPluginOptions = {}): AppPlugin {
@@ -80,7 +87,15 @@ export function otp({
schema: () => schema: () =>
em( em(
{ {
[entityName]: entity(entityName, otpFields), [entityName]: entity(
entityName,
otpFields,
entityConfig ?? {
name: "Users OTP",
sort_dir: "desc",
},
"generated",
),
}, },
({ index }, schema) => { ({ index }, schema) => {
const otp = schema[entityName]!; const otp = schema[entityName]!;
@@ -131,14 +146,14 @@ export function otp({
// @ts-expect-error private method // @ts-expect-error private method
return auth.authenticator.respondWithUser(c, { user, token: jwt }); return auth.authenticator.respondWithUser(c, { user, token: jwt });
} else { } else {
const otpData = await generateAndSendCode( await generateAndSendCode(
app, app,
{ generateCode, generateEmail, ttl, entity: entityName }, { generateCode, generateEmail, ttl, entity: entityName },
user, user,
"login", "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 // @ts-expect-error private method
return auth.authenticator.respondWithUser(c, { user, token: jwt }); return auth.authenticator.respondWithUser(c, { user, token: jwt });
} else { } else {
const otpData = await generateAndSendCode( await generateAndSendCode(
app, app,
{ generateCode, generateEmail, ttl, entity: entityName }, { generateCode, generateEmail, ttl, entity: entityName },
{ email }, { email },
"register", "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 err;
} }
throw new Exception("Invalid code", HttpStatus.BAD_REQUEST); throw new Exception("Invalid credentials", HttpStatus.BAD_REQUEST);
}); });
app.server.route(apiBasePath, hono); app.server.route(apiBasePath, hono);
@@ -282,7 +297,7 @@ async function invalidateAllUserCodes(app: App, entityName: string, email: strin
function registerListeners(app: App, entityName: string) { function registerListeners(app: App, entityName: string) {
app.emgr.onAny( app.emgr.onAny(
async (event) => { (event) => {
let allowed = true; let allowed = true;
let action = ""; let action = "";
if (event instanceof MutatorInsertBefore) { if (event instanceof MutatorInsertBefore) {