alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
567 lines (476 loc) • 16.7 kB
text/typescript
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 } from "alepha/server";
import { describe, it } from "vitest";
import {
AlephaApiUsers,
CredentialService,
RealmProvider,
SessionService,
UserController,
UserNotifications,
} 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),
sessionService: alepha.inject(SessionService),
cryptoProvider: alepha.inject(CryptoProvider),
dateTimeProvider: alepha.inject(DateTimeProvider),
emailProvider,
actions: alepha.inject(UserController),
};
};
// Helper to extract code from email
const extractCode = (emailBody: string): string => {
// Code is displayed in the email body as a 6-digit number
const match = emailBody.match(/(\d{6})/);
if (!match) throw new Error("Code not found in email");
return match[1];
};
describe("alepha/api/users - Password Reset", () => {
it("should successfully request password reset and send email", async ({
expect,
}) => {
const { credentialService, cryptoProvider, emailProvider, actions } =
await setup();
// Create a test user with credentials
const hashedPassword = await cryptoProvider.hashPassword("OldPassword123");
const user = await credentialService.users().create({
username: "testuser",
email: "test@example.com",
roles: ["user"],
});
await credentialService.identities().create({
userId: user.id,
provider: "credentials",
providerUserId: "test@example.com",
providerData: { password: hashedPassword },
});
// Request password reset
const result = await actions.requestPasswordReset({
body: {
email: "test@example.com",
},
});
expect(result.success).toBe(true);
expect(result.message).toContain("password reset code has been sent");
// Verify email was sent via password reset notification
await expect.poll(() => emailProvider.records.length).toBe(1);
const email = emailProvider.records[0];
expect(email.to).toBe("test@example.com");
expect(email.subject).toBe("Reset your password");
// Check for 6-digit code in email
expect(email.body).toMatch(/\d{6}/);
expect(email.body).toContain("5 minutes"); // Default code verification expiration
});
it("should not reveal if email does not exist", async ({ expect }) => {
const { emailProvider, actions } = await setup();
// Request password reset for non-existent email
const result = await actions.requestPasswordReset({
body: {
email: "nonexistent@example.com",
},
});
// Should return success to prevent email enumeration
expect(result.success).toBe(true);
expect(result.message).toContain("password reset code has been sent");
// But no email should be sent
expect(emailProvider.records).toHaveLength(0);
});
it("should not send email for OAuth-only users", async ({ expect }) => {
const { credentialService, emailProvider, actions } = await setup();
// Create a user with only OAuth identity (no credentials)
const user = await credentialService.users().create({
username: "oauthuser",
email: "oauth@example.com",
roles: ["user"],
});
await credentialService.identities().create({
userId: user.id,
provider: "google",
providerUserId: "google-123",
});
// Request password reset
const result = await actions.requestPasswordReset({
body: {
email: "oauth@example.com",
},
});
// Should return success but not send email
expect(result.success).toBe(true);
expect(emailProvider.records).toHaveLength(0);
});
it("should validate a valid reset token", async ({ expect }) => {
const { credentialService, cryptoProvider, emailProvider, actions } =
await setup();
// Create a test user with credentials
const hashedPassword = await cryptoProvider.hashPassword("OldPassword123");
const user = await credentialService.users().create({
username: "testuser",
email: "test@example.com",
roles: ["user"],
});
await credentialService.identities().create({
userId: user.id,
provider: "credentials",
providerUserId: "test@example.com",
providerData: { password: hashedPassword },
});
// Request password reset
await credentialService.requestPasswordReset("test@example.com");
// Extract code from email
await expect.poll(() => emailProvider.records.length).toBe(1);
const token = extractCode(emailProvider.records[0].body);
// Validate token
const result = await actions.validateResetToken({
query: { email: "test@example.com", token },
});
expect(result.valid).toBe(true);
expect(result.email).toBe("test@example.com");
});
it("should reject invalid reset token", async ({ expect }) => {
const { actions } = await setup();
// Validate invalid token
const result = await actions.validateResetToken({
query: {
email: "test@example.com",
token: "550e8400-e29b-41d4-a716-446655440000",
},
});
expect(result.valid).toBe(false);
expect(result.email).toBeUndefined();
});
it("should reject expired reset token", async ({ expect }) => {
const {
credentialService,
cryptoProvider,
dateTimeProvider,
emailProvider,
actions,
} = await setup();
// Create a test user with credentials
const hashedPassword = await cryptoProvider.hashPassword("OldPassword123");
const user = await credentialService.users().create({
username: "testuser",
email: "test@example.com",
roles: ["user"],
});
await credentialService.identities().create({
userId: user.id,
provider: "credentials",
providerUserId: "test@example.com",
providerData: { password: hashedPassword },
});
// Request password reset
await credentialService.requestPasswordReset("test@example.com");
// Extract code
await expect.poll(() => emailProvider.records.length).toBe(1);
const token = extractCode(emailProvider.records[0].body);
// Travel forward in time to expire the token (default expiration is 5 minutes for code verification)
dateTimeProvider.travel(6, "minutes");
// Validate expired token
const result = await actions.validateResetToken({
query: { email: "test@example.com", token },
});
expect(result.valid).toBe(false);
expect(result.email).toBeUndefined();
});
it("should successfully reset password with valid token", async ({
expect,
}) => {
const {
credentialService,
sessionService,
cryptoProvider,
emailProvider,
actions,
} = await setup();
// Create a test user with credentials
const hashedPassword = await cryptoProvider.hashPassword("OldPassword123");
const user = await credentialService.users().create({
username: "testuser",
email: "test@example.com",
roles: ["user"],
});
await credentialService.identities().create({
userId: user.id,
provider: "credentials",
providerUserId: "test@example.com",
providerData: { password: hashedPassword },
});
// Request password reset
await credentialService.requestPasswordReset("test@example.com");
// Extract code
await expect.poll(() => emailProvider.records.length).toBe(1);
const token = extractCode(emailProvider.records[0].body);
// Reset password
const result = await actions.resetPassword({
body: {
email: "test@example.com",
token,
newPassword: "NewPassword456",
},
});
expect(result.success).toBe(true);
expect(result.message).toContain("Password has been reset successfully");
// Verify old password no longer works
await expect(
sessionService.login("credentials", "test@example.com", "OldPassword123"),
).rejects.toThrow();
// Verify new password works
const loggedInUser = await sessionService.login(
"credentials",
"test@example.com",
"NewPassword456",
);
expect(loggedInUser?.email).toBe("test@example.com");
});
it("should reject password reset with invalid token", async ({ expect }) => {
const { actions } = await setup();
// Attempt to reset password with invalid token
await expect(
actions.resetPassword({
body: {
email: "test@example.com",
token: "550e8400-e29b-41d4-a716-446655440000",
newPassword: "NewPassword456",
},
}),
).rejects.toThrowError(BadRequestError);
});
it("should reject password reset with expired token", async ({ expect }) => {
const {
credentialService,
cryptoProvider,
dateTimeProvider,
emailProvider,
actions,
} = await setup();
// Create a test user with credentials
const hashedPassword = await cryptoProvider.hashPassword("OldPassword123");
const user = await credentialService.users().create({
username: "testuser",
email: "test@example.com",
roles: ["user"],
});
await credentialService.identities().create({
userId: user.id,
provider: "credentials",
providerUserId: "test@example.com",
providerData: { password: hashedPassword },
});
// Request password reset
await credentialService.requestPasswordReset("test@example.com");
// Extract code
await expect.poll(() => emailProvider.records.length).toBe(1);
const token = extractCode(emailProvider.records[0].body);
// Travel forward in time to expire the token (5 minutes for code)
dateTimeProvider.travel(6, "minutes");
// Attempt to reset password with expired token
await expect(
actions.resetPassword({
body: {
email: "test@example.com",
token,
newPassword: "NewPassword456",
},
}),
).rejects.toThrowError(BadRequestError);
});
it("should not allow token reuse after successful password reset", async ({
expect,
}) => {
const { credentialService, cryptoProvider, emailProvider, actions } =
await setup();
// Create a test user with credentials
const hashedPassword = await cryptoProvider.hashPassword("OldPassword123");
const user = await credentialService.users().create({
username: "testuser",
email: "test@example.com",
roles: ["user"],
});
await credentialService.identities().create({
userId: user.id,
provider: "credentials",
providerUserId: "test@example.com",
providerData: { password: hashedPassword },
});
// Request password reset
await credentialService.requestPasswordReset("test@example.com");
// Extract code
await expect.poll(() => emailProvider.records.length).toBe(1);
const token = extractCode(emailProvider.records[0].body);
// Reset password
await actions.resetPassword({
body: {
email: "test@example.com",
token,
newPassword: "NewPassword456",
},
});
// Attempt to use the same token again should fail
await expect(
actions.resetPassword({
body: {
email: "test@example.com",
token,
newPassword: "AnotherPassword789",
},
}),
).rejects.toThrowError(BadRequestError);
});
it("should invalidate all sessions after password reset", async ({
expect,
}) => {
const {
credentialService,
sessionService,
cryptoProvider,
emailProvider,
actions,
} = await setup();
// Create a test user with credentials
const hashedPassword = await cryptoProvider.hashPassword("OldPassword123");
const user = await credentialService.users().create({
username: "testuser",
email: "test@example.com",
roles: ["user"],
});
await credentialService.identities().create({
userId: user.id,
provider: "credentials",
providerUserId: "test@example.com",
providerData: { password: hashedPassword },
});
// Create some sessions
await sessionService.createSession(user, 3600);
await sessionService.createSession(user, 3600);
// Verify sessions exist
const existingSessions = await sessionService.sessions().findMany({
where: { userId: { eq: user.id } },
});
expect(existingSessions).toHaveLength(2);
// Request password reset and reset password
await credentialService.requestPasswordReset("test@example.com");
await expect.poll(() => emailProvider.records.length).toBe(1);
const token = extractCode(emailProvider.records[0].body);
await actions.resetPassword({
body: {
email: "test@example.com",
token,
newPassword: "NewPassword456",
},
});
// Verify all sessions are deleted
const remainingSessions = await sessionService.sessions().findMany({
where: { userId: { eq: user.id } },
});
expect(remainingSessions).toHaveLength(0);
});
it("should enforce minimum password length", async ({ expect }) => {
const { credentialService, cryptoProvider, emailProvider, actions } =
await setup();
// Create a test user with credentials
const hashedPassword = await cryptoProvider.hashPassword("OldPassword123");
const user = await credentialService.users().create({
username: "testuser",
email: "test@example.com",
roles: ["user"],
});
await credentialService.identities().create({
userId: user.id,
provider: "credentials",
providerUserId: "test@example.com",
providerData: { password: hashedPassword },
});
// Request password reset
await credentialService.requestPasswordReset("test@example.com");
await expect.poll(() => emailProvider.records.length).toBe(1);
const token = extractCode(emailProvider.records[0].body);
// Attempt to reset with short password (less than 8 characters)
await expect(
actions.resetPassword({
body: {
email: "test@example.com",
token,
newPassword: "Short1", // Only 6 characters
},
}),
).rejects.toThrow();
});
it("should respect rate limiting on password reset requests", async ({
expect,
}) => {
const {
credentialService,
cryptoProvider,
dateTimeProvider,
emailProvider,
actions,
} = await setup();
// Create a test user with credentials
const hashedPassword = await cryptoProvider.hashPassword("OldPassword123");
const user = await credentialService.users().create({
username: "testuser",
email: "test@example.com",
roles: ["user"],
});
await credentialService.identities().create({
userId: user.id,
provider: "credentials",
providerUserId: "test@example.com",
providerData: { password: hashedPassword },
});
// Request password reset multiple times within cooldown period
await actions.requestPasswordReset({
body: {
email: "test@example.com",
},
});
await expect.poll(() => emailProvider.records.length).toBe(1);
// Second request should be silently ignored (cooldown)
await actions.requestPasswordReset({
body: {
email: "test@example.com",
},
});
// Still only 1 email
expect(emailProvider.records.length).toBe(1);
// Wait for cooldown to pass (90 seconds default)
dateTimeProvider.travel(91, "seconds");
// Now should work
await actions.requestPasswordReset({
body: {
email: "test@example.com",
},
});
await expect.poll(() => emailProvider.records.length).toBe(2);
});
});