A personal media tracker built on the AT Protocol
opnshelf.xyz
1import { type ExecutionContext, UnauthorizedException } from "@nestjs/common";
2import { Test, type TestingModule } from "@nestjs/testing";
3
4// Mock PrismaService before importing AuthService/AuthGuard
5jest.mock("../prisma/prisma.service", () => ({
6 PrismaService: jest.fn(),
7}));
8
9// Mock @atproto modules to prevent import errors
10jest.mock("@atproto/oauth-client-node", () => ({}));
11jest.mock("@atproto/api", () => ({}));
12
13import { AuthGuard } from "./auth.guard";
14import { AuthService } from "./auth.service";
15
16describe("AuthGuard", () => {
17 let guard: AuthGuard;
18 let authService: jest.Mocked<AuthService>;
19
20 const mockAuthService = {
21 getSessionById: jest.fn(),
22 restore: jest.fn(),
23 };
24
25 const createMockExecutionContext = (
26 cookies: Record<string, string> = {},
27 headers: Record<string, string> = {},
28 ) => {
29 const mockRequest = {
30 cookies,
31 headers,
32 };
33
34 return {
35 switchToHttp: () => ({
36 getRequest: () => mockRequest,
37 }),
38 } as unknown as ExecutionContext;
39 };
40
41 beforeEach(async () => {
42 jest.clearAllMocks();
43
44 const module: TestingModule = await Test.createTestingModule({
45 providers: [
46 AuthGuard,
47 { provide: AuthService, useValue: mockAuthService },
48 ],
49 }).compile();
50
51 guard = module.get<AuthGuard>(AuthGuard);
52 authService = module.get(AuthService);
53 });
54
55 describe("canActivate", () => {
56 it("should throw UnauthorizedException when no session cookie", async () => {
57 const context = createMockExecutionContext({});
58
59 await expect(guard.canActivate(context)).rejects.toThrow(
60 new UnauthorizedException("Not authenticated"),
61 );
62 });
63
64 it("should throw UnauthorizedException when session not found in DB", async () => {
65 mockAuthService.getSessionById.mockResolvedValue(null);
66 const context = createMockExecutionContext({
67 session: "invalid-session",
68 });
69
70 await expect(guard.canActivate(context)).rejects.toThrow(
71 new UnauthorizedException("Session not found or expired"),
72 );
73 expect(mockAuthService.getSessionById).toHaveBeenCalledWith(
74 "invalid-session",
75 );
76 });
77
78 it("should throw UnauthorizedException when restore returns null", async () => {
79 const mockSessionRecord = {
80 id: "session-123",
81 userDid: "did:plc:abc123",
82 sessionData: "{}",
83 createdAt: new Date(),
84 updatedAt: new Date(),
85 };
86 mockAuthService.getSessionById.mockResolvedValue(mockSessionRecord);
87 mockAuthService.restore.mockResolvedValue(undefined);
88
89 const context = createMockExecutionContext({ session: "session-123" });
90
91 await expect(guard.canActivate(context)).rejects.toThrow(
92 new UnauthorizedException("Session not found or expired"),
93 );
94 expect(mockAuthService.restore).toHaveBeenCalledWith("did:plc:abc123");
95 });
96
97 it("should attach user to request and return true when valid session", async () => {
98 const mockSessionRecord = {
99 id: "session-123",
100 userDid: "did:plc:abc123",
101 sessionData: "{}",
102 createdAt: new Date(),
103 updatedAt: new Date(),
104 };
105 const mockSession = { did: "did:plc:abc123" };
106
107 mockAuthService.getSessionById.mockResolvedValue(mockSessionRecord);
108 mockAuthService.restore.mockResolvedValue(mockSession);
109
110 const mockRequest = { cookies: { session: "session-123" }, headers: {} };
111 const context = {
112 switchToHttp: () => ({
113 getRequest: () => mockRequest,
114 }),
115 } as unknown as ExecutionContext;
116
117 const result = await guard.canActivate(context);
118
119 expect(result).toBe(true);
120 expect((mockRequest as any).user).toEqual({
121 did: "did:plc:abc123",
122 session: mockSession,
123 });
124 });
125
126 it("should rethrow UnauthorizedException from inner try block", async () => {
127 const mockSessionRecord = {
128 id: "session-123",
129 userDid: "did:plc:abc123",
130 sessionData: "{}",
131 createdAt: new Date(),
132 updatedAt: new Date(),
133 };
134 mockAuthService.getSessionById.mockResolvedValue(mockSessionRecord);
135 mockAuthService.restore.mockResolvedValue(null);
136
137 const context = createMockExecutionContext({ session: "session-123" });
138
139 await expect(guard.canActivate(context)).rejects.toThrow(
140 UnauthorizedException,
141 );
142 });
143
144 it("should throw generic UnauthorizedException on non-Unauthorized errors", async () => {
145 const mockSessionRecord = {
146 id: "session-123",
147 userDid: "did:plc:abc123",
148 sessionData: "{}",
149 createdAt: new Date(),
150 updatedAt: new Date(),
151 };
152 mockAuthService.getSessionById.mockResolvedValue(mockSessionRecord);
153 mockAuthService.restore.mockRejectedValue(new Error("Database error"));
154
155 const context = createMockExecutionContext({ session: "session-123" });
156
157 await expect(guard.canActivate(context)).rejects.toThrow(
158 new UnauthorizedException("Invalid or expired session"),
159 );
160 });
161
162 it("should handle undefined cookies object", async () => {
163 const mockRequest = { headers: {} };
164 const context = {
165 switchToHttp: () => ({
166 getRequest: () => mockRequest,
167 }),
168 } as unknown as ExecutionContext;
169
170 await expect(guard.canActivate(context)).rejects.toThrow(
171 new UnauthorizedException("Not authenticated"),
172 );
173 });
174
175 it("should authenticate with Bearer token", async () => {
176 const mockSessionRecord = {
177 id: "session-123",
178 userDid: "did:plc:abc123",
179 sessionData: "{}",
180 createdAt: new Date(),
181 updatedAt: new Date(),
182 };
183 const mockSession = { did: "did:plc:abc123" };
184
185 mockAuthService.getSessionById.mockResolvedValue(mockSessionRecord);
186 mockAuthService.restore.mockResolvedValue(mockSession);
187
188 const mockRequest = {
189 cookies: {},
190 headers: { authorization: "Bearer session-123" },
191 };
192 const context = {
193 switchToHttp: () => ({
194 getRequest: () => mockRequest,
195 }),
196 } as unknown as ExecutionContext;
197
198 const result = await guard.canActivate(context);
199
200 expect(result).toBe(true);
201 expect(mockAuthService.getSessionById).toHaveBeenCalledWith(
202 "session-123",
203 );
204 });
205 });
206});