@flink-app/otp-auth-plugin 0.12.1-alpha.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +879 -0
  3. package/dist/OtpAuthPlugin.d.ts +64 -0
  4. package/dist/OtpAuthPlugin.js +231 -0
  5. package/dist/OtpAuthPluginContext.d.ts +10 -0
  6. package/dist/OtpAuthPluginContext.js +2 -0
  7. package/dist/OtpAuthPluginOptions.d.ts +112 -0
  8. package/dist/OtpAuthPluginOptions.js +2 -0
  9. package/dist/OtpInternalContext.d.ts +11 -0
  10. package/dist/OtpInternalContext.js +2 -0
  11. package/dist/functions/initiate.d.ts +18 -0
  12. package/dist/functions/initiate.js +104 -0
  13. package/dist/functions/verify.d.ts +20 -0
  14. package/dist/functions/verify.js +142 -0
  15. package/dist/handlers/PostOtpInitiate.d.ts +7 -0
  16. package/dist/handlers/PostOtpInitiate.js +70 -0
  17. package/dist/handlers/PostOtpVerify.d.ts +7 -0
  18. package/dist/handlers/PostOtpVerify.js +86 -0
  19. package/dist/index.d.ts +11 -0
  20. package/dist/index.js +24 -0
  21. package/dist/repos/OtpSessionRepo.d.ts +13 -0
  22. package/dist/repos/OtpSessionRepo.js +145 -0
  23. package/dist/schemas/InitiateRequest.d.ts +8 -0
  24. package/dist/schemas/InitiateRequest.js +2 -0
  25. package/dist/schemas/InitiateResponse.d.ts +8 -0
  26. package/dist/schemas/InitiateResponse.js +2 -0
  27. package/dist/schemas/OtpSession.d.ts +25 -0
  28. package/dist/schemas/OtpSession.js +2 -0
  29. package/dist/schemas/VerifyRequest.d.ts +6 -0
  30. package/dist/schemas/VerifyRequest.js +2 -0
  31. package/dist/schemas/VerifyResponse.d.ts +12 -0
  32. package/dist/schemas/VerifyResponse.js +2 -0
  33. package/dist/utils/otp-utils.d.ts +43 -0
  34. package/dist/utils/otp-utils.js +95 -0
  35. package/examples/basic-usage.ts +145 -0
  36. package/package.json +37 -0
  37. package/spec/OtpAuthPlugin.spec.ts +159 -0
  38. package/spec/OtpSessionRepo.spec.ts +194 -0
  39. package/spec/otp-utils.spec.ts +172 -0
  40. package/spec/support/jasmine.json +7 -0
  41. package/src/OtpAuthPlugin.ts +163 -0
  42. package/src/OtpAuthPluginContext.ts +11 -0
  43. package/src/OtpAuthPluginOptions.ts +135 -0
  44. package/src/OtpInternalContext.ts +12 -0
  45. package/src/functions/initiate.ts +86 -0
  46. package/src/functions/verify.ts +123 -0
  47. package/src/handlers/PostOtpInitiate.ts +28 -0
  48. package/src/handlers/PostOtpVerify.ts +42 -0
  49. package/src/index.ts +17 -0
  50. package/src/repos/OtpSessionRepo.ts +47 -0
  51. package/src/schemas/InitiateRequest.ts +8 -0
  52. package/src/schemas/InitiateResponse.ts +8 -0
  53. package/src/schemas/OtpSession.ts +25 -0
  54. package/src/schemas/VerifyRequest.ts +6 -0
  55. package/src/schemas/VerifyResponse.ts +12 -0
  56. package/src/utils/otp-utils.ts +89 -0
  57. package/tsconfig.dist.json +4 -0
  58. package/tsconfig.json +24 -0
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.normalizeIdentifier = exports.normalizeEmail = exports.normalizePhoneNumber = exports.validateIdentifier = exports.generateSessionId = exports.generateOtpCode = void 0;
7
+ var crypto_1 = __importDefault(require("crypto"));
8
+ /**
9
+ * Generates a random numeric OTP code.
10
+ *
11
+ * @param length - Number of digits (4-8)
12
+ * @returns A numeric string of the specified length
13
+ */
14
+ function generateOtpCode(length) {
15
+ if (length === void 0) { length = 6; }
16
+ if (length < 4 || length > 8) {
17
+ throw new Error("OTP code length must be between 4 and 8 digits");
18
+ }
19
+ // Generate a random number with the specified number of digits
20
+ var min = Math.pow(10, length - 1);
21
+ var max = Math.pow(10, length) - 1;
22
+ // Use crypto.randomInt for cryptographically secure random numbers
23
+ return crypto_1.default.randomInt(min, max + 1).toString();
24
+ }
25
+ exports.generateOtpCode = generateOtpCode;
26
+ /**
27
+ * Generates a unique session ID.
28
+ *
29
+ * @returns A cryptographically secure random hex string
30
+ */
31
+ function generateSessionId() {
32
+ return crypto_1.default.randomBytes(16).toString("hex");
33
+ }
34
+ exports.generateSessionId = generateSessionId;
35
+ /**
36
+ * Validates that an identifier looks like a valid phone number or email.
37
+ *
38
+ * @param identifier - The identifier to validate
39
+ * @param method - The expected delivery method
40
+ * @returns true if valid, false otherwise
41
+ */
42
+ function validateIdentifier(identifier, method) {
43
+ if (!identifier || typeof identifier !== "string") {
44
+ return false;
45
+ }
46
+ if (method === "email") {
47
+ // Basic email validation
48
+ var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
49
+ return emailRegex.test(identifier);
50
+ }
51
+ else {
52
+ // Phone number validation (allow various formats)
53
+ // This accepts: digits, spaces, dashes, parentheses, and + prefix
54
+ var phoneRegex = /^\+?[\d\s\-()]{8,20}$/;
55
+ return phoneRegex.test(identifier);
56
+ }
57
+ }
58
+ exports.validateIdentifier = validateIdentifier;
59
+ /**
60
+ * Normalizes a phone number by removing formatting characters.
61
+ *
62
+ * @param phoneNumber - The phone number to normalize
63
+ * @returns Normalized phone number (digits and + only)
64
+ */
65
+ function normalizePhoneNumber(phoneNumber) {
66
+ // Remove all characters except digits and +
67
+ return phoneNumber.replace(/[^\d+]/g, "");
68
+ }
69
+ exports.normalizePhoneNumber = normalizePhoneNumber;
70
+ /**
71
+ * Normalizes an email by converting to lowercase and trimming.
72
+ *
73
+ * @param email - The email to normalize
74
+ * @returns Normalized email
75
+ */
76
+ function normalizeEmail(email) {
77
+ return email.toLowerCase().trim();
78
+ }
79
+ exports.normalizeEmail = normalizeEmail;
80
+ /**
81
+ * Normalizes an identifier based on the delivery method.
82
+ *
83
+ * @param identifier - The identifier to normalize
84
+ * @param method - The delivery method
85
+ * @returns Normalized identifier
86
+ */
87
+ function normalizeIdentifier(identifier, method) {
88
+ if (method === "email") {
89
+ return normalizeEmail(identifier);
90
+ }
91
+ else {
92
+ return normalizePhoneNumber(identifier);
93
+ }
94
+ }
95
+ exports.normalizeIdentifier = normalizeIdentifier;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Basic OTP Auth Plugin Usage Example
3
+ *
4
+ * This example demonstrates how to set up the OTP Auth Plugin
5
+ * with both SMS and email support.
6
+ */
7
+
8
+ import { FlinkApp } from "@flink-app/flink";
9
+ import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
10
+ import { otpAuthPlugin } from "@flink-app/otp-auth-plugin";
11
+
12
+ // Example: Define your context
13
+ interface Context {
14
+ // Your context definition
15
+ }
16
+
17
+ async function start() {
18
+ const app = new FlinkApp<Context>({
19
+ name: "OTP Auth Example",
20
+
21
+ // JWT Auth Plugin (required)
22
+ auth: jwtAuthPlugin({
23
+ secret: process.env.JWT_SECRET || "your-secret-key",
24
+ getUser: async (tokenData) => {
25
+ // Retrieve user from database
26
+ const user = await app.ctx.repos.userRepo.getById(tokenData.userId);
27
+ return {
28
+ id: user._id,
29
+ username: user.username,
30
+ roles: user.roles,
31
+ };
32
+ },
33
+ rolePermissions: {
34
+ user: ["read", "write"],
35
+ admin: ["read", "write", "delete"],
36
+ },
37
+ }),
38
+
39
+ db: {
40
+ uri: process.env.MONGODB_URI || "mongodb://localhost:27017/otp-example",
41
+ },
42
+
43
+ plugins: [
44
+ // OTP Auth Plugin
45
+ otpAuthPlugin({
46
+ // Configuration
47
+ codeLength: 6, // 6-digit code
48
+ codeTTL: 300, // 5 minutes
49
+ maxAttempts: 3, // Lock after 3 failed attempts
50
+
51
+ // Callback to send OTP code
52
+ onSendCode: async (code, identifier, method, payload) => {
53
+ console.log(`[OTP] Sending code ${code} to ${identifier} via ${method}`);
54
+
55
+ if (method === "sms") {
56
+ // In production, use a real SMS service:
57
+ // await app.ctx.plugins.sms.send({
58
+ // to: identifier,
59
+ // body: `Your verification code is: ${code}`
60
+ // });
61
+
62
+ // For demo purposes, just log
63
+ console.log(`[SMS] To: ${identifier}, Code: ${code}`);
64
+ } else {
65
+ // In production, use a real email service:
66
+ // await app.ctx.plugins.email.send({
67
+ // to: identifier,
68
+ // subject: 'Your Verification Code',
69
+ // text: `Your verification code is: ${code}`
70
+ // });
71
+
72
+ // For demo purposes, just log
73
+ console.log(`[EMAIL] To: ${identifier}, Code: ${code}`);
74
+ }
75
+
76
+ return true;
77
+ },
78
+
79
+ // Callback to get user by identifier
80
+ onGetUser: async (identifier, method, payload) => {
81
+ console.log(`[OTP] Getting user by ${method}: ${identifier}`);
82
+
83
+ // Find user by phone or email
84
+ if (method === "sms") {
85
+ return await app.ctx.repos.userRepo.findOne({
86
+ phoneNumber: identifier,
87
+ });
88
+ } else {
89
+ return await app.ctx.repos.userRepo.findOne({
90
+ email: identifier,
91
+ });
92
+ }
93
+ },
94
+
95
+ // Callback after successful verification
96
+ onVerifySuccess: async (user, identifier, method, payload) => {
97
+ console.log(`[OTP] Verification successful for user: ${user._id}`);
98
+
99
+ // Generate JWT token
100
+ const token = await app.ctx.auth.createToken(
101
+ {
102
+ userId: user._id,
103
+ username: user.username,
104
+ },
105
+ user.roles
106
+ );
107
+
108
+ // Return user and token
109
+ return {
110
+ user: {
111
+ id: user._id,
112
+ username: user.username,
113
+ email: user.email,
114
+ phoneNumber: user.phoneNumber,
115
+ },
116
+ token,
117
+ };
118
+ },
119
+ }),
120
+ ],
121
+ });
122
+
123
+ await app.start();
124
+
125
+ console.log("\n=== OTP Auth Plugin Example ===");
126
+ console.log("API Endpoints:");
127
+ console.log(" POST /otp/initiate - Initiate OTP authentication");
128
+ console.log(" POST /otp/verify - Verify OTP code");
129
+ console.log("\nExample requests:");
130
+ console.log('\n1. Initiate SMS OTP:');
131
+ console.log(' POST /otp/initiate');
132
+ console.log(' { "identifier": "+46701234567", "method": "sms" }');
133
+ console.log('\n2. Initiate Email OTP:');
134
+ console.log(' POST /otp/initiate');
135
+ console.log(' { "identifier": "user@example.com", "method": "email" }');
136
+ console.log('\n3. Verify OTP:');
137
+ console.log(' POST /otp/verify');
138
+ console.log(' { "sessionId": "...", "code": "123456" }');
139
+ console.log("\n================================\n");
140
+ }
141
+
142
+ start().catch((error) => {
143
+ console.error("Failed to start application:", error);
144
+ process.exit(1);
145
+ });
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@flink-app/otp-auth-plugin",
3
+ "version": "0.12.1-alpha.40",
4
+ "description": "Flink plugin for OTP (One-Time Password) authentication via SMS or email",
5
+ "scripts": {
6
+ "test": "node --preserve-symlinks -r ts-node/register -- node_modules/jasmine/bin/jasmine --config=./spec/support/jasmine.json",
7
+ "test:watch": "nodemon --ext ts --exec 'jasmine-ts --config=./spec/support/jasmine.json'",
8
+ "prepare": "tsc --project tsconfig.dist.json",
9
+ "watch": "tsc-watch --project tsconfig.dist.json"
10
+ },
11
+ "author": "joel@frost.se",
12
+ "license": "MIT",
13
+ "types": "dist/index.d.ts",
14
+ "main": "dist/index.js",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "peerDependencies": {
19
+ "mongodb": "^6.15.0"
20
+ },
21
+ "overrides": {
22
+ "@types/node": "22.13.10"
23
+ },
24
+ "devDependencies": {
25
+ "@flink-app/flink": "^0.12.1-alpha.40",
26
+ "@types/jasmine": "^3.7.1",
27
+ "@types/node": "22.13.10",
28
+ "jasmine": "^3.7.0",
29
+ "jasmine-spec-reporter": "^7.0.0",
30
+ "mongodb": "6.15.0",
31
+ "nodemon": "^2.0.7",
32
+ "ts-node": "^9.1.1",
33
+ "tsc-watch": "^4.2.9",
34
+ "typescript": "5.4.5"
35
+ },
36
+ "gitHead": "456502f273fe9473df05b71a803f3eda1a2f8931"
37
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * OTP Auth Plugin Initialization Tests
3
+ *
4
+ * Tests for plugin configuration and initialization
5
+ */
6
+
7
+ import { otpAuthPlugin } from "../src/OtpAuthPlugin";
8
+
9
+ describe("OtpAuthPlugin", () => {
10
+ describe("Plugin Initialization", () => {
11
+ it("should throw error if onSendCode callback is missing", () => {
12
+ expect(() => {
13
+ otpAuthPlugin({
14
+ onGetUser: async () => null,
15
+ onVerifySuccess: async () => ({ user: {}, token: "" }),
16
+ } as any);
17
+ }).toThrowError(/onSendCode callback is required/);
18
+ });
19
+
20
+ it("should throw error if onGetUser callback is missing", () => {
21
+ expect(() => {
22
+ otpAuthPlugin({
23
+ onSendCode: async () => true,
24
+ onVerifySuccess: async () => ({ user: {}, token: "" }),
25
+ } as any);
26
+ }).toThrowError(/onGetUser callback is required/);
27
+ });
28
+
29
+ it("should throw error if onVerifySuccess callback is missing", () => {
30
+ expect(() => {
31
+ otpAuthPlugin({
32
+ onSendCode: async () => true,
33
+ onGetUser: async () => null,
34
+ } as any);
35
+ }).toThrowError(/onVerifySuccess callback is required/);
36
+ });
37
+
38
+ it("should throw error if codeLength is less than 4", () => {
39
+ expect(() => {
40
+ otpAuthPlugin({
41
+ codeLength: 3,
42
+ onSendCode: async () => true,
43
+ onGetUser: async () => null,
44
+ onVerifySuccess: async () => ({ user: {}, token: "" }),
45
+ });
46
+ }).toThrowError(/codeLength must be between 4 and 8/);
47
+ });
48
+
49
+ it("should throw error if codeLength is greater than 8", () => {
50
+ expect(() => {
51
+ otpAuthPlugin({
52
+ codeLength: 9,
53
+ onSendCode: async () => true,
54
+ onGetUser: async () => null,
55
+ onVerifySuccess: async () => ({ user: {}, token: "" }),
56
+ });
57
+ }).toThrowError(/codeLength must be between 4 and 8/);
58
+ });
59
+
60
+ it("should throw error if maxAttempts is invalid", () => {
61
+ // Note: maxAttempts validation happens after default is applied
62
+ // So 0 becomes the default 3, but we can test > 10
63
+ expect(() => {
64
+ otpAuthPlugin({
65
+ maxAttempts: 11,
66
+ onSendCode: async () => true,
67
+ onGetUser: async () => null,
68
+ onVerifySuccess: async () => ({ user: {}, token: "" }),
69
+ });
70
+ }).toThrowError(/maxAttempts must be between 1 and 10/);
71
+ });
72
+
73
+
74
+ it("should create plugin with valid configuration", () => {
75
+ const plugin = otpAuthPlugin({
76
+ codeLength: 6,
77
+ codeTTL: 300,
78
+ maxAttempts: 3,
79
+ onSendCode: async (code, identifier, method) => {
80
+ return true;
81
+ },
82
+ onGetUser: async (identifier, method) => {
83
+ return null;
84
+ },
85
+ onVerifySuccess: async (user, identifier, method) => {
86
+ return { user: {}, token: "" };
87
+ },
88
+ });
89
+
90
+ expect(plugin).toBeDefined();
91
+ expect(plugin.id).toBe("otpAuth");
92
+ expect(plugin.db).toBeDefined();
93
+ expect(plugin.db?.useHostDb).toBe(true);
94
+ expect(plugin.ctx).toBeDefined();
95
+ expect(plugin.init).toBeDefined();
96
+ });
97
+
98
+ it("should use default values when optional config is omitted", () => {
99
+ const plugin = otpAuthPlugin({
100
+ onSendCode: async () => true,
101
+ onGetUser: async () => null,
102
+ onVerifySuccess: async () => ({ user: {}, token: "" }),
103
+ });
104
+
105
+ expect(plugin).toBeDefined();
106
+ // Options are frozen with original values, defaults are applied internally
107
+ expect(plugin.ctx.options.codeLength).toBeUndefined();
108
+ expect(plugin.ctx.options.codeTTL).toBeUndefined();
109
+ expect(plugin.ctx.options.maxAttempts).toBeUndefined();
110
+ });
111
+
112
+ it("should accept custom configuration values", () => {
113
+ const plugin = otpAuthPlugin({
114
+ codeLength: 4,
115
+ codeTTL: 600,
116
+ maxAttempts: 5,
117
+ keepSessionsSec: 3600,
118
+ otpSessionsCollectionName: "custom_otp",
119
+ registerRoutes: false,
120
+ onSendCode: async () => true,
121
+ onGetUser: async () => null,
122
+ onVerifySuccess: async () => ({ user: {}, token: "" }),
123
+ });
124
+
125
+ expect(plugin.ctx.options.codeLength).toBe(4);
126
+ expect(plugin.ctx.options.codeTTL).toBe(600);
127
+ expect(plugin.ctx.options.maxAttempts).toBe(5);
128
+ expect(plugin.ctx.options.keepSessionsSec).toBe(3600);
129
+ expect(plugin.ctx.options.otpSessionsCollectionName).toBe("custom_otp");
130
+ expect(plugin.ctx.options.registerRoutes).toBe(false);
131
+ });
132
+
133
+ it("should provide context API methods", () => {
134
+ const plugin = otpAuthPlugin({
135
+ onSendCode: async () => true,
136
+ onGetUser: async () => null,
137
+ onVerifySuccess: async () => ({ user: {}, token: "" }),
138
+ });
139
+
140
+ expect(plugin.ctx.initiate).toBeDefined();
141
+ expect(typeof plugin.ctx.initiate).toBe("function");
142
+ expect(plugin.ctx.verify).toBeDefined();
143
+ expect(typeof plugin.ctx.verify).toBe("function");
144
+ });
145
+
146
+ it("should freeze options object to prevent modification", () => {
147
+ const plugin = otpAuthPlugin({
148
+ codeLength: 6,
149
+ onSendCode: async () => true,
150
+ onGetUser: async () => null,
151
+ onVerifySuccess: async () => ({ user: {}, token: "" }),
152
+ });
153
+
154
+ expect(() => {
155
+ (plugin.ctx.options as any).codeLength = 4;
156
+ }).toThrow();
157
+ });
158
+ });
159
+ });
@@ -0,0 +1,194 @@
1
+ import { MongoClient, Db } from "mongodb";
2
+ import OtpSessionRepo from "../src/repos/OtpSessionRepo";
3
+ import OtpSession from "../src/schemas/OtpSession";
4
+
5
+ /**
6
+ * OTP Session Repository Tests
7
+ *
8
+ * Tests critical repository methods with actual MongoDB connection.
9
+ */
10
+ describe("OtpSessionRepo", () => {
11
+ let client: MongoClient;
12
+ let db: Db;
13
+ let sessionRepo: OtpSessionRepo;
14
+
15
+ beforeAll(async () => {
16
+ // Connect to test database
17
+ const mongoUrl = process.env.TEST_MONGO_URL || "mongodb://localhost:27017";
18
+ client = new MongoClient(mongoUrl);
19
+ await client.connect();
20
+ db = client.db("otp_auth_plugin_test");
21
+
22
+ // Initialize repository
23
+ sessionRepo = new OtpSessionRepo("otp_sessions", db);
24
+ });
25
+
26
+ afterAll(async () => {
27
+ // Clean up and close connection
28
+ await db.dropDatabase();
29
+ await client.close();
30
+ });
31
+
32
+ afterEach(async () => {
33
+ // Clear collection between tests
34
+ await db.collection("otp_sessions").deleteMany({});
35
+ });
36
+
37
+ it("should create and retrieve session by sessionId", async () => {
38
+ // Arrange
39
+ const session: Omit<OtpSession, "_id"> = {
40
+ sessionId: "test-session-123",
41
+ identifier: "+46701234567",
42
+ method: "sms",
43
+ code: "123456",
44
+ attempts: 0,
45
+ maxAttempts: 3,
46
+ status: "pending",
47
+ createdAt: new Date(),
48
+ expiresAt: new Date(Date.now() + 300000), // 5 minutes
49
+ };
50
+
51
+ // Act
52
+ const created = await sessionRepo.createSession(session);
53
+ const found = await sessionRepo.getSession("test-session-123");
54
+
55
+ // Assert
56
+ expect(created._id).toBeDefined();
57
+ expect(found).toBeDefined();
58
+ expect(found?.sessionId).toBe("test-session-123");
59
+ expect(found?.identifier).toBe("+46701234567");
60
+ expect(found?.method).toBe("sms");
61
+ expect(found?.code).toBe("123456");
62
+ expect(found?.status).toBe("pending");
63
+ });
64
+
65
+ it("should increment attempts counter", async () => {
66
+ // Arrange
67
+ const session: Omit<OtpSession, "_id"> = {
68
+ sessionId: "increment-test",
69
+ identifier: "user@example.com",
70
+ method: "email",
71
+ code: "654321",
72
+ attempts: 0,
73
+ maxAttempts: 3,
74
+ status: "pending",
75
+ createdAt: new Date(),
76
+ expiresAt: new Date(Date.now() + 300000),
77
+ };
78
+ await sessionRepo.createSession(session);
79
+
80
+ // Act
81
+ const updated = await sessionRepo.incrementAttempts("increment-test");
82
+
83
+ // Assert
84
+ expect(updated).toBeDefined();
85
+ expect(updated?.attempts).toBe(1);
86
+ expect(updated?.status).toBe("pending");
87
+ });
88
+
89
+ it("should lock session after max attempts reached", async () => {
90
+ // Arrange
91
+ const session: Omit<OtpSession, "_id"> = {
92
+ sessionId: "lock-test",
93
+ identifier: "+46701234567",
94
+ method: "sms",
95
+ code: "111111",
96
+ attempts: 2,
97
+ maxAttempts: 3,
98
+ status: "pending",
99
+ createdAt: new Date(),
100
+ expiresAt: new Date(Date.now() + 300000),
101
+ };
102
+ await sessionRepo.createSession(session);
103
+
104
+ // Act - increment to reach max attempts
105
+ const updated = await sessionRepo.incrementAttempts("lock-test");
106
+
107
+ // Assert
108
+ expect(updated?.attempts).toBe(3);
109
+ expect(updated?.status).toBe("locked");
110
+ });
111
+
112
+ it("should mark session as verified", async () => {
113
+ // Arrange
114
+ const session: Omit<OtpSession, "_id"> = {
115
+ sessionId: "verify-test",
116
+ identifier: "user@example.com",
117
+ method: "email",
118
+ code: "999999",
119
+ attempts: 1,
120
+ maxAttempts: 3,
121
+ status: "pending",
122
+ createdAt: new Date(),
123
+ expiresAt: new Date(Date.now() + 300000),
124
+ };
125
+ await sessionRepo.createSession(session);
126
+
127
+ // Act
128
+ const updated = await sessionRepo.markAsVerified("verify-test");
129
+
130
+ // Assert
131
+ expect(updated).toBeDefined();
132
+ expect(updated?.status).toBe("verified");
133
+ expect(updated?.verifiedAt).toBeDefined();
134
+ });
135
+
136
+ it("should mark session as expired", async () => {
137
+ // Arrange
138
+ const session: Omit<OtpSession, "_id"> = {
139
+ sessionId: "expire-test",
140
+ identifier: "+46701234567",
141
+ method: "sms",
142
+ code: "888888",
143
+ attempts: 0,
144
+ maxAttempts: 3,
145
+ status: "pending",
146
+ createdAt: new Date(),
147
+ expiresAt: new Date(Date.now() - 1000), // Already expired
148
+ };
149
+ await sessionRepo.createSession(session);
150
+
151
+ // Act
152
+ const updated = await sessionRepo.markAsExpired("expire-test");
153
+
154
+ // Assert
155
+ expect(updated).toBeDefined();
156
+ expect(updated?.status).toBe("expired");
157
+ });
158
+
159
+ it("should return null when session not found", async () => {
160
+ // Act
161
+ const found = await sessionRepo.getSession("non-existent-session");
162
+
163
+ // Assert
164
+ expect(found).toBeNull();
165
+ });
166
+
167
+ it("should handle custom payload in session", async () => {
168
+ // Arrange
169
+ const session: Omit<OtpSession, "_id"> = {
170
+ sessionId: "payload-test",
171
+ identifier: "user@example.com",
172
+ method: "email",
173
+ code: "777777",
174
+ attempts: 0,
175
+ maxAttempts: 3,
176
+ status: "pending",
177
+ createdAt: new Date(),
178
+ expiresAt: new Date(Date.now() + 300000),
179
+ payload: {
180
+ action: "password-reset",
181
+ returnUrl: "/new-password",
182
+ },
183
+ };
184
+
185
+ // Act
186
+ const created = await sessionRepo.createSession(session);
187
+ const found = await sessionRepo.getSession("payload-test");
188
+
189
+ // Assert
190
+ expect(found?.payload).toBeDefined();
191
+ expect(found?.payload?.action).toBe("password-reset");
192
+ expect(found?.payload?.returnUrl).toBe("/new-password");
193
+ });
194
+ });