UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

700 lines (592 loc) 20.9 kB
import { Alepha } from "alepha"; import { AlephaApiVerification } from "alepha/api/verifications"; import { DateTimeProvider } from "alepha/datetime"; import { AlephaEmail, MemoryEmailProvider } from "alepha/email"; import { AlephaOrmPostgres } from "alepha/orm/postgres"; import { AlephaSecurity, CryptoProvider } from "alepha/security"; import { BadRequestError, HttpError } from "alepha/server"; import { describe, it } from "vitest"; import { AlephaApiUsers, CredentialService, RealmProvider, SessionService, UserNotifications, UserService, } from "../index.ts"; const setup = async () => { const alepha = Alepha.create({ env: { LOG_LEVEL: "error" }, }); alepha.with(AlephaOrmPostgres); alepha.with(AlephaSecurity); alepha.with(AlephaEmail); alepha.with(AlephaApiVerification); alepha.with(AlephaApiUsers); alepha.with(UserNotifications); await alepha.start(); // Enable notifications for the default realm const realmProvider = alepha.inject(RealmProvider); realmProvider.register("default", { features: { notifications: true, }, settings: { resetPasswordAllowed: true, }, }); const emailProvider = alepha.inject(MemoryEmailProvider); emailProvider.records = []; return { alepha, credentialService: alepha.inject(CredentialService), userService: alepha.inject(UserService), sessionService: alepha.inject(SessionService), cryptoProvider: alepha.inject(CryptoProvider), dateTimeProvider: alepha.inject(DateTimeProvider), emailProvider, }; }; // Helper to extract code from email const extractCode = (emailBody: string): string => { const match = emailBody.match(/(\d{6})/); if (!match) throw new Error("Code not found in email"); return match[1]; }; // Helper to create a user with credentials const createUserWithCredentials = async ( userService: UserService, credentialService: CredentialService, cryptoProvider: CryptoProvider, email: string, password: string, ) => { const user = await userService.users().create({ email, username: email.split("@")[0], roles: ["user"], }); const hashedPassword = await cryptoProvider.hashPassword(password); await credentialService.identities().create({ userId: user.id, provider: "credentials", password: hashedPassword, }); return user; }; describe("alepha/api/users - CredentialService", () => { describe("Phase 1: createPasswordResetIntent", () => { it("should create a password reset intent for existing user", async ({ expect, }) => { const { credentialService, userService, cryptoProvider, emailProvider } = await setup(); await createUserWithCredentials( userService, credentialService, cryptoProvider, "reset@example.com", "OldPassword123!", ); const result = await credentialService.createPasswordResetIntent("reset@example.com"); expect(result.intentId).toBeDefined(); expect(result.expiresAt).toBeDefined(); // Verify email was sent await expect.poll(() => emailProvider.records.length).toBe(1); expect(emailProvider.records[0].to).toBe("reset@example.com"); expect(emailProvider.records[0].subject).toBe("Reset your password"); }); it("should return fake intent for non-existent email (security)", async ({ expect, }) => { const { credentialService, emailProvider } = await setup(); // Should still return a response (security - don't reveal if email exists) const result = await credentialService.createPasswordResetIntent( "nonexistent@example.com", ); expect(result.intentId).toBeDefined(); expect(result.expiresAt).toBeDefined(); // No email should be sent expect(emailProvider.records.length).toBe(0); }); it("should return fake intent for user without credentials identity", async ({ expect, }) => { const { credentialService, userService, emailProvider } = await setup(); // Create user without credentials (OAuth-only user) await userService.users().create({ email: "oauth@example.com", username: "oauthuser", roles: ["user"], }); // Should still return a response const result = await credentialService.createPasswordResetIntent("oauth@example.com"); expect(result.intentId).toBeDefined(); expect(result.expiresAt).toBeDefined(); // No email should be sent expect(emailProvider.records.length).toBe(0); }); it("should set correct expiration time (10 minutes)", async ({ expect, }) => { const { credentialService, userService, cryptoProvider, dateTimeProvider, } = await setup(); await createUserWithCredentials( userService, credentialService, cryptoProvider, "expiry@example.com", "Password123!", ); const before = dateTimeProvider.now(); const result = await credentialService.createPasswordResetIntent("expiry@example.com"); const expiresAt = dateTimeProvider.of(result.expiresAt); const expectedExpiry = before.add(10, "minutes"); // Should expire approximately 10 minutes from now expect(expiresAt.diff(expectedExpiry, "seconds")).toBeLessThan(5); }); }); describe("Phase 2: completePasswordReset", () => { it("should complete password reset with valid code", async ({ expect }) => { const { credentialService, userService, sessionService, cryptoProvider, emailProvider, } = await setup(); await createUserWithCredentials( userService, credentialService, cryptoProvider, "complete@example.com", "OldPassword123!", ); // Phase 1: Create intent const intent = await credentialService.createPasswordResetIntent( "complete@example.com", ); // Extract code from email await expect.poll(() => emailProvider.records.length).toBe(1); const code = extractCode(emailProvider.records[0].body); // Phase 2: Complete password reset await credentialService.completePasswordReset({ intentId: intent.intentId, code, newPassword: "NewPassword456!", }); // Verify new password works const loggedInUser = await sessionService.login( "credentials", "complete@example.com", "NewPassword456!", ); expect(loggedInUser?.email).toBe("complete@example.com"); }); it("should reject expired intent (410 Gone)", async ({ expect }) => { const { credentialService, userService, cryptoProvider, dateTimeProvider, emailProvider, } = await setup(); await createUserWithCredentials( userService, credentialService, cryptoProvider, "expired@example.com", "OldPassword123!", ); // Create intent const intent = await credentialService.createPasswordResetIntent( "expired@example.com", ); // Extract code await expect.poll(() => emailProvider.records.length).toBe(1); const code = extractCode(emailProvider.records[0].body); // Travel forward 11 minutes (intent expires at 10) dateTimeProvider.travel(11, "minutes"); // Attempt to complete await expect( credentialService.completePasswordReset({ intentId: intent.intentId, code, newPassword: "NewPassword456!", }), ).rejects.toThrow(HttpError); }); it("should reject invalid intent ID", async ({ expect }) => { const { credentialService } = await setup(); await expect( credentialService.completePasswordReset({ intentId: "550e8400-e29b-41d4-a716-446655440000", code: "123456", newPassword: "NewPassword456!", }), ).rejects.toThrow(HttpError); }); it("should reject invalid verification code", async ({ expect }) => { const { credentialService, userService, cryptoProvider, emailProvider } = await setup(); await createUserWithCredentials( userService, credentialService, cryptoProvider, "wrongcode@example.com", "OldPassword123!", ); const intent = await credentialService.createPasswordResetIntent( "wrongcode@example.com", ); // Wait for email await expect.poll(() => emailProvider.records.length).toBe(1); await expect( credentialService.completePasswordReset({ intentId: intent.intentId, code: "000000", // Wrong code newPassword: "NewPassword456!", }), ).rejects.toThrowError(BadRequestError); }); it("should not allow intent reuse after successful reset", async ({ expect, }) => { const { credentialService, userService, cryptoProvider, emailProvider } = await setup(); await createUserWithCredentials( userService, credentialService, cryptoProvider, "onetime@example.com", "OldPassword123!", ); const intent = await credentialService.createPasswordResetIntent( "onetime@example.com", ); // Extract code await expect.poll(() => emailProvider.records.length).toBe(1); const code = extractCode(emailProvider.records[0].body); // First completion should succeed await credentialService.completePasswordReset({ intentId: intent.intentId, code, newPassword: "NewPassword456!", }); // Second attempt should fail (intent deleted) await expect( credentialService.completePasswordReset({ intentId: intent.intentId, code, newPassword: "AnotherPassword789!", }), ).rejects.toThrow(HttpError); }); it("should invalidate all existing sessions after password reset", async ({ expect, }) => { const { credentialService, userService, sessionService, cryptoProvider, emailProvider, } = await setup(); const user = await createUserWithCredentials( userService, credentialService, cryptoProvider, "sessions@example.com", "OldPassword123!", ); // Create a session await sessionService.sessions().create({ userId: user.id, refreshToken: crypto.randomUUID(), expiresAt: new Date(Date.now() + 3600000).toISOString(), }); // Verify session exists const sessionsBefore = await sessionService.sessions().findMany({ where: { userId: { eq: user.id } }, }); expect(sessionsBefore).toHaveLength(1); // Create password reset intent const intent = await credentialService.createPasswordResetIntent( "sessions@example.com", ); // Extract code await expect.poll(() => emailProvider.records.length).toBe(1); const code = extractCode(emailProvider.records[0].body); // Complete password reset await credentialService.completePasswordReset({ intentId: intent.intentId, code, newPassword: "NewPassword456!", }); // Verify all sessions are invalidated const sessionsAfter = await sessionService.sessions().findMany({ where: { userId: { eq: user.id } }, }); expect(sessionsAfter).toHaveLength(0); }); it("should not allow old password after reset", async ({ expect }) => { const { credentialService, userService, sessionService, cryptoProvider, emailProvider, } = await setup(); await createUserWithCredentials( userService, credentialService, cryptoProvider, "oldpass@example.com", "OldPassword123!", ); // Verify old password works before reset const beforeReset = await sessionService.login( "credentials", "oldpass@example.com", "OldPassword123!", ); expect(beforeReset?.email).toBe("oldpass@example.com"); // Create intent and reset password const intent = await credentialService.createPasswordResetIntent( "oldpass@example.com", ); await expect.poll(() => emailProvider.records.length).toBe(1); const code = extractCode(emailProvider.records[0].body); await credentialService.completePasswordReset({ intentId: intent.intentId, code, newPassword: "NewPassword456!", }); // Old password should no longer work (login throws error for invalid credentials) await expect( sessionService.login( "credentials", "oldpass@example.com", "OldPassword123!", ), ).rejects.toThrow(); }); }); describe("Full password reset flow integration", () => { it("should complete full password reset flow", async ({ expect }) => { const { credentialService, userService, sessionService, cryptoProvider, emailProvider, } = await setup(); // Create user await createUserWithCredentials( userService, credentialService, cryptoProvider, "fullflow@example.com", "OldPassword123!", ); // Phase 1: Request password reset const intent = await credentialService.createPasswordResetIntent( "fullflow@example.com", ); expect(intent.intentId).toBeDefined(); expect(intent.expiresAt).toBeDefined(); // Verify email was sent await expect.poll(() => emailProvider.records.length).toBe(1); expect(emailProvider.records[0].to).toBe("fullflow@example.com"); const code = extractCode(emailProvider.records[0].body); // Phase 2: Complete password reset await credentialService.completePasswordReset({ intentId: intent.intentId, code, newPassword: "NewSecurePassword789!", }); // Verify new password works const user = await sessionService.login( "credentials", "fullflow@example.com", "NewSecurePassword789!", ); expect(user?.email).toBe("fullflow@example.com"); }); }); describe("resetPasswordAllowed=false", () => { it("should silently return intent when resetPasswordAllowed is false", async ({ expect, }) => { const { alepha, credentialService, userService, cryptoProvider } = await setup(); // Register a separate realm with resetPasswordAllowed disabled const realmProvider = alepha.inject(RealmProvider); realmProvider.register("no-reset", { features: { notifications: true }, settings: { resetPasswordAllowed: false }, }); // Create a user in the no-reset realm const user = await credentialService.users("no-reset").create({ email: "noreset@example.com", username: "noresetuser", roles: ["user"], }); const hashedPassword = await cryptoProvider.hashPassword("Password123!"); await credentialService.identities("no-reset").create({ userId: user.id, provider: "credentials", password: hashedPassword, }); // Request password reset - should return an intentId (security: doesn't reveal it's disabled) const result = await credentialService.createPasswordResetIntent( "noreset@example.com", "no-reset", ); expect(result.intentId).toBeDefined(); expect(result.expiresAt).toBeDefined(); // No email should be sent const emailProvider = alepha.inject(MemoryEmailProvider); expect(emailProvider.records.length).toBe(0); // Completing the reset with the returned intentId should fail with 410 // because the intent was never actually stored in the cache await expect( credentialService.completePasswordReset({ intentId: result.intentId, code: "123456", newPassword: "NewPassword456!", }), ).rejects.toThrow(HttpError); }); }); describe("Password policy enforcement during reset", () => { it("should reject password that violates realm policy", async ({ expect, }) => { const { alepha, credentialService, userService, cryptoProvider } = await setup(); // Register a realm with strict password policy const realmProvider = alepha.inject(RealmProvider); realmProvider.register("strict-policy", { features: { notifications: true }, settings: { resetPasswordAllowed: true, passwordPolicy: { minLength: 8, requireUppercase: true, requireLowercase: true, requireNumbers: true, requireSpecialCharacters: false, }, }, }); // Create a user in the strict-policy realm const user = await credentialService.users("strict-policy").create({ email: "strict@example.com", username: "strictuser", roles: ["user"], }); const hashedPassword = await cryptoProvider.hashPassword("OldPassword123!"); await credentialService.identities("strict-policy").create({ userId: user.id, provider: "credentials", password: hashedPassword, }); // Create intent const intent = await credentialService.createPasswordResetIntent( "strict@example.com", "strict-policy", ); // Extract code from email const emailProvider = alepha.inject(MemoryEmailProvider); await expect.poll(() => emailProvider.records.length).toBe(1); const code = extractCode(emailProvider.records[0].body); // Try to complete with a password that has no uppercase and no numbers await expect( credentialService.completePasswordReset({ intentId: intent.intentId, code, newPassword: "alllowercase", }), ).rejects.toThrowError(BadRequestError); // The verification code should NOT be consumed - can retry with a valid password await credentialService.completePasswordReset({ intentId: intent.intentId, code, newPassword: "ValidRetry123", }); }); it("should accept password that meets realm policy", async ({ expect }) => { const { alepha, credentialService, userService, cryptoProvider } = await setup(); // Register a realm with strict password policy const realmProvider = alepha.inject(RealmProvider); realmProvider.register("strict-accept", { features: { notifications: true }, settings: { resetPasswordAllowed: true, passwordPolicy: { minLength: 8, requireUppercase: true, requireLowercase: true, requireNumbers: true, requireSpecialCharacters: false, }, }, }); // Create a user in the strict-accept realm const user = await credentialService.users("strict-accept").create({ email: "accept@example.com", username: "acceptuser", roles: ["user"], }); const hashedPassword = await cryptoProvider.hashPassword("OldPassword123!"); await credentialService.identities("strict-accept").create({ userId: user.id, provider: "credentials", password: hashedPassword, }); // Create intent const intent = await credentialService.createPasswordResetIntent( "accept@example.com", "strict-accept", ); // Extract code from email const emailProvider = alepha.inject(MemoryEmailProvider); await expect.poll(() => emailProvider.records.length).toBe(1); const code = extractCode(emailProvider.records[0].body); // Complete with a password that meets the policy await credentialService.completePasswordReset({ intentId: intent.intentId, code, newPassword: "ValidPass123", }); }); }); describe("Legacy methods (backward compatibility)", () => { it("requestPasswordReset should work as before", async ({ expect }) => { const { credentialService, userService, cryptoProvider, emailProvider } = await setup(); await createUserWithCredentials( userService, credentialService, cryptoProvider, "legacy@example.com", "OldPassword123!", ); const result = await credentialService.requestPasswordReset("legacy@example.com"); expect(result).toBe(true); await expect.poll(() => emailProvider.records.length).toBe(1); }); }); });