Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
390 lines
11 KiB
TypeScript
390 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import {
|
|
ConflictException,
|
|
ForbiddenException,
|
|
InternalServerErrorException,
|
|
UnauthorizedException,
|
|
} from "@nestjs/common";
|
|
import { hash } from "bcryptjs";
|
|
import { LocalAuthService } from "./local-auth.service";
|
|
import { PrismaService } from "../../prisma/prisma.service";
|
|
|
|
describe("LocalAuthService", () => {
|
|
let service: LocalAuthService;
|
|
|
|
const mockTxSession = {
|
|
create: vi.fn(),
|
|
};
|
|
|
|
const mockTxWorkspace = {
|
|
findFirst: vi.fn(),
|
|
create: vi.fn(),
|
|
};
|
|
|
|
const mockTxWorkspaceMember = {
|
|
create: vi.fn(),
|
|
};
|
|
|
|
const mockTxUser = {
|
|
create: vi.fn(),
|
|
findUnique: vi.fn(),
|
|
};
|
|
|
|
const mockTx = {
|
|
user: mockTxUser,
|
|
workspace: mockTxWorkspace,
|
|
workspaceMember: mockTxWorkspaceMember,
|
|
session: mockTxSession,
|
|
};
|
|
|
|
const mockPrismaService = {
|
|
user: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
session: {
|
|
create: vi.fn(),
|
|
},
|
|
$transaction: vi
|
|
.fn()
|
|
.mockImplementation((fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
|
|
};
|
|
|
|
const originalEnv = {
|
|
BREAKGLASS_SETUP_TOKEN: process.env.BREAKGLASS_SETUP_TOKEN,
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
LocalAuthService,
|
|
{
|
|
provide: PrismaService,
|
|
useValue: mockPrismaService,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<LocalAuthService>(LocalAuthService);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
if (originalEnv.BREAKGLASS_SETUP_TOKEN !== undefined) {
|
|
process.env.BREAKGLASS_SETUP_TOKEN = originalEnv.BREAKGLASS_SETUP_TOKEN;
|
|
} else {
|
|
delete process.env.BREAKGLASS_SETUP_TOKEN;
|
|
}
|
|
});
|
|
|
|
describe("setup", () => {
|
|
const validSetupArgs = {
|
|
email: "admin@example.com",
|
|
name: "Break Glass Admin",
|
|
password: "securePassword123!",
|
|
setupToken: "valid-token-123",
|
|
};
|
|
|
|
const mockCreatedUser = {
|
|
id: "user-uuid-123",
|
|
email: "admin@example.com",
|
|
name: "Break Glass Admin",
|
|
isLocalAuth: true,
|
|
createdAt: new Date("2026-02-28T00:00:00Z"),
|
|
};
|
|
|
|
const mockWorkspace = {
|
|
id: "workspace-uuid-123",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
process.env.BREAKGLASS_SETUP_TOKEN = "valid-token-123";
|
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
|
mockTxUser.create.mockResolvedValue(mockCreatedUser);
|
|
mockTxWorkspace.findFirst.mockResolvedValue(mockWorkspace);
|
|
mockTxWorkspaceMember.create.mockResolvedValue({});
|
|
mockTxSession.create.mockResolvedValue({});
|
|
});
|
|
|
|
it("should create a local auth user with hashed password", async () => {
|
|
const result = await service.setup(
|
|
validSetupArgs.email,
|
|
validSetupArgs.name,
|
|
validSetupArgs.password,
|
|
validSetupArgs.setupToken
|
|
);
|
|
|
|
expect(result.user).toEqual(mockCreatedUser);
|
|
expect(result.session.token).toBeDefined();
|
|
expect(result.session.token.length).toBeGreaterThan(0);
|
|
expect(result.session.expiresAt).toBeInstanceOf(Date);
|
|
expect(result.session.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
|
|
|
expect(mockTxUser.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
email: "admin@example.com",
|
|
name: "Break Glass Admin",
|
|
isLocalAuth: true,
|
|
emailVerified: true,
|
|
passwordHash: expect.any(String) as string,
|
|
}),
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
isLocalAuth: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should assign OWNER role on default workspace", async () => {
|
|
await service.setup(
|
|
validSetupArgs.email,
|
|
validSetupArgs.name,
|
|
validSetupArgs.password,
|
|
validSetupArgs.setupToken
|
|
);
|
|
|
|
expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({
|
|
data: {
|
|
workspaceId: "workspace-uuid-123",
|
|
userId: "user-uuid-123",
|
|
role: "OWNER",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should create a new workspace if none exists", async () => {
|
|
mockTxWorkspace.findFirst.mockResolvedValue(null);
|
|
mockTxWorkspace.create.mockResolvedValue({ id: "new-workspace-uuid" });
|
|
|
|
await service.setup(
|
|
validSetupArgs.email,
|
|
validSetupArgs.name,
|
|
validSetupArgs.password,
|
|
validSetupArgs.setupToken
|
|
);
|
|
|
|
expect(mockTxWorkspace.create).toHaveBeenCalledWith({
|
|
data: {
|
|
name: "Default Workspace",
|
|
ownerId: "user-uuid-123",
|
|
settings: {},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({
|
|
data: {
|
|
workspaceId: "new-workspace-uuid",
|
|
userId: "user-uuid-123",
|
|
role: "OWNER",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should create a BetterAuth-compatible session", async () => {
|
|
await service.setup(
|
|
validSetupArgs.email,
|
|
validSetupArgs.name,
|
|
validSetupArgs.password,
|
|
validSetupArgs.setupToken,
|
|
"192.168.1.1",
|
|
"TestAgent/1.0"
|
|
);
|
|
|
|
expect(mockTxSession.create).toHaveBeenCalledWith({
|
|
data: {
|
|
userId: "user-uuid-123",
|
|
token: expect.any(String) as string,
|
|
expiresAt: expect.any(Date) as Date,
|
|
ipAddress: "192.168.1.1",
|
|
userAgent: "TestAgent/1.0",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should reject when BREAKGLASS_SETUP_TOKEN is not set", async () => {
|
|
delete process.env.BREAKGLASS_SETUP_TOKEN;
|
|
|
|
await expect(
|
|
service.setup(
|
|
validSetupArgs.email,
|
|
validSetupArgs.name,
|
|
validSetupArgs.password,
|
|
validSetupArgs.setupToken
|
|
)
|
|
).rejects.toThrow(ForbiddenException);
|
|
});
|
|
|
|
it("should reject when BREAKGLASS_SETUP_TOKEN is empty", async () => {
|
|
process.env.BREAKGLASS_SETUP_TOKEN = "";
|
|
|
|
await expect(
|
|
service.setup(
|
|
validSetupArgs.email,
|
|
validSetupArgs.name,
|
|
validSetupArgs.password,
|
|
validSetupArgs.setupToken
|
|
)
|
|
).rejects.toThrow(ForbiddenException);
|
|
});
|
|
|
|
it("should reject when setup token does not match", async () => {
|
|
await expect(
|
|
service.setup(
|
|
validSetupArgs.email,
|
|
validSetupArgs.name,
|
|
validSetupArgs.password,
|
|
"wrong-token"
|
|
)
|
|
).rejects.toThrow(ForbiddenException);
|
|
});
|
|
|
|
it("should reject when email already exists", async () => {
|
|
mockPrismaService.user.findUnique.mockResolvedValue({
|
|
id: "existing-user",
|
|
email: "admin@example.com",
|
|
});
|
|
|
|
await expect(
|
|
service.setup(
|
|
validSetupArgs.email,
|
|
validSetupArgs.name,
|
|
validSetupArgs.password,
|
|
validSetupArgs.setupToken
|
|
)
|
|
).rejects.toThrow(ConflictException);
|
|
});
|
|
|
|
it("should return session token and expiry", async () => {
|
|
const result = await service.setup(
|
|
validSetupArgs.email,
|
|
validSetupArgs.name,
|
|
validSetupArgs.password,
|
|
validSetupArgs.setupToken
|
|
);
|
|
|
|
expect(typeof result.session.token).toBe("string");
|
|
expect(result.session.token.length).toBe(64); // 32 bytes hex
|
|
expect(result.session.expiresAt).toBeInstanceOf(Date);
|
|
});
|
|
});
|
|
|
|
describe("login", () => {
|
|
const validPasswordHash = "$2a$12$LJ3m4ys3Lz/YgP7xYz5k5uU6b5F6X1234567890abcdefghijkl";
|
|
|
|
beforeEach(async () => {
|
|
// Create a real bcrypt hash for testing
|
|
const realHash = await hash("securePassword123!", 4); // Low rounds for test speed
|
|
mockPrismaService.user.findUnique.mockResolvedValue({
|
|
id: "user-uuid-123",
|
|
email: "admin@example.com",
|
|
name: "Break Glass Admin",
|
|
isLocalAuth: true,
|
|
passwordHash: realHash,
|
|
deactivatedAt: null,
|
|
});
|
|
mockPrismaService.session.create.mockResolvedValue({});
|
|
});
|
|
|
|
it("should authenticate a valid local auth user", async () => {
|
|
const result = await service.login("admin@example.com", "securePassword123!");
|
|
|
|
expect(result.user).toEqual({
|
|
id: "user-uuid-123",
|
|
email: "admin@example.com",
|
|
name: "Break Glass Admin",
|
|
});
|
|
expect(result.session.token).toBeDefined();
|
|
expect(result.session.expiresAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("should create a session with ip and user agent", async () => {
|
|
await service.login("admin@example.com", "securePassword123!", "10.0.0.1", "Mozilla/5.0");
|
|
|
|
expect(mockPrismaService.session.create).toHaveBeenCalledWith({
|
|
data: {
|
|
userId: "user-uuid-123",
|
|
token: expect.any(String) as string,
|
|
expiresAt: expect.any(Date) as Date,
|
|
ipAddress: "10.0.0.1",
|
|
userAgent: "Mozilla/5.0",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should reject when user does not exist", async () => {
|
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(service.login("nonexistent@example.com", "password123456")).rejects.toThrow(
|
|
UnauthorizedException
|
|
);
|
|
});
|
|
|
|
it("should reject when user is not a local auth user", async () => {
|
|
mockPrismaService.user.findUnique.mockResolvedValue({
|
|
id: "user-uuid-123",
|
|
email: "admin@example.com",
|
|
name: "OIDC User",
|
|
isLocalAuth: false,
|
|
passwordHash: null,
|
|
deactivatedAt: null,
|
|
});
|
|
|
|
await expect(service.login("admin@example.com", "password123456")).rejects.toThrow(
|
|
UnauthorizedException
|
|
);
|
|
});
|
|
|
|
it("should reject when user is deactivated", async () => {
|
|
const realHash = await hash("securePassword123!", 4);
|
|
mockPrismaService.user.findUnique.mockResolvedValue({
|
|
id: "user-uuid-123",
|
|
email: "admin@example.com",
|
|
name: "Deactivated User",
|
|
isLocalAuth: true,
|
|
passwordHash: realHash,
|
|
deactivatedAt: new Date("2026-01-01"),
|
|
});
|
|
|
|
await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow(
|
|
new UnauthorizedException("Account has been deactivated")
|
|
);
|
|
});
|
|
|
|
it("should reject when password is incorrect", async () => {
|
|
await expect(service.login("admin@example.com", "wrongPassword123!")).rejects.toThrow(
|
|
UnauthorizedException
|
|
);
|
|
});
|
|
|
|
it("should throw InternalServerError when local auth user has no password hash", async () => {
|
|
mockPrismaService.user.findUnique.mockResolvedValue({
|
|
id: "user-uuid-123",
|
|
email: "admin@example.com",
|
|
name: "Broken User",
|
|
isLocalAuth: true,
|
|
passwordHash: null,
|
|
deactivatedAt: null,
|
|
});
|
|
|
|
await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow(
|
|
InternalServerErrorException
|
|
);
|
|
});
|
|
|
|
it("should not reveal whether email exists in error messages", async () => {
|
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
|
|
|
try {
|
|
await service.login("nonexistent@example.com", "password123456");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(UnauthorizedException);
|
|
expect((error as UnauthorizedException).message).toBe("Invalid email or password");
|
|
}
|
|
});
|
|
});
|
|
});
|