@terreno/api 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/auth.test.ts CHANGED
@@ -619,4 +619,151 @@ describe("generateTokens edge cases", () => {
619
619
  expect(decoded.exp).toBeGreaterThan(expectedExp - 10);
620
620
  expect(decoded.exp).toBeLessThan(expectedExp + 10);
621
621
  });
622
+
623
+ it("throws when TOKEN_SECRET is not set", async () => {
624
+ process.env.TOKEN_SECRET = "";
625
+ let caught: unknown;
626
+ try {
627
+ await generateTokens({_id: "user-123"});
628
+ } catch (error) {
629
+ caught = error;
630
+ }
631
+ expect(caught).toBeDefined();
632
+ expect((caught as Error).message).toBe("TOKEN_SECRET must be set in env.");
633
+ });
634
+
635
+ it("uses TOKEN_EXPIRES_IN from env when valid", async () => {
636
+ const jwtLib = await import("jsonwebtoken");
637
+ process.env.TOKEN_EXPIRES_IN = "2h";
638
+ const result = await generateTokens({_id: "user-123"});
639
+ const decoded = jwtLib.decode(result.token as string) as any;
640
+ const expectedExp = Math.floor(Date.now() / 1000) + 2 * 3600;
641
+ expect(decoded.exp).toBeGreaterThan(expectedExp - 10);
642
+ expect(decoded.exp).toBeLessThan(expectedExp + 10);
643
+ });
644
+
645
+ it("uses REFRESH_TOKEN_EXPIRES_IN from env when valid", async () => {
646
+ const jwtLib = await import("jsonwebtoken");
647
+ process.env.REFRESH_TOKEN_EXPIRES_IN = "1h";
648
+ const result = await generateTokens({_id: "user-123"});
649
+ const decoded = jwtLib.decode(result.refreshToken as string) as any;
650
+ const expectedExp = Math.floor(Date.now() / 1000) + 3600;
651
+ expect(decoded.exp).toBeGreaterThan(expectedExp - 10);
652
+ expect(decoded.exp).toBeLessThan(expectedExp + 10);
653
+ });
654
+
655
+ it("does not issue refresh token when REFRESH_TOKEN_SECRET is not set", async () => {
656
+ process.env.REFRESH_TOKEN_SECRET = "";
657
+ const result = await generateTokens({_id: "user-123"});
658
+ expect(result.token).toBeDefined();
659
+ expect(result.refreshToken).toBeUndefined();
660
+ });
661
+ });
662
+
663
+ describe("addAuthRoutes /refresh_token error paths", () => {
664
+ let app: express.Application;
665
+ let agent: TestAgent;
666
+
667
+ beforeEach(async () => {
668
+ setSystemTime();
669
+ await setupDb();
670
+ app = setupServer({
671
+ addRoutes: () => {},
672
+ skipListen: true,
673
+ userModel: UserModel as any,
674
+ });
675
+ agent = supertest.agent(app);
676
+ });
677
+
678
+ afterEach(() => {
679
+ setSystemTime();
680
+ });
681
+
682
+ it("returns 401 when no refreshToken in body", async () => {
683
+ const res = await agent.post("/auth/refresh_token").send({}).expect(401);
684
+ expect(res.body.message).toContain("No refresh token provided");
685
+ });
686
+
687
+ it("returns 401 when refresh token is invalid", async () => {
688
+ const res = await agent
689
+ .post("/auth/refresh_token")
690
+ .send({refreshToken: "not-a-valid-jwt"})
691
+ .expect(401);
692
+ expect(res.body.message).toBeDefined();
693
+ });
694
+
695
+ it("returns 401 when refresh token is signed with wrong secret", async () => {
696
+ const jwtLib = (await import("jsonwebtoken")).default;
697
+ const bogusToken = jwtLib.sign({id: "abc"}, "different-secret");
698
+ const res = await agent
699
+ .post("/auth/refresh_token")
700
+ .send({refreshToken: bogusToken})
701
+ .expect(401);
702
+ expect(res.body.message).toBeDefined();
703
+ });
704
+
705
+ it("returns 401 when refresh token has no id", async () => {
706
+ const jwtLib = (await import("jsonwebtoken")).default;
707
+ const tokenNoId = jwtLib.sign({foo: "bar"}, process.env.REFRESH_TOKEN_SECRET as string);
708
+ const res = await agent.post("/auth/refresh_token").send({refreshToken: tokenNoId}).expect(401);
709
+ expect(res.body.message).toBe("Invalid refresh token");
710
+ });
711
+
712
+ it("issues new tokens on valid refresh", async () => {
713
+ const [adminUser] = await setupDb();
714
+ const jwtLib = (await import("jsonwebtoken")).default;
715
+ const validToken = jwtLib.sign(
716
+ {id: (adminUser as any)._id.toString()},
717
+ process.env.REFRESH_TOKEN_SECRET as string
718
+ );
719
+ const res = await agent
720
+ .post("/auth/refresh_token")
721
+ .send({refreshToken: validToken})
722
+ .expect(200);
723
+ expect(res.body.data.token).toBeDefined();
724
+ expect(res.body.data.refreshToken).toBeDefined();
725
+ });
726
+ });
727
+
728
+ describe("addMeRoutes edge cases", () => {
729
+ let app: express.Application;
730
+ let agent: TestAgent;
731
+
732
+ beforeEach(async () => {
733
+ setSystemTime();
734
+ await setupDb();
735
+ app = setupServer({
736
+ addRoutes: () => {},
737
+ skipListen: true,
738
+ userModel: UserModel as any,
739
+ });
740
+ agent = supertest.agent(app);
741
+ });
742
+
743
+ afterEach(() => {
744
+ setSystemTime();
745
+ });
746
+
747
+ it("GET /auth/me returns 401 without auth", async () => {
748
+ await agent.get("/auth/me").expect(401);
749
+ });
750
+
751
+ it("PATCH /auth/me returns 401 without auth", async () => {
752
+ await agent.patch("/auth/me").send({email: "x@x.com"}).expect(401);
753
+ });
754
+
755
+ it("GET /auth/me returns 404 when user is deleted after auth", async () => {
756
+ const [_admin, notAdmin] = await setupDb();
757
+ const jwtLib = (await import("jsonwebtoken")).default;
758
+ const token = jwtLib.sign(
759
+ {id: (notAdmin as any)._id.toString()},
760
+ process.env.TOKEN_SECRET as string,
761
+ {issuer: process.env.TOKEN_ISSUER}
762
+ );
763
+ // Delete the user so findById returns null
764
+ await UserModel.deleteOne({_id: (notAdmin as any)._id});
765
+ const res = await agent.get("/auth/me").set("authorization", `Bearer ${token}`);
766
+ // Either 404 (user not found in /me handler) or 401 (auth middleware rejects)
767
+ expect([401, 404]).toContain(res.status);
768
+ });
622
769
  });
