Files
stack/apps/api/src/auth/auth.controller.spec.ts
Jason Woltje 128431ba58
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
fix(api,web): separate workspace context from auth session (#551)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 15:14:29 +00:00

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"));
});
});
});