Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
490 lines
16 KiB
TypeScript
490 lines
16 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
// Mock better-auth modules before importing AuthService (pulled in by AuthController)
|
|
vi.mock("better-auth/node", () => ({
|
|
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
|
|
}));
|
|
|
|
vi.mock("better-auth", () => ({
|
|
betterAuth: vi.fn().mockReturnValue({
|
|
handler: vi.fn(),
|
|
api: { getSession: vi.fn() },
|
|
}),
|
|
}));
|
|
|
|
vi.mock("better-auth/adapters/prisma", () => ({
|
|
prismaAdapter: vi.fn().mockReturnValue({}),
|
|
}));
|
|
|
|
vi.mock("better-auth/plugins", () => ({
|
|
genericOAuth: vi.fn().mockReturnValue({ id: "generic-oauth" }),
|
|
}));
|
|
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { HttpException, HttpStatus, UnauthorizedException } from "@nestjs/common";
|
|
import type { AuthUser, AuthSession } from "@mosaic/shared";
|
|
import type { Request as ExpressRequest, Response as ExpressResponse } from "express";
|
|
import { AuthController } from "./auth.controller";
|
|
import { AuthService } from "./auth.service";
|
|
|
|
describe("AuthController", () => {
|
|
let controller: AuthController;
|
|
|
|
const mockNodeHandler = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const mockAuthService = {
|
|
getAuth: vi.fn(),
|
|
getNodeHandler: vi.fn().mockReturnValue(mockNodeHandler),
|
|
getAuthConfig: vi.fn(),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
controllers: [AuthController],
|
|
providers: [
|
|
{
|
|
provide: AuthService,
|
|
useValue: mockAuthService,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
controller = module.get<AuthController>(AuthController);
|
|
|
|
vi.clearAllMocks();
|
|
|
|
// Restore mock implementations after clearAllMocks
|
|
mockAuthService.getNodeHandler.mockReturnValue(mockNodeHandler);
|
|
mockNodeHandler.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe("handleAuth", () => {
|
|
it("should delegate to BetterAuth node handler with Express req/res", async () => {
|
|
const mockRequest = {
|
|
method: "GET",
|
|
url: "/auth/session",
|
|
headers: {},
|
|
ip: "127.0.0.1",
|
|
socket: { remoteAddress: "127.0.0.1" },
|
|
} as unknown as ExpressRequest;
|
|
|
|
const mockResponse = {
|
|
headersSent: false,
|
|
} as unknown as ExpressResponse;
|
|
|
|
await controller.handleAuth(mockRequest, mockResponse);
|
|
|
|
expect(mockAuthService.getNodeHandler).toHaveBeenCalled();
|
|
expect(mockNodeHandler).toHaveBeenCalledWith(mockRequest, mockResponse);
|
|
});
|
|
|
|
it("should throw HttpException with 500 when handler throws before headers sent", async () => {
|
|
const handlerError = new Error("BetterAuth internal failure");
|
|
mockNodeHandler.mockRejectedValueOnce(handlerError);
|
|
|
|
const mockRequest = {
|
|
method: "POST",
|
|
url: "/auth/sign-in",
|
|
headers: {},
|
|
ip: "192.168.1.10",
|
|
socket: { remoteAddress: "192.168.1.10" },
|
|
} as unknown as ExpressRequest;
|
|
|
|
const mockResponse = {
|
|
headersSent: false,
|
|
} as unknown as ExpressResponse;
|
|
|
|
try {
|
|
await controller.handleAuth(mockRequest, mockResponse);
|
|
// Should not reach here
|
|
expect.unreachable("Expected HttpException to be thrown");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
|
|
expect((err as HttpException).getResponse()).toBe(
|
|
"Unable to complete authentication. Please try again in a moment."
|
|
);
|
|
}
|
|
});
|
|
|
|
it("should preserve better-call status and body for handler APIError", async () => {
|
|
const apiError = {
|
|
statusCode: HttpStatus.BAD_REQUEST,
|
|
message: "Invalid OAuth configuration",
|
|
body: {
|
|
message: "Invalid OAuth configuration",
|
|
code: "INVALID_OAUTH_CONFIGURATION",
|
|
},
|
|
};
|
|
mockNodeHandler.mockRejectedValueOnce(apiError);
|
|
|
|
const mockRequest = {
|
|
method: "POST",
|
|
url: "/auth/sign-in/oauth2",
|
|
headers: {},
|
|
ip: "192.168.1.10",
|
|
socket: { remoteAddress: "192.168.1.10" },
|
|
} as unknown as ExpressRequest;
|
|
|
|
const mockResponse = {
|
|
headersSent: false,
|
|
} as unknown as ExpressResponse;
|
|
|
|
try {
|
|
await controller.handleAuth(mockRequest, mockResponse);
|
|
expect.unreachable("Expected HttpException to be thrown");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
expect((err as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST);
|
|
expect((err as HttpException).getResponse()).toMatchObject({
|
|
message: "Invalid OAuth configuration",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("should log warning and not throw when handler throws after headers sent", async () => {
|
|
const handlerError = new Error("Stream interrupted");
|
|
mockNodeHandler.mockRejectedValueOnce(handlerError);
|
|
|
|
const mockRequest = {
|
|
method: "POST",
|
|
url: "/auth/sign-up",
|
|
headers: {},
|
|
ip: "10.0.0.5",
|
|
socket: { remoteAddress: "10.0.0.5" },
|
|
} as unknown as ExpressRequest;
|
|
|
|
const mockResponse = {
|
|
headersSent: true,
|
|
} as unknown as ExpressResponse;
|
|
|
|
// Should not throw when headers already sent
|
|
await expect(controller.handleAuth(mockRequest, mockResponse)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("should handle non-Error thrown values", async () => {
|
|
mockNodeHandler.mockRejectedValueOnce("string error");
|
|
|
|
const mockRequest = {
|
|
method: "GET",
|
|
url: "/auth/callback",
|
|
headers: {},
|
|
ip: "127.0.0.1",
|
|
socket: { remoteAddress: "127.0.0.1" },
|
|
} as unknown as ExpressRequest;
|
|
|
|
const mockResponse = {
|
|
headersSent: false,
|
|
} as unknown as ExpressResponse;
|
|
|
|
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(HttpException);
|
|
});
|
|
});
|
|
|
|
describe("getConfig", () => {
|
|
it("should return auth config from service", async () => {
|
|
const mockConfig = {
|
|
providers: [
|
|
{ id: "email", name: "Email", type: "credentials" as const },
|
|
{ id: "authentik", name: "Authentik", type: "oauth" as const },
|
|
],
|
|
};
|
|
mockAuthService.getAuthConfig.mockResolvedValue(mockConfig);
|
|
|
|
const result = await controller.getConfig();
|
|
|
|
expect(result).toEqual(mockConfig);
|
|
expect(mockAuthService.getAuthConfig).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should return correct response shape with only email provider", async () => {
|
|
const mockConfig = {
|
|
providers: [{ id: "email", name: "Email", type: "credentials" as const }],
|
|
};
|
|
mockAuthService.getAuthConfig.mockResolvedValue(mockConfig);
|
|
|
|
const result = await controller.getConfig();
|
|
|
|
expect(result).toEqual(mockConfig);
|
|
expect(result.providers).toHaveLength(1);
|
|
expect(result.providers[0]).toEqual({
|
|
id: "email",
|
|
name: "Email",
|
|
type: "credentials",
|
|
});
|
|
});
|
|
|
|
it("should never leak secrets in auth config response", async () => {
|
|
// Set ALL sensitive environment variables with known values
|
|
const sensitiveEnv: Record<string, string> = {
|
|
OIDC_CLIENT_SECRET: "test-client-secret",
|
|
OIDC_CLIENT_ID: "test-client-id",
|
|
OIDC_ISSUER: "https://auth.test.com/",
|
|
OIDC_REDIRECT_URI: "https://app.test.com/auth/oauth2/callback/authentik",
|
|
BETTER_AUTH_SECRET: "test-better-auth-secret",
|
|
JWT_SECRET: "test-jwt-secret",
|
|
CSRF_SECRET: "test-csrf-secret",
|
|
DATABASE_URL: "postgresql://user:password@localhost/db",
|
|
OIDC_ENABLED: "true",
|
|
};
|
|
|
|
const originalEnv: Record<string, string | undefined> = {};
|
|
for (const [key, value] of Object.entries(sensitiveEnv)) {
|
|
originalEnv[key] = process.env[key];
|
|
process.env[key] = value;
|
|
}
|
|
|
|
try {
|
|
// Mock the service to return a realistic config with both providers
|
|
const mockConfig = {
|
|
providers: [
|
|
{ id: "email", name: "Email", type: "credentials" as const },
|
|
{ id: "authentik", name: "Authentik", type: "oauth" as const },
|
|
],
|
|
};
|
|
mockAuthService.getAuthConfig.mockResolvedValue(mockConfig);
|
|
|
|
const result = await controller.getConfig();
|
|
const serialized = JSON.stringify(result);
|
|
|
|
// Assert no secret values leak into the serialized response
|
|
const forbiddenPatterns = [
|
|
"test-client-secret",
|
|
"test-client-id",
|
|
"test-better-auth-secret",
|
|
"test-jwt-secret",
|
|
"test-csrf-secret",
|
|
"auth.test.com",
|
|
"callback",
|
|
"password",
|
|
];
|
|
|
|
for (const pattern of forbiddenPatterns) {
|
|
expect(serialized).not.toContain(pattern);
|
|
}
|
|
|
|
// Assert response contains ONLY expected fields
|
|
expect(result).toHaveProperty("providers");
|
|
expect(Object.keys(result)).toEqual(["providers"]);
|
|
expect(Array.isArray(result.providers)).toBe(true);
|
|
|
|
for (const provider of result.providers) {
|
|
const keys = Object.keys(provider);
|
|
expect(keys).toEqual(expect.arrayContaining(["id", "name", "type"]));
|
|
expect(keys).toHaveLength(3);
|
|
}
|
|
} finally {
|
|
// Restore original environment
|
|
for (const [key] of Object.entries(sensitiveEnv)) {
|
|
if (originalEnv[key] === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = originalEnv[key];
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("getSession", () => {
|
|
it("should return user and session data", () => {
|
|
const mockUser: AuthUser = {
|
|
id: "user-123",
|
|
email: "test@example.com",
|
|
name: "Test User",
|
|
workspaceId: "workspace-123",
|
|
};
|
|
|
|
const mockSession = {
|
|
id: "session-123",
|
|
token: "session-token",
|
|
expiresAt: new Date(Date.now() + 86400000),
|
|
};
|
|
|
|
const mockRequest = {
|
|
user: mockUser,
|
|
session: mockSession,
|
|
};
|
|
|
|
const result = controller.getSession(mockRequest);
|
|
|
|
const expected: AuthSession = {
|
|
user: mockUser,
|
|
session: {
|
|
id: mockSession.id,
|
|
token: mockSession.token,
|
|
expiresAt: mockSession.expiresAt,
|
|
},
|
|
};
|
|
|
|
expect(result).toEqual(expected);
|
|
});
|
|
|
|
it("should throw UnauthorizedException when req.user is undefined", () => {
|
|
const mockRequest = {
|
|
session: {
|
|
id: "session-123",
|
|
token: "session-token",
|
|
expiresAt: new Date(Date.now() + 86400000),
|
|
},
|
|
};
|
|
|
|
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
|
|
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
|
"Missing authentication context"
|
|
);
|
|
});
|
|
|
|
it("should throw UnauthorizedException when req.session is undefined", () => {
|
|
const mockRequest = {
|
|
user: {
|
|
id: "user-123",
|
|
email: "test@example.com",
|
|
name: "Test User",
|
|
},
|
|
};
|
|
|
|
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
|
|
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
|
"Missing authentication context"
|
|
);
|
|
});
|
|
|
|
it("should throw UnauthorizedException when both req.user and req.session are undefined", () => {
|
|
const mockRequest = {};
|
|
|
|
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
|
|
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
|
"Missing authentication context"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getProfile", () => {
|
|
it("should return complete user profile with identity fields", () => {
|
|
const mockUser: AuthUser = {
|
|
id: "user-123",
|
|
email: "test@example.com",
|
|
name: "Test User",
|
|
image: "https://example.com/avatar.jpg",
|
|
emailVerified: true,
|
|
};
|
|
|
|
const result = controller.getProfile(mockUser);
|
|
|
|
expect(result).toEqual({
|
|
id: mockUser.id,
|
|
email: mockUser.email,
|
|
name: mockUser.name,
|
|
image: mockUser.image,
|
|
emailVerified: mockUser.emailVerified,
|
|
});
|
|
});
|
|
|
|
it("should return user profile with only required fields", () => {
|
|
const mockUser: AuthUser = {
|
|
id: "user-123",
|
|
email: "test@example.com",
|
|
name: "Test User",
|
|
};
|
|
|
|
const result = controller.getProfile(mockUser);
|
|
|
|
expect(result).toEqual({
|
|
id: mockUser.id,
|
|
email: mockUser.email,
|
|
name: mockUser.name,
|
|
});
|
|
// Workspace fields are not included — served by GET /api/workspaces
|
|
expect(result).not.toHaveProperty("workspaceId");
|
|
expect(result).not.toHaveProperty("currentWorkspaceId");
|
|
expect(result).not.toHaveProperty("workspaceRole");
|
|
});
|
|
});
|
|
|
|
describe("getClientIp (via handleAuth)", () => {
|
|
it("should extract IP from X-Forwarded-For with single IP", async () => {
|
|
const mockRequest = {
|
|
method: "GET",
|
|
url: "/auth/callback",
|
|
headers: { "x-forwarded-for": "203.0.113.50" },
|
|
ip: "127.0.0.1",
|
|
socket: { remoteAddress: "127.0.0.1" },
|
|
} as unknown as ExpressRequest;
|
|
|
|
const mockResponse = {
|
|
headersSent: false,
|
|
} as unknown as ExpressResponse;
|
|
|
|
// Spy on the logger to verify the extracted IP
|
|
const debugSpy = vi.spyOn(controller["logger"], "debug");
|
|
|
|
await controller.handleAuth(mockRequest, mockResponse);
|
|
|
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
|
|
});
|
|
|
|
it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => {
|
|
const mockRequest = {
|
|
method: "GET",
|
|
url: "/auth/callback",
|
|
headers: { "x-forwarded-for": "203.0.113.50, 70.41.3.18" },
|
|
ip: "127.0.0.1",
|
|
socket: { remoteAddress: "127.0.0.1" },
|
|
} as unknown as ExpressRequest;
|
|
|
|
const mockResponse = {
|
|
headersSent: false,
|
|
} as unknown as ExpressResponse;
|
|
|
|
const debugSpy = vi.spyOn(controller["logger"], "debug");
|
|
|
|
await controller.handleAuth(mockRequest, mockResponse);
|
|
|
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
|
|
// Ensure it does NOT contain the second IP in the extracted position
|
|
expect(debugSpy).toHaveBeenCalledWith(expect.not.stringContaining("70.41.3.18"));
|
|
});
|
|
|
|
it("should extract first IP from X-Forwarded-For as array", async () => {
|
|
const mockRequest = {
|
|
method: "GET",
|
|
url: "/auth/callback",
|
|
headers: { "x-forwarded-for": ["203.0.113.50", "70.41.3.18"] },
|
|
ip: "127.0.0.1",
|
|
socket: { remoteAddress: "127.0.0.1" },
|
|
} as unknown as ExpressRequest;
|
|
|
|
const mockResponse = {
|
|
headersSent: false,
|
|
} as unknown as ExpressResponse;
|
|
|
|
const debugSpy = vi.spyOn(controller["logger"], "debug");
|
|
|
|
await controller.handleAuth(mockRequest, mockResponse);
|
|
|
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
|
|
});
|
|
|
|
it("should fallback to req.ip when no X-Forwarded-For header", async () => {
|
|
const mockRequest = {
|
|
method: "GET",
|
|
url: "/auth/callback",
|
|
headers: {},
|
|
ip: "192.168.1.100",
|
|
socket: { remoteAddress: "192.168.1.100" },
|
|
} as unknown as ExpressRequest;
|
|
|
|
const mockResponse = {
|
|
headersSent: false,
|
|
} as unknown as ExpressResponse;
|
|
|
|
const debugSpy = vi.spyOn(controller["logger"], "debug");
|
|
|
|
await controller.handleAuth(mockRequest, mockResponse);
|
|
|
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("192.168.1.100"));
|
|
});
|
|
});
|
|
});
|