@@ -707,6 +707,168 @@ describe("ConsentApp", () => {
707
707
  });
708
708
  });
709
709
 
710
+ describe("POST /consent-forms/generate (aiConfig)", () => {
711
+ const aiConfig = {
712
+ generateContent: async ({
713
+ type,
714
+ description,
715
+ locale,
716
+ }: {
717
+ type: string;
718
+ description: string;
719
+ locale: string;
720
+ }) => `Generated ${type} content (${locale}) for: ${description}`,
721
+ translateContent: async ({
722
+ content,
723
+ fromLocale,
724
+ toLocale,
725
+ }: {
726
+ content: string;
727
+ fromLocale: string;
728
+ toLocale: string;
729
+ }) => `[${fromLocale}->${toLocale}] ${content}`,
730
+ };
731
+
732
+ it("generates consent form content for admins", async () => {
733
+ const aiApp = buildApp({aiConfig});
734
+ const aiAdmin = await authAsUser(aiApp, "admin");
735
+
736
+ const res = await aiAdmin
737
+ .post("/consent-forms/generate")
738
+ .send({description: "Privacy policy for a health app", locale: "es", type: "privacy"})
739
+ .expect(200);
740
+
741
+ expect(res.body.data.content).toBe(
742
+ "Generated privacy content (es) for: Privacy policy for a health app"
743
+ );
744
+ });
745
+
746
+ it("defaults locale to en when not provided", async () => {
747
+ const aiApp = buildApp({aiConfig});
748
+ const aiAdmin = await authAsUser(aiApp, "admin");
749
+
750
+ const res = await aiAdmin
751
+ .post("/consent-forms/generate")
752
+ .send({description: "Terms", type: "terms"})
753
+ .expect(200);
754
+
755
+ expect(res.body.data.content).toContain("(en)");
756
+ });
757
+
758
+ it("returns 403 for non-admin users", async () => {
759
+ const aiApp = buildApp({aiConfig});
760
+ const aiUser = await authAsUser(aiApp, "notAdmin");
761
+
762
+ await aiUser
763
+ .post("/consent-forms/generate")
764
+ .send({description: "Privacy", type: "privacy"})
765
+ .expect(403);
766
+ });
767
+
768
+ it("returns 400 when type is missing", async () => {
769
+ const aiApp = buildApp({aiConfig});
770
+ const aiAdmin = await authAsUser(aiApp, "admin");
771
+
772
+ await aiAdmin.post("/consent-forms/generate").send({description: "Privacy"}).expect(400);
773
+ });
774
+
775
+ it("returns 400 when description is missing", async () => {
776
+ const aiApp = buildApp({aiConfig});
777
+ const aiAdmin = await authAsUser(aiApp, "admin");
778
+
779
+ await aiAdmin.post("/consent-forms/generate").send({type: "privacy"}).expect(400);
780
+ });
781
+
782
+ it("returns 404 when aiConfig is not provided", async () => {
783
+ await adminAgent
784
+ .post("/consent-forms/generate")
785
+ .send({description: "Privacy", type: "privacy"})
786
+ .expect(404);
787
+ });
788
+ });
789
+
790
+ describe("POST /consent-forms/translate (aiConfig)", () => {
791
+ const aiConfig = {
792
+ generateContent: async ({
793
+ type,
794
+ description,
795
+ locale,
796
+ }: {
797
+ type: string;
798
+ description: string;
799
+ locale: string;
800
+ }) => `Generated ${type} content (${locale}) for: ${description}`,
801
+ translateContent: async ({
802
+ content,
803
+ fromLocale,
804
+ toLocale,
805
+ }: {
806
+ content: string;
807
+ fromLocale: string;
808
+ toLocale: string;
809
+ }) => `[${fromLocale}->${toLocale}] ${content}`,
810
+ };
811
+
812
+ it("translates consent form content for admins", async () => {
813
+ const aiApp = buildApp({aiConfig});
814
+ const aiAdmin = await authAsUser(aiApp, "admin");
815
+
816
+ const res = await aiAdmin
817
+ .post("/consent-forms/translate")
818
+ .send({content: "Hello world", fromLocale: "en", toLocale: "es"})
819
+ .expect(200);
820
+
821
+ expect(res.body.data.content).toBe("[en->es] Hello world");
822
+ });
823
+
824
+ it("returns 403 for non-admin users", async () => {
825
+ const aiApp = buildApp({aiConfig});
826
+ const aiUser = await authAsUser(aiApp, "notAdmin");
827
+
828
+ await aiUser
829
+ .post("/consent-forms/translate")
830
+ .send({content: "Hello", fromLocale: "en", toLocale: "es"})
831
+ .expect(403);
832
+ });
833
+
834
+ it("returns 400 when content is missing", async () => {
835
+ const aiApp = buildApp({aiConfig});
836
+ const aiAdmin = await authAsUser(aiApp, "admin");
837
+
838
+ await aiAdmin
839
+ .post("/consent-forms/translate")
840
+ .send({fromLocale: "en", toLocale: "es"})
841
+ .expect(400);
842
+ });
843
+
844
+ it("returns 400 when fromLocale is missing", async () => {
845
+ const aiApp = buildApp({aiConfig});
846
+ const aiAdmin = await authAsUser(aiApp, "admin");
847
+
848
+ await aiAdmin
849
+ .post("/consent-forms/translate")
850
+ .send({content: "Hello", toLocale: "es"})
851
+ .expect(400);
852
+ });
853
+
854
+ it("returns 400 when toLocale is missing", async () => {
855
+ const aiApp = buildApp({aiConfig});
856
+ const aiAdmin = await authAsUser(aiApp, "admin");
857
+
858
+ await aiAdmin
859
+ .post("/consent-forms/translate")
860
+ .send({content: "Hello", fromLocale: "en"})
861
+ .expect(400);
862
+ });
863
+
864
+ it("returns 404 when aiConfig is not provided", async () => {
865
+ await adminAgent
866
+ .post("/consent-forms/translate")
867
+ .send({content: "Hello", fromLocale: "en", toLocale: "es"})
868
+ .expect(404);
869
+ });
870
+ });
871
+
710
872
  describe("GET /consents/audit/:userId", () => {
711
873
  it("returns audit history for a user when auditTrail is enabled", async () => {
712
874
  const form = await ConsentForm.create({
@@ -1,12 +1,13 @@
1
1
  import {afterEach, beforeEach, describe, expect, it, setSystemTime} from "bun:test";
2
2
  import type express from "express";
3
3
  import mongoose, {model, Schema} from "mongoose";
4
+ import passport from "passport";
4
5
  import passportLocalMongoose from "passport-local-mongoose";
5
6
  import supertest from "supertest";
6
7
  import type TestAgent from "supertest/lib/agent";
7
8
 
8
9
  import {setupServer} from "./expressServer";
9
- import {type GitHubUserFields, githubUserPlugin} from "./githubAuth";
10
+ import {type GitHubUserFields, githubUserPlugin, setupGitHubAuth} from "./githubAuth";
10
11
  import {logger} from "./logger";
11
12
  import {createdUpdatedPlugin, isDisabledPlugin} from "./plugins";
12
13
 
@@ -221,3 +222,308 @@ describe("GitHub auth disabled", () => {
221
222
  await agent.delete("/auth/github/unlink").expect(404);
222
223
  });
223
224
  });
225
+
226
+ interface GitHubProfileLike {
227
+ id?: string;
228
+ emails?: Array<{value: string}>;
229
+ username?: string;
230
+ displayName?: string;
231
+ [key: string]: unknown;
232
+ }
233
+
234
+ interface VerifyError {
235
+ status?: number;
236
+ message?: string;
237
+ }
238
+
239
+ type VerifiedUser = any;
240
+
241
+ interface VerifyStrategy {
242
+ _verify: (
243
+ req: unknown,
244
+ accessToken: string,
245
+ refreshToken: string,
246
+ profile: GitHubProfileLike,
247
+ done: (err: VerifyError | null, user?: VerifiedUser) => void
248
+ ) => void;
249
+ }
250
+
251
+ interface PassportWithStrategies {
252
+ _strategies?: Record<string, VerifyStrategy | undefined>;
253
+ }
254
+
255
+ // Helper to extract the strategy verify callback and invoke it directly
256
+ const invokeGitHubVerify = (
257
+ req: unknown,
258
+ accessToken: string,
259
+ refreshToken: string,
260
+ profile: GitHubProfileLike
261
+ ) => {
262
+ const strategy = (passport as unknown as PassportWithStrategies)._strategies?.github;
263
+ if (!strategy) {
264
+ throw new Error("github strategy not registered");
265
+ }
266
+ return new Promise<{err: VerifyError | null; user: VerifiedUser}>((resolve) => {
267
+ const done = (err: VerifyError | null, user?: VerifiedUser) => resolve({err, user});
268
+ strategy._verify(req, accessToken, refreshToken, profile, done);
269
+ });
270
+ };
271
+
272
+ describe("GitHub strategy verify callback", () => {
273
+ const testApp = {get: () => {}, use: () => {}} as any;
274
+
275
+ beforeEach(async () => {
276
+ await connectDb();
277
+ await GitHubTestUserModel.deleteMany({});
278
+ });
279
+
280
+ it("uses custom findOrCreateUser when provided", async () => {
281
+ const customUser = {_id: "custom-user-id", email: "custom@example.com"};
282
+ setupGitHubAuth(testApp, GitHubTestUserModel as any, {
283
+ callbackURL: "http://localhost:9000/auth/github/callback",
284
+ clientId: "id",
285
+ clientSecret: "secret",
286
+ findOrCreateUser: async () => customUser,
287
+ });
288
+
289
+ const result = await invokeGitHubVerify({}, "access", "refresh", {id: "123"});
290
+ expect(result.err).toBeNull();
291
+ expect(result.user).toEqual(customUser);
292
+ });
293
+
294
+ it("creates a new user when no existing GitHub or email user", async () => {
295
+ setupGitHubAuth(testApp, GitHubTestUserModel as any, {
296
+ callbackURL: "http://localhost:9000/auth/github/callback",
297
+ clientId: "id",
298
+ clientSecret: "secret",
299
+ });
300
+
301
+ const profile = {
302
+ emails: [{value: "new@example.com"}],
303
+ id: "gh-new-1",
304
+ photos: [{value: "http://avatar"}],
305
+ profileUrl: "http://profile",
306
+ username: "newghuser",
307
+ };
308
+
309
+ const result = await invokeGitHubVerify({}, "access", "refresh", profile);
310
+ expect(result.err).toBeNull();
311
+ expect(result.user).toBeDefined();
312
+ expect(result.user.githubId).toBe("gh-new-1");
313
+ expect(result.user.githubUsername).toBe("newghuser");
314
+ expect(result.user.email).toBe("new@example.com");
315
+ });
316
+
317
+ it("logs in existing GitHub user", async () => {
318
+ const existingUser = await GitHubTestUserModel.create({
319
+ email: "gh@example.com",
320
+ githubId: "gh-existing-1",
321
+ githubUsername: "ghuser",
322
+ name: "GH User",
323
+ } as any);
324
+
325
+ setupGitHubAuth(testApp, GitHubTestUserModel as any, {
326
+ callbackURL: "http://localhost:9000/auth/github/callback",
327
+ clientId: "id",
328
+ clientSecret: "secret",
329
+ });
330
+
331
+ const result = await invokeGitHubVerify({}, "access", "refresh", {
332
+ id: "gh-existing-1",
333
+ username: "ghuser",
334
+ });
335
+ expect(result.err).toBeNull();
336
+ expect(result.user._id.toString()).toBe((existingUser as any)._id.toString());
337
+ });
338
+
339
+ it("links GitHub to authenticated user when allowAccountLinking=true", async () => {
340
+ const existingUser = await GitHubTestUserModel.create({
341
+ email: "link@example.com",
342
+ name: "Link User",
343
+ } as any);
344
+
345
+ setupGitHubAuth(testApp, GitHubTestUserModel as any, {
346
+ allowAccountLinking: true,
347
+ callbackURL: "http://localhost:9000/auth/github/callback",
348
+ clientId: "id",
349
+ clientSecret: "secret",
350
+ });
351
+
352
+ const req = {user: existingUser};
353
+ const result = await invokeGitHubVerify(req, "access", "refresh", {
354
+ id: "gh-link-1",
355
+ photos: [{value: "http://avatar"}],
356
+ profileUrl: "http://profile",
357
+ username: "linkedghuser",
358
+ });
359
+ expect(result.err).toBeNull();
360
+ expect(result.user.githubId).toBe("gh-link-1");
361
+ expect(result.user.githubUsername).toBe("linkedghuser");
362
+ });
363
+
364
+ it("rejects linking when allowAccountLinking=false", async () => {
365
+ const existingUser = await GitHubTestUserModel.create({
366
+ email: "nolink@example.com",
367
+ } as any);
368
+
369
+ setupGitHubAuth(testApp, GitHubTestUserModel as any, {
370
+ allowAccountLinking: false,
371
+ callbackURL: "http://localhost:9000/auth/github/callback",
372
+ clientId: "id",
373
+ clientSecret: "secret",
374
+ });
375
+
376
+ const req = {user: existingUser};
377
+ const result = await invokeGitHubVerify(req, "access", "refresh", {id: "gh-nolink-1"});
378
+ expect(result.err).toBeDefined();
379
+ expect((result.err as any).status).toBe(400);
380
+ });
381
+
382
+ it("rejects linking when GitHub account belongs to another user", async () => {
383
+ const userA = await GitHubTestUserModel.create({
384
+ email: "a@example.com",
385
+ } as any);
386
+ await GitHubTestUserModel.create({
387
+ email: "b@example.com",
388
+ githubId: "gh-other-1",
389
+ } as any);
390
+
391
+ setupGitHubAuth(testApp, GitHubTestUserModel as any, {
392
+ allowAccountLinking: true,
393
+ callbackURL: "http://localhost:9000/auth/github/callback",
394
+ clientId: "id",
395
+ clientSecret: "secret",
396
+ });
397
+
398
+ const req = {user: userA};
399
+ const result = await invokeGitHubVerify(req, "access", "refresh", {id: "gh-other-1"});
400
+ expect(result.err).toBeDefined();
401
+ expect((result.err as any).status).toBe(400);
402
+ });
403
+
404
+ it("links GitHub to existing email user when allowAccountLinking is not false", async () => {
405
+ await GitHubTestUserModel.create({
406
+ email: "emailuser@example.com",
407
+ } as any);
408
+
409
+ setupGitHubAuth(testApp, GitHubTestUserModel as any, {
410
+ callbackURL: "http://localhost:9000/auth/github/callback",
411
+ clientId: "id",
412
+ clientSecret: "secret",
413
+ });
414
+
415
+ const result = await invokeGitHubVerify({}, "access", "refresh", {
416
+ emails: [{value: "emailuser@example.com"}],
417
+ id: "gh-email-link-1",
418
+ username: "emailuserghuser",
419
+ });
420
+ expect(result.err).toBeNull();
421
+ expect(result.user.githubId).toBe("gh-email-link-1");
422
+ expect(result.user.email).toBe("emailuser@example.com");
423
+ });
424
+
425
+ it("rejects email-link when allowAccountLinking=false", async () => {
426
+ await GitHubTestUserModel.create({
427
+ email: "emailnolink@example.com",
428
+ } as any);
429
+
430
+ setupGitHubAuth(testApp, GitHubTestUserModel as any, {
431
+ allowAccountLinking: false,
432
+ callbackURL: "http://localhost:9000/auth/github/callback",
433
+ clientId: "id",
434
+ clientSecret: "secret",
435
+ });
436
+
437
+ const result = await invokeGitHubVerify({}, "access", "refresh", {
438
+ emails: [{value: "emailnolink@example.com"}],
439
+ id: "gh-email-nolink-1",
440
+ });
441
+ expect(result.err).toBeDefined();
442
+ expect((result.err as any).status).toBe(400);
443
+ });
444
+
445
+ it("returns error when thrown during lookup", async () => {
446
+ // Set up strategy with findOrCreateUser that throws
447
+ setupGitHubAuth(testApp, GitHubTestUserModel as any, {
448
+ callbackURL: "http://localhost:9000/auth/github/callback",
449
+ clientId: "id",
450
+ clientSecret: "secret",
451
+ findOrCreateUser: async () => {
452
+ throw new Error("boom");
453
+ },
454
+ });
455
+
456
+ const result = await invokeGitHubVerify({}, "access", "refresh", {id: "gh-err-1"});
457
+ expect(result.err).toBeDefined();
458
+ expect((result.err as Error).message).toBe("boom");
459
+ });
460
+ });
461
+
462
+ describe("addGitHubAuthRoutes link endpoints", () => {
463
+ let app: express.Application;
464
+ let agent: TestAgent;
465
+
466
+ beforeEach(async () => {
467
+ setSystemTime();
468
+ await connectDb();
469
+ await GitHubTestUserModel.deleteMany({});
470
+
471
+ function addRoutes(router: express.Router): void {
472
+ router.get("/test", (_req, res) => res.json({ok: true}));
473
+ }
474
+
475
+ app = setupServer({
476
+ addRoutes,
477
+ githubAuth: {
478
+ allowAccountLinking: true,
479
+ callbackURL: "http://localhost:9000/auth/github/callback",
480
+ clientId: "test-client-id",
481
+ clientSecret: "test-client-secret",
482
+ },
483
+ skipListen: true,
484
+ userModel: GitHubTestUserModel as any,
485
+ });
486
+ agent = supertest.agent(app);
487
+ });
488
+
489
+ afterEach(() => {
490
+ setSystemTime();
491
+ });
492
+
493
+ it("GET /auth/github/link requires JWT authentication", async () => {
494
+ const res = await agent.get("/auth/github/link").expect(401);
495
+ expect(res.body.message).toContain("Authentication required");
496
+ });
497
+
498
+ it("GET /auth/github with returnTo stores it in session", async () => {
499
+ const res = await agent.get("/auth/github?returnTo=https://example.com/cb").expect(302);
500
+ expect(res.headers.location).toContain("github.com");
501
+ });
502
+
503
+ it("DELETE /auth/github/unlink clears GitHub fields", async () => {
504
+ const user = await GitHubTestUserModel.create({
505
+ email: "unlinkme@example.com",
506
+ githubAvatarUrl: "http://avatar",
507
+ githubId: "77777",
508
+ githubProfileUrl: "http://profile",
509
+ githubUsername: "ghunlink",
510
+ } as any);
511
+ await (user as any).setPassword("password123");
512
+ await user.save();
513
+
514
+ const loginRes = await agent
515
+ .post("/auth/login")
516
+ .send({email: "unlinkme@example.com", password: "password123"})
517
+ .expect(200);
518
+
519
+ await agent
520
+ .delete("/auth/github/unlink")
521
+ .set("authorization", `Bearer ${loginRes.body.data.token}`)
522
+ .expect(200);
523
+
524
+ const updatedUser = await GitHubTestUserModel.findOne({email: "unlinkme@example.com"});
525
+ expect((updatedUser as any).githubId).toBeUndefined();
526
+ expect((updatedUser as any).githubAvatarUrl).toBeUndefined();
527
+ expect((updatedUser as any).githubProfileUrl).toBeUndefined();
528
+ });
529
+ });