@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/bunfig.toml +5 -2
- package/dist/auth.test.js +257 -0
- package/dist/consentApp.test.js +245 -0
- package/dist/githubAuth.test.js +380 -0
- package/dist/logger.test.d.ts +1 -0
- package/dist/logger.test.js +143 -0
- package/dist/notifiers/googleChatNotifier.test.js +37 -0
- package/dist/openApi.js +2 -2
- package/dist/openApi.test.js +122 -0
- package/dist/openApiBuilder.d.ts +1 -0
- package/dist/openApiBuilder.js +13 -2
- package/dist/openApiBuilder.test.js +66 -0
- package/dist/openApiValidator.test.js +309 -0
- package/dist/plugins.d.ts +8 -8
- package/dist/plugins.js +38 -32
- package/dist/populate.test.js +99 -0
- package/dist/syncConsents.test.js +273 -0
- package/dist/tests.d.ts +3 -3
- package/dist/tests.js +78 -82
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +7 -7
- package/package.json +2 -1
- package/src/__snapshots__/openApi.test.ts.snap +48 -0
- package/src/auth.test.ts +147 -0
- package/src/consentApp.test.ts +162 -0
- package/src/githubAuth.test.ts +307 -1
- package/src/logger.test.ts +149 -0
- package/src/notifiers/googleChatNotifier.test.ts +24 -0
- package/src/openApi.test.ts +152 -0
- package/src/openApi.ts +6 -2
- package/src/openApiBuilder.test.ts +81 -0
- package/src/openApiBuilder.ts +17 -2
- package/src/openApiValidator.test.ts +410 -0
- package/src/plugins.ts +32 -23
- package/src/populate.test.ts +78 -2
- package/src/syncConsents.test.ts +145 -0
- package/src/tests.ts +8 -8
- package/src/utils.ts +4 -4
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
|
});
|
package/src/consentApp.test.ts
CHANGED
|
@@ -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({
|
package/src/githubAuth.test.ts
CHANGED
|
@@ -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
|
+
});
|