A personal media tracker built on the AT Protocol opnshelf.xyz
at main 818 lines 24 kB view raw
1import { BadRequestException } from "@nestjs/common"; 2import { ConfigService } from "@nestjs/config"; 3import { Test, type TestingModule } from "@nestjs/testing"; 4import type { Response } from "express"; 5 6// Mock PrismaService before importing AuthController/AuthService 7jest.mock("../prisma/prisma.service", () => ({ 8 PrismaService: jest.fn(), 9})); 10 11// Mock @atproto modules to prevent import errors 12jest.mock("@atproto/oauth-client-node", () => ({})); 13jest.mock("@atproto/api", () => ({})); 14jest.mock("@atproto/tap", () => ({ 15 Tap: jest.fn(), 16 SimpleIndexer: jest.fn(), 17})); 18 19import { IngesterService } from "../ingester/ingester.service"; 20import { UsersService } from "../users/users.service"; 21import { AuthController } from "./auth.controller"; 22import { AuthService } from "./auth.service"; 23 24describe("AuthController", () => { 25 let controller: AuthController; 26 27 const mockAuthService: { 28 getClientMetadata: jest.Mock; 29 authorize: jest.Mock; 30 authorizeWithPds: jest.Mock; 31 callback: jest.Mock; 32 parseOAuthAppState: jest.Mock; 33 fetchProfile: jest.Mock; 34 upsertUser: jest.Mock; 35 getSessionByUserDid: jest.Mock; 36 getUser: jest.Mock; 37 hasBlueskyProfile: jest.Mock; 38 revokeBySessionId: jest.Mock; 39 } = { 40 getClientMetadata: jest.fn(), 41 authorize: jest.fn(), 42 authorizeWithPds: jest.fn(), 43 callback: jest.fn(), 44 parseOAuthAppState: jest.fn().mockReturnValue({}), 45 fetchProfile: jest.fn(), 46 upsertUser: jest.fn(), 47 getSessionByUserDid: jest.fn(), 48 getUser: jest.fn(), 49 hasBlueskyProfile: jest.fn().mockResolvedValue(false), 50 revokeBySessionId: jest.fn(), 51 }; 52 53 const mockIngesterService = { 54 addRepo: jest.fn().mockResolvedValue(undefined), 55 }; 56 57 const mockUsersService = { 58 initializeProfileForNewUser: jest.fn().mockResolvedValue(undefined), 59 }; 60 61 const mockConfigService = { 62 get: jest.fn((key: string) => { 63 const config: Record<string, string> = { 64 FRONTEND_URL: "http://127.0.0.1:3000", 65 NODE_ENV: "test", 66 }; 67 return config[key]; 68 }), 69 }; 70 71 const createMockResponse = () => { 72 const res = { 73 redirect: jest.fn().mockReturnThis(), 74 cookie: jest.fn().mockReturnThis(), 75 clearCookie: jest.fn().mockReturnThis(), 76 status: jest.fn().mockReturnThis(), 77 json: jest.fn().mockReturnThis(), 78 } as unknown as jest.Mocked<Response>; 79 return res; 80 }; 81 82 const createMockRequest = ( 83 overrides: Partial<import("express").Request> = {}, 84 ) => { 85 return { 86 url: "/auth/callback", 87 cookies: {}, 88 ...overrides, 89 } as unknown as import("express").Request; 90 }; 91 92 beforeEach(async () => { 93 jest.clearAllMocks(); 94 mockAuthService.parseOAuthAppState.mockReturnValue({}); 95 96 const module: TestingModule = await Test.createTestingModule({ 97 controllers: [AuthController], 98 providers: [ 99 { provide: AuthService, useValue: mockAuthService }, 100 { provide: ConfigService, useValue: mockConfigService }, 101 { provide: IngesterService, useValue: mockIngesterService }, 102 { provide: UsersService, useValue: mockUsersService }, 103 ], 104 }).compile(); 105 106 controller = module.get<AuthController>(AuthController); 107 }); 108 109 describe("getClientMetadata", () => { 110 it("should return client metadata from auth service", () => { 111 const mockMetadata = { 112 client_id: 113 "http://127.0.0.1:3001/.well-known/oauth-client-metadata.json", 114 client_name: "OpnShelf", 115 }; 116 mockAuthService.getClientMetadata.mockReturnValue(mockMetadata); 117 118 const result = controller.getClientMetadata(); 119 120 expect(result).toEqual(mockMetadata); 121 expect(mockAuthService.getClientMetadata).toHaveBeenCalled(); 122 }); 123 }); 124 125 describe("login", () => { 126 it("should redirect to auth URL on success", async () => { 127 const authUrl = "https://bsky.social/oauth/authorize?state=abc"; 128 mockAuthService.authorize.mockResolvedValue(authUrl); 129 const res = createMockResponse(); 130 131 await controller.login("user.bsky.social", undefined, undefined, res); 132 133 expect(mockAuthService.authorize).toHaveBeenCalledWith( 134 "user.bsky.social", 135 { 136 platform: undefined, 137 timezone: undefined, 138 }, 139 ); 140 expect(res.redirect).toHaveBeenCalledWith(authUrl); 141 }); 142 143 it("should redirect with error when handle is not provided", async () => { 144 const res = createMockResponse(); 145 146 await controller.login(undefined, undefined, undefined, res); 147 148 expect(mockAuthService.authorize).not.toHaveBeenCalled(); 149 expect(res.redirect).toHaveBeenCalledWith("http://127.0.0.1:3000/login"); 150 }); 151 152 it("should redirect to mobile login when handle is not provided on mobile", async () => { 153 const res = createMockResponse(); 154 155 await controller.login(undefined, "mobile", undefined, res); 156 157 expect(mockAuthService.authorize).not.toHaveBeenCalled(); 158 expect(res.redirect).toHaveBeenCalledWith( 159 "opnshelf://auth/complete?error=handle_required", 160 ); 161 }); 162 163 it("should set platform cookie when platform=mobile", async () => { 164 const authUrl = "https://bsky.social/oauth/authorize?state=abc"; 165 mockAuthService.authorize.mockResolvedValue(authUrl); 166 const res = createMockResponse(); 167 168 await controller.login("user.bsky.social", "mobile", undefined, res); 169 170 expect(mockAuthService.authorize).toHaveBeenCalledWith( 171 "user.bsky.social", 172 { 173 platform: "mobile", 174 timezone: undefined, 175 }, 176 ); 177 expect(res.cookie).toHaveBeenCalledWith("auth_platform", "mobile", { 178 httpOnly: true, 179 maxAge: 5 * 60 * 1000, 180 sameSite: "lax", 181 }); 182 expect(res.redirect).toHaveBeenCalledWith(authUrl); 183 }); 184 185 it("should set timezone cookie when timezone provided", async () => { 186 const authUrl = "https://bsky.social/oauth/authorize?state=abc"; 187 mockAuthService.authorize.mockResolvedValue(authUrl); 188 const res = createMockResponse(); 189 190 await controller.login( 191 "user.bsky.social", 192 undefined, 193 "Europe/London", 194 res, 195 ); 196 197 expect(mockAuthService.authorize).toHaveBeenCalledWith( 198 "user.bsky.social", 199 { 200 platform: undefined, 201 timezone: "Europe/London", 202 }, 203 ); 204 expect(res.cookie).toHaveBeenCalledWith( 205 "auth_timezone", 206 "Europe/London", 207 { 208 httpOnly: true, 209 maxAge: 5 * 60 * 1000, 210 sameSite: "lax", 211 }, 212 ); 213 expect(res.redirect).toHaveBeenCalledWith(authUrl); 214 }); 215 216 it("should redirect to frontend with error on failure", async () => { 217 mockAuthService.authorize.mockRejectedValue(new Error("OAuth error")); 218 const res = createMockResponse(); 219 220 await controller.login("user.bsky.social", undefined, undefined, res); 221 222 expect(res.redirect).toHaveBeenCalledWith("http://127.0.0.1:3000/login"); 223 }); 224 225 it("should redirect to mobile login on failure when platform is mobile", async () => { 226 mockAuthService.authorize.mockRejectedValue(new Error("OAuth error")); 227 const res = createMockResponse(); 228 229 await controller.login("user.bsky.social", "mobile", undefined, res); 230 231 expect(res.redirect).toHaveBeenCalledWith( 232 "opnshelf://auth/complete?error=auth_failed", 233 ); 234 }); 235 }); 236 237 describe("signup", () => { 238 it("should redirect to frontend with error on signup failure", async () => { 239 mockAuthService.authorizeWithPds.mockRejectedValue( 240 new Error("OAuth error"), 241 ); 242 const res = createMockResponse(); 243 244 await controller.signup(undefined, undefined, res); 245 246 expect(res.redirect).toHaveBeenCalledWith("http://127.0.0.1:3000/login"); 247 }); 248 249 it("should redirect to mobile login on signup failure when platform is mobile", async () => { 250 mockAuthService.authorizeWithPds.mockRejectedValue( 251 new Error("OAuth error"), 252 ); 253 const res = createMockResponse(); 254 255 await controller.signup("mobile", undefined, res); 256 257 expect(res.redirect).toHaveBeenCalledWith( 258 "opnshelf://auth/complete?error=auth_failed", 259 ); 260 }); 261 }); 262 263 describe("callback", () => { 264 it("should set cookie and redirect to /auth/complete on success", async () => { 265 const mockSession = { did: "did:plc:abc123" }; 266 const mockProfile = { 267 did: "did:plc:abc123", 268 handle: "user.bsky.social", 269 displayName: "Test User", 270 avatar: "https://example.com/avatar.jpg", 271 }; 272 const mockSessionRecord = { 273 id: "session-123", 274 userDid: "did:plc:abc123", 275 }; 276 277 mockAuthService.callback.mockResolvedValue({ session: mockSession }); 278 mockAuthService.fetchProfile.mockResolvedValue(mockProfile); 279 mockAuthService.upsertUser.mockResolvedValue(mockProfile); 280 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord); 281 282 const req = createMockRequest({ 283 url: "/auth/callback?code=abc&state=xyz", 284 }); 285 const res = createMockResponse(); 286 287 await controller.callback(req, res); 288 289 expect(mockAuthService.callback).toHaveBeenCalled(); 290 expect(mockAuthService.fetchProfile).toHaveBeenCalledWith(mockSession); 291 expect(mockAuthService.upsertUser).toHaveBeenCalledWith( 292 mockProfile, 293 undefined, 294 ); 295 expect(res.cookie).toHaveBeenCalledWith( 296 "session", 297 "session-123", 298 expect.objectContaining({ 299 httpOnly: true, 300 sameSite: "lax", 301 path: "/", 302 }), 303 ); 304 expect(res.redirect).toHaveBeenCalledWith( 305 "http://127.0.0.1:3000/auth/complete", 306 ); 307 }); 308 309 it("initializes the seeded profile and default lists for new users", async () => { 310 const mockSession = { did: "did:plc:new123" }; 311 const mockProfile = { 312 did: "did:plc:new123", 313 handle: "new-user.bsky.social", 314 displayName: "New User", 315 avatar: "https://example.com/avatar.jpg", 316 }; 317 const mockSessionRecord = { 318 id: "session-123", 319 userDid: "did:plc:new123", 320 }; 321 322 mockAuthService.callback.mockResolvedValue({ session: mockSession }); 323 mockAuthService.fetchProfile.mockResolvedValue(mockProfile); 324 mockAuthService.upsertUser.mockResolvedValue({ 325 user: mockProfile, 326 isNewUser: true, 327 }); 328 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord); 329 330 const req = createMockRequest({ 331 url: "/auth/callback?code=abc&state=xyz", 332 }); 333 const res = createMockResponse(); 334 335 await controller.callback(req, res); 336 337 expect(mockUsersService.initializeProfileForNewUser).toHaveBeenCalledWith( 338 "did:plc:new123", 339 mockSession, 340 { 341 handle: "new-user.bsky.social", 342 displayName: "New User", 343 avatarUrl: "https://example.com/avatar.jpg", 344 }, 345 ); 346 }); 347 348 it("should register user DID with TAP on successful callback", async () => { 349 const mockSession = { did: "did:plc:abc123" }; 350 const mockProfile = { 351 did: "did:plc:abc123", 352 handle: "user.bsky.social", 353 displayName: "Test User", 354 avatar: "https://example.com/avatar.jpg", 355 }; 356 const mockSessionRecord = { 357 id: "session-123", 358 userDid: "did:plc:abc123", 359 }; 360 361 mockAuthService.callback.mockResolvedValue({ session: mockSession }); 362 mockAuthService.fetchProfile.mockResolvedValue(mockProfile); 363 mockAuthService.upsertUser.mockResolvedValue(mockProfile); 364 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord); 365 366 const req = createMockRequest({ 367 url: "/auth/callback?code=abc&state=xyz", 368 }); 369 const res = createMockResponse(); 370 371 await controller.callback(req, res); 372 373 expect(mockAuthService.upsertUser).toHaveBeenCalledWith( 374 mockProfile, 375 undefined, 376 ); 377 expect(mockIngesterService.addRepo).toHaveBeenCalledWith( 378 "did:plc:abc123", 379 ); 380 }); 381 382 it("should still redirect on success even if TAP registration fails", async () => { 383 const mockSession = { did: "did:plc:abc123" }; 384 const mockProfile = { 385 did: "did:plc:abc123", 386 handle: "user.bsky.social", 387 displayName: "Test User", 388 avatar: "https://example.com/avatar.jpg", 389 }; 390 const mockSessionRecord = { 391 id: "session-123", 392 userDid: "did:plc:abc123", 393 }; 394 395 mockAuthService.callback.mockResolvedValue({ session: mockSession }); 396 mockAuthService.fetchProfile.mockResolvedValue(mockProfile); 397 mockAuthService.upsertUser.mockResolvedValue(mockProfile); 398 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord); 399 mockIngesterService.addRepo.mockRejectedValue(new Error("TAP error")); 400 401 const req = createMockRequest({ 402 url: "/auth/callback?code=abc&state=xyz", 403 }); 404 const res = createMockResponse(); 405 406 await controller.callback(req, res); 407 408 expect(mockAuthService.upsertUser).toHaveBeenCalledWith( 409 mockProfile, 410 undefined, 411 ); 412 expect(res.redirect).toHaveBeenCalledWith( 413 "http://127.0.0.1:3000/auth/complete", 414 ); 415 }); 416 417 it("should redirect to mobile deep link when platform cookie is set", async () => { 418 const mockSession = { did: "did:plc:abc123" }; 419 const mockProfile = { 420 did: "did:plc:abc123", 421 handle: "user.bsky.social", 422 displayName: "Test User", 423 avatar: "https://example.com/avatar.jpg", 424 }; 425 const mockSessionRecord = { 426 id: "session-123", 427 userDid: "did:plc:abc123", 428 }; 429 430 mockAuthService.callback.mockResolvedValue({ session: mockSession }); 431 mockAuthService.fetchProfile.mockResolvedValue(mockProfile); 432 mockAuthService.upsertUser.mockResolvedValue(mockProfile); 433 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord); 434 435 const req = createMockRequest({ 436 url: "/auth/callback?code=abc&state=xyz", 437 cookies: { auth_platform: "mobile" }, 438 }); 439 const res = createMockResponse(); 440 441 await controller.callback(req, res); 442 443 expect(mockAuthService.upsertUser).toHaveBeenCalledWith( 444 mockProfile, 445 undefined, 446 ); 447 expect(res.clearCookie).toHaveBeenCalledWith("auth_platform"); 448 expect(res.redirect).toHaveBeenCalledWith( 449 "opnshelf://auth/complete?session=session-123", 450 ); 451 }); 452 453 it("should redirect to mobile deep link when state contains mobile platform", async () => { 454 const mockSession = { did: "did:plc:abc123" }; 455 const mockProfile = { 456 did: "did:plc:abc123", 457 handle: "user.bsky.social", 458 displayName: "Test User", 459 avatar: "https://example.com/avatar.jpg", 460 }; 461 const mockSessionRecord = { 462 id: "session-123", 463 userDid: "did:plc:abc123", 464 }; 465 466 mockAuthService.callback.mockResolvedValue({ 467 session: mockSession, 468 state: '{"platform":"mobile"}', 469 }); 470 mockAuthService.parseOAuthAppState.mockReturnValue({ 471 platform: "mobile", 472 }); 473 mockAuthService.fetchProfile.mockResolvedValue(mockProfile); 474 mockAuthService.upsertUser.mockResolvedValue(mockProfile); 475 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord); 476 477 const req = createMockRequest({ 478 url: "/auth/callback?code=abc&state=xyz", 479 cookies: {}, 480 }); 481 const res = createMockResponse(); 482 483 await controller.callback(req, res); 484 485 expect(mockAuthService.parseOAuthAppState).toHaveBeenCalledWith( 486 '{"platform":"mobile"}', 487 ); 488 expect(res.redirect).toHaveBeenCalledWith( 489 "opnshelf://auth/complete?session=session-123", 490 ); 491 }); 492 493 it("should redirect with error when session record not found", async () => { 494 const mockSession = { did: "did:plc:abc123" }; 495 const mockProfile = { 496 did: "did:plc:abc123", 497 handle: "user.bsky.social", 498 displayName: null, 499 avatar: null, 500 }; 501 502 mockAuthService.callback.mockResolvedValue({ session: mockSession }); 503 mockAuthService.fetchProfile.mockResolvedValue(mockProfile); 504 mockAuthService.upsertUser.mockResolvedValue(mockProfile); 505 mockAuthService.getSessionByUserDid.mockResolvedValue(null); 506 507 const req = createMockRequest({ 508 url: "/auth/callback?code=abc&state=xyz", 509 }); 510 const res = createMockResponse(); 511 512 await controller.callback(req, res); 513 514 expect(mockAuthService.upsertUser).toHaveBeenCalledWith( 515 mockProfile, 516 undefined, 517 ); 518 expect(res.redirect).toHaveBeenCalledWith("http://127.0.0.1:3000/login"); 519 }); 520 521 it("should redirect to mobile login when session record not found for mobile state", async () => { 522 const mockSession = { did: "did:plc:abc123" }; 523 const mockProfile = { 524 did: "did:plc:abc123", 525 handle: "user.bsky.social", 526 displayName: null, 527 avatar: null, 528 }; 529 530 mockAuthService.callback.mockResolvedValue({ 531 session: mockSession, 532 state: '{"platform":"mobile"}', 533 }); 534 mockAuthService.parseOAuthAppState.mockReturnValue({ 535 platform: "mobile", 536 }); 537 mockAuthService.fetchProfile.mockResolvedValue(mockProfile); 538 mockAuthService.upsertUser.mockResolvedValue(mockProfile); 539 mockAuthService.getSessionByUserDid.mockResolvedValue(null); 540 541 const req = createMockRequest({ 542 url: "/auth/callback?code=abc&state=xyz", 543 }); 544 const res = createMockResponse(); 545 546 await controller.callback(req, res); 547 548 expect(res.redirect).toHaveBeenCalledWith( 549 "opnshelf://auth/complete?error=callback_failed", 550 ); 551 }); 552 553 it("should redirect with error on callback failure", async () => { 554 mockAuthService.callback.mockRejectedValue(new Error("OAuth error")); 555 556 const req = createMockRequest({ 557 url: "/auth/callback?code=abc&state=xyz", 558 }); 559 const res = createMockResponse(); 560 561 await controller.callback(req, res); 562 563 expect(res.redirect).toHaveBeenCalledWith("http://127.0.0.1:3000/login"); 564 }); 565 566 it("should redirect to mobile deep link on callback failure when error state is mobile", async () => { 567 const error = new Error("OAuth error") as Error & { state?: string }; 568 error.state = '{"platform":"mobile"}'; 569 mockAuthService.callback.mockRejectedValue(error); 570 mockAuthService.parseOAuthAppState.mockReturnValue({ 571 platform: "mobile", 572 }); 573 574 const req = createMockRequest({ 575 url: "/auth/callback?code=abc&state=xyz", 576 }); 577 const res = createMockResponse(); 578 579 await controller.callback(req, res); 580 581 expect(res.redirect).toHaveBeenCalledWith( 582 "opnshelf://auth/complete?error=callback_failed", 583 ); 584 }); 585 586 it("should redirect to mobile login on callback failure when mobile cookie is set", async () => { 587 mockAuthService.callback.mockRejectedValue(new Error("OAuth error")); 588 589 const req = createMockRequest({ 590 url: "/auth/callback?code=abc&state=xyz", 591 cookies: { auth_platform: "mobile" }, 592 }); 593 const res = createMockResponse(); 594 595 await controller.callback(req, res); 596 597 expect(res.redirect).toHaveBeenCalledWith( 598 "opnshelf://auth/complete?error=callback_failed", 599 ); 600 }); 601 }); 602 603 describe("me", () => { 604 it("should return user data when authenticated", async () => { 605 const mockUser = { 606 did: "did:plc:abc123", 607 handle: "user.bsky.social", 608 displayName: "Test User", 609 avatar: "https://example.com/avatar.jpg", 610 onboardingCompletedAt: new Date("2026-01-01T00:00:00.000Z"), 611 }; 612 mockAuthService.getUser.mockResolvedValue(mockUser); 613 614 const req = createMockRequest({ 615 user: { did: "did:plc:abc123", session: {} }, 616 } as unknown as import("express").Request); 617 618 const result = await controller.me( 619 req as unknown as import("../auth/types").AuthenticatedRequest, 620 ); 621 622 expect(result).toEqual({ 623 did: "did:plc:abc123", 624 handle: "user.bsky.social", 625 displayName: "Test User", 626 avatar: "https://example.com/avatar.jpg", 627 onboardingCompletedAt: "2026-01-01T00:00:00.000Z", 628 needsOnboarding: false, 629 }); 630 expect(mockAuthService.getUser).toHaveBeenCalledWith("did:plc:abc123"); 631 expect(mockAuthService.hasBlueskyProfile).not.toHaveBeenCalled(); 632 }); 633 634 it("should throw BadRequestException when no user in request", async () => { 635 const req = createMockRequest(); 636 637 await expect( 638 controller.me( 639 req as unknown as import("../auth/types").AuthenticatedRequest, 640 ), 641 ).rejects.toThrow(BadRequestException); 642 }); 643 644 it("should throw BadRequestException when user not found in DB", async () => { 645 mockAuthService.getUser.mockResolvedValue(null); 646 647 const req = createMockRequest({ 648 user: { did: "did:plc:abc123", session: {} }, 649 } as unknown as import("express").Request); 650 651 await expect( 652 controller.me( 653 req as unknown as import("../auth/types").AuthenticatedRequest, 654 ), 655 ).rejects.toThrow(BadRequestException); 656 }); 657 }); 658 659 describe("blueskyProfileStatus", () => { 660 it("should return Bluesky profile status when authenticated", async () => { 661 mockAuthService.hasBlueskyProfile.mockResolvedValue(true); 662 663 const req = createMockRequest({ 664 user: { did: "did:plc:abc123", session: {} }, 665 } as unknown as import("express").Request); 666 667 const result = await controller.blueskyProfileStatus( 668 req as unknown as import("../auth/types").AuthenticatedRequest, 669 ); 670 671 expect(result).toEqual({ hasBlueskyProfile: true }); 672 expect(mockAuthService.hasBlueskyProfile).toHaveBeenCalledWith( 673 "did:plc:abc123", 674 ); 675 }); 676 677 it("should throw BadRequestException when no user in request", async () => { 678 const req = createMockRequest(); 679 680 await expect( 681 controller.blueskyProfileStatus( 682 req as unknown as import("../auth/types").AuthenticatedRequest, 683 ), 684 ).rejects.toThrow(BadRequestException); 685 }); 686 }); 687 688 describe("logout", () => { 689 it("should revoke session and clear cookie", async () => { 690 const req = createMockRequest({ 691 cookies: { session: "session-123" }, 692 user: { did: "did:plc:abc123", session: {} }, 693 } as unknown as import("express").Request); 694 const res = createMockResponse(); 695 696 await controller.logout( 697 req as unknown as import("../auth/types").AuthenticatedRequest, 698 res, 699 ); 700 701 expect(mockAuthService.revokeBySessionId).toHaveBeenCalledWith( 702 "session-123", 703 ); 704 expect(res.clearCookie).toHaveBeenCalledWith( 705 "session", 706 expect.objectContaining({ 707 httpOnly: true, 708 sameSite: "lax", 709 path: "/", 710 }), 711 ); 712 expect(res.status).toHaveBeenCalledWith(200); 713 expect(res.json).toHaveBeenCalledWith({ 714 message: "Logged out successfully", 715 }); 716 }); 717 718 it("should still clear cookie when no session exists", async () => { 719 const req = createMockRequest({ 720 cookies: {}, 721 user: { did: "did:plc:abc123", session: {} }, 722 } as unknown as import("express").Request); 723 const res = createMockResponse(); 724 725 await controller.logout( 726 req as unknown as import("../auth/types").AuthenticatedRequest, 727 res, 728 ); 729 730 expect(mockAuthService.revokeBySessionId).not.toHaveBeenCalled(); 731 expect(res.clearCookie).toHaveBeenCalled(); 732 expect(res.status).toHaveBeenCalledWith(200); 733 }); 734 }); 735 736 describe("getCookieDomain (via callback)", () => { 737 it("should not set domain in development", async () => { 738 const mockSession = { did: "did:plc:abc123" }; 739 const mockProfile = { 740 did: "did:plc:abc123", 741 handle: "user.bsky.social", 742 displayName: null, 743 avatar: null, 744 }; 745 const mockSessionRecord = { 746 id: "session-123", 747 userDid: "did:plc:abc123", 748 }; 749 750 mockAuthService.callback.mockResolvedValue({ session: mockSession }); 751 mockAuthService.fetchProfile.mockResolvedValue(mockProfile); 752 mockAuthService.upsertUser.mockResolvedValue(mockProfile); 753 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord); 754 755 const req = createMockRequest({ url: "/auth/callback?code=abc" }); 756 const res = createMockResponse(); 757 758 await controller.callback(req, res); 759 760 expect(mockAuthService.upsertUser).toHaveBeenCalledWith( 761 mockProfile, 762 undefined, 763 ); 764 // In test/dev mode, domain should not be set 765 expect(res.cookie).toHaveBeenCalledWith( 766 "session", 767 "session-123", 768 expect.not.objectContaining({ domain: expect.any(String) }), 769 ); 770 }); 771 772 it("should set domain in production", async () => { 773 // Override to production config 774 mockConfigService.get.mockImplementation((key: string) => { 775 const config: Record<string, string> = { 776 FRONTEND_URL: "https://opnshelf.xyz", 777 NODE_ENV: "production", 778 }; 779 return config[key]; 780 }); 781 782 const mockSession = { did: "did:plc:abc123" }; 783 const mockProfile = { 784 did: "did:plc:abc123", 785 handle: "user.bsky.social", 786 displayName: null, 787 avatar: null, 788 }; 789 const mockSessionRecord = { 790 id: "session-123", 791 userDid: "did:plc:abc123", 792 }; 793 794 mockAuthService.callback.mockResolvedValue({ session: mockSession }); 795 mockAuthService.fetchProfile.mockResolvedValue(mockProfile); 796 mockAuthService.upsertUser.mockResolvedValue(mockProfile); 797 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord); 798 799 const req = createMockRequest({ url: "/auth/callback?code=abc" }); 800 const res = createMockResponse(); 801 802 await controller.callback(req, res); 803 804 expect(mockAuthService.upsertUser).toHaveBeenCalledWith( 805 mockProfile, 806 undefined, 807 ); 808 expect(res.cookie).toHaveBeenCalledWith( 809 "session", 810 "session-123", 811 expect.objectContaining({ 812 secure: true, 813 domain: "opnshelf.xyz", 814 }), 815 ); 816 }); 817 }); 818});