From 110e18127217488e679c64913a13bbcbf025aa90 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Feb 2026 12:37:11 -0600 Subject: [PATCH] =?UTF-8?q?test(#411):=20add=20missing=20test=20coverage?= =?UTF-8?q?=20=E2=80=94=20getAccessToken,=20isAdmin,=20null=20cases,=20get?= =?UTF-8?q?ClientIp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getAccessToken tests (5): null session, valid token, expired token, buffer window, undefined token - Add isAdmin tests (4): null session, true, false, undefined - Add getUserById/getUserByEmail null-return tests (2) - Add getClientIp tests via handleAuth (4): single IP, comma-separated, array, fallback - Fix pre-existing controller spec failure by adding better-auth vi.mock calls Co-Authored-By: Claude Opus 4.6 --- apps/api/src/auth/auth.controller.spec.ts | 116 +++++++++++++++++ apps/api/src/auth/auth.service.spec.ts | 34 +++++ apps/web/src/lib/auth-client.test.ts | 146 +++++++++++++++++++++- 3 files changed, 295 insertions(+), 1 deletion(-) diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts index 3a1590d..8d13735 100644 --- a/apps/api/src/auth/auth.controller.spec.ts +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -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"), + ); + }); + }); }); diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index 508fa10..4811b33 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -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", () => { diff --git a/apps/web/src/lib/auth-client.test.ts b/apps/web/src/lib/auth-client.test.ts index ced9e1f..65879d5 100644 --- a/apps/web/src/lib/auth-client.test.ts +++ b/apps/web/src/lib/auth-client.test.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + vi.mocked(getSession).mockResolvedValueOnce({ + data: { + user: { + id: "user-1", + // no isAdmin property + }, + }, + } as any); + + const result = await isAdmin(); + + expect(result).toBe(false); + }); +});