fix(#411): auth & frontend remediation — all 6 phases complete #418
@@ -1,4 +1,25 @@
|
||||
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 } from "@nestjs/common";
|
||||
import type { AuthUser, AuthSession } from "@mosaic/shared";
|
||||
@@ -337,4 +358,99 @@ describe("AuthController", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,6 +89,23 @@ describe("AuthService", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when user is not found", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getUserById("nonexistent-id");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "nonexistent-id" },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
authProviderId: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserByEmail", () => {
|
||||
@@ -115,6 +132,23 @@ describe("AuthService", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when user is not found", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getUserByEmail("unknown@example.com");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { email: "unknown@example.com" },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
authProviderId: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOidcProviderReachable", () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock("./config", () => ({
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
const { signInWithCredentials } = await import("./auth-client");
|
||||
const { signInWithCredentials, getAccessToken, isAdmin, getSession } = await import("./auth-client");
|
||||
|
||||
/**
|
||||
* Helper to build a mock Response object that behaves like the Fetch API Response.
|
||||
@@ -218,3 +218,147 @@ describe("signInWithCredentials PDA-friendly language compliance", (): void => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// AUTH-030: getAccessToken tests
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getAccessToken", (): void => {
|
||||
afterEach((): void => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should return null when no session exists (session.data is null)", async (): Promise<void> => {
|
||||
vi.mocked(getSession).mockResolvedValueOnce({ data: null } as any);
|
||||
|
||||
const result = await getAccessToken();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return accessToken when session has valid, non-expired token", async (): Promise<void> => {
|
||||
vi.mocked(getSession).mockResolvedValueOnce({
|
||||
data: {
|
||||
user: {
|
||||
id: "user-1",
|
||||
accessToken: "valid-token-abc",
|
||||
tokenExpiresAt: Date.now() + 300_000, // 5 minutes from now
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await getAccessToken();
|
||||
|
||||
expect(result).toBe("valid-token-abc");
|
||||
});
|
||||
|
||||
it("should return null when token is expired (tokenExpiresAt in the past)", async (): Promise<void> => {
|
||||
vi.mocked(getSession).mockResolvedValueOnce({
|
||||
data: {
|
||||
user: {
|
||||
id: "user-1",
|
||||
accessToken: "expired-token",
|
||||
tokenExpiresAt: Date.now() - 120_000, // 2 minutes ago
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await getAccessToken();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when token expires within 60-second buffer window", async (): Promise<void> => {
|
||||
vi.mocked(getSession).mockResolvedValueOnce({
|
||||
data: {
|
||||
user: {
|
||||
id: "user-1",
|
||||
accessToken: "almost-expired-token",
|
||||
tokenExpiresAt: Date.now() + 30_000, // 30 seconds from now (within 60s buffer)
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await getAccessToken();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when accessToken is undefined on user object", async (): Promise<void> => {
|
||||
vi.mocked(getSession).mockResolvedValueOnce({
|
||||
data: {
|
||||
user: {
|
||||
id: "user-1",
|
||||
// no accessToken property
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await getAccessToken();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// AUTH-030: isAdmin tests
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("isAdmin", (): void => {
|
||||
afterEach((): void => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should return false when no session exists", async (): Promise<void> => {
|
||||
vi.mocked(getSession).mockResolvedValueOnce({ data: null } as any);
|
||||
|
||||
const result = await isAdmin();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when user.isAdmin is true", async (): Promise<void> => {
|
||||
vi.mocked(getSession).mockResolvedValueOnce({
|
||||
data: {
|
||||
user: {
|
||||
id: "admin-1",
|
||||
isAdmin: true,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await isAdmin();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when user.isAdmin is false", async (): Promise<void> => {
|
||||
vi.mocked(getSession).mockResolvedValueOnce({
|
||||
data: {
|
||||
user: {
|
||||
id: "user-1",
|
||||
isAdmin: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await isAdmin();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when user.isAdmin is undefined", async (): Promise<void> => {
|
||||
vi.mocked(getSession).mockResolvedValueOnce({
|
||||
data: {
|
||||
user: {
|
||||
id: "user-1",
|
||||
// no isAdmin property
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await isAdmin();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user