@terreno/api 0.9.3 → 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/bunfig.unit.toml +3 -0
- package/dist/auth.test.js +257 -0
- package/dist/consentApp.test.js +245 -0
- package/dist/expressServer.js +3 -9
- package/dist/expressServer.test.js +4 -7
- 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 +125 -0
- package/dist/openApiBuilder.d.ts +1 -0
- package/dist/openApiBuilder.js +13 -2
- package/dist/openApiBuilder.test.js +66 -0
- package/dist/openApiEtag.test.js +8 -0
- package/dist/openApiValidator.test.js +309 -0
- package/dist/permissions.middleware.test.d.ts +1 -0
- package/dist/permissions.middleware.test.js +341 -0
- package/dist/plugins.d.ts +8 -8
- package/dist/plugins.js +38 -32
- package/dist/populate.test.js +99 -0
- package/dist/syncConsents.js +2 -2
- package/dist/syncConsents.test.js +273 -0
- package/dist/tests/bunSetup.js +27 -22
- 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/expressServer.test.ts +4 -11
- package/src/expressServer.ts +4 -8
- 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 +157 -1
- package/src/openApi.ts +6 -2
- package/src/openApiBuilder.test.ts +81 -0
- package/src/openApiBuilder.ts +17 -2
- package/src/openApiEtag.test.ts +11 -0
- package/src/openApiValidator.test.ts +410 -0
- package/src/permissions.middleware.test.ts +197 -0
- package/src/plugins.ts +32 -23
- package/src/populate.test.ts +78 -2
- package/src/syncConsents.test.ts +145 -0
- package/src/syncConsents.ts +1 -1
- package/src/tests/bunSetup.ts +14 -8
- package/src/tests.ts +8 -8
- package/src/utils.ts +4 -4
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({
|
|
@@ -310,21 +310,14 @@ describe("expressServer", () => {
|
|
|
310
310
|
);
|
|
311
311
|
});
|
|
312
312
|
|
|
313
|
-
|
|
314
|
-
// schedule to a cron expression but then use the original schedule string.
|
|
315
|
-
// This test documents that current (buggy) behavior.
|
|
316
|
-
it("hourly alias fails due to bug in implementation", () => {
|
|
313
|
+
it("accepts hourly alias", () => {
|
|
317
314
|
const callback = () => {};
|
|
318
|
-
expect(() => cronjob("test-hourly-alias", "hourly", callback)).toThrow(
|
|
319
|
-
"Failed to create cronjob"
|
|
320
|
-
);
|
|
315
|
+
expect(() => cronjob("test-hourly-alias", "hourly", callback)).not.toThrow();
|
|
321
316
|
});
|
|
322
317
|
|
|
323
|
-
it("minutely alias
|
|
318
|
+
it("accepts minutely alias", () => {
|
|
324
319
|
const callback = () => {};
|
|
325
|
-
expect(() => cronjob("test-minutely-alias", "minutely", callback)).toThrow(
|
|
326
|
-
"Failed to create cronjob"
|
|
327
|
-
);
|
|
320
|
+
expect(() => cronjob("test-minutely-alias", "minutely", callback)).not.toThrow();
|
|
328
321
|
});
|
|
329
322
|
});
|
|
330
323
|
|
package/src/expressServer.ts
CHANGED
|
@@ -340,15 +340,11 @@ export function cronjob(
|
|
|
340
340
|
schedule: "hourly" | "minutely" | string,
|
|
341
341
|
callback: () => void
|
|
342
342
|
) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
} else if (schedule === "minutely") {
|
|
347
|
-
_cronSchedule = "* * * * *";
|
|
348
|
-
}
|
|
349
|
-
logger.info(`Adding cronjob ${name}, running at: ${schedule}`);
|
|
343
|
+
const cronSchedule =
|
|
344
|
+
schedule === "hourly" ? "0 * * * *" : schedule === "minutely" ? "* * * * *" : schedule;
|
|
345
|
+
logger.info(`Adding cronjob ${name}, running at: ${cronSchedule}`);
|
|
350
346
|
try {
|
|
351
|
-
new cron.CronJob(
|
|
347
|
+
new cron.CronJob(cronSchedule, callback, null, true, "America/Chicago");
|
|
352
348
|
} catch (error) {
|
|
353
349
|
throw new Error(`Failed to create cronjob: ${error}`);
|
|
354
350
|
}
|
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
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import winston from "winston";
|
|
6
|
+
|
|
7
|
+
import {logger, setupLogging} from "./logger";
|
|
8
|
+
|
|
9
|
+
describe("logger", () => {
|
|
10
|
+
const OLD_ENV = process.env;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
process.env = {...OLD_ENV};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
process.env = OLD_ENV;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("logger.info writes a log entry", () => {
|
|
21
|
+
expect(() => logger.info("test info message")).not.toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("logger.warn writes a log entry", () => {
|
|
25
|
+
expect(() => logger.warn("test warn message")).not.toThrow();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("logger.error writes a log entry", () => {
|
|
29
|
+
expect(() => logger.error("test error message")).not.toThrow();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("logger.debug writes a log entry", () => {
|
|
33
|
+
expect(() => logger.debug("test debug message")).not.toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("logger.catch handles Error instance", () => {
|
|
37
|
+
expect(() => logger.catch(new Error("caught error"))).not.toThrow();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("logger.catch handles non-Error value", () => {
|
|
41
|
+
expect(() => logger.catch("string error")).not.toThrow();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("logger.catch with Sentry logging enabled and Error", () => {
|
|
45
|
+
process.env.USE_SENTRY_LOGGING = "true";
|
|
46
|
+
expect(() => logger.catch(new Error("captured"))).not.toThrow();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("setupLogging", () => {
|
|
51
|
+
let tempDir: string;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logger-test-"));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
try {
|
|
59
|
+
fs.rmSync(tempDir, {force: true, recursive: true});
|
|
60
|
+
} catch {}
|
|
61
|
+
// Restore a default logger config
|
|
62
|
+
setupLogging({disableFileLogging: true});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("disables console logging when disableConsoleLogging is true", () => {
|
|
66
|
+
expect(() =>
|
|
67
|
+
setupLogging({
|
|
68
|
+
disableConsoleLogging: true,
|
|
69
|
+
disableFileLogging: true,
|
|
70
|
+
})
|
|
71
|
+
).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("disables console colors when disableConsoleColors is true", () => {
|
|
75
|
+
expect(() =>
|
|
76
|
+
setupLogging({
|
|
77
|
+
disableConsoleColors: true,
|
|
78
|
+
disableFileLogging: true,
|
|
79
|
+
})
|
|
80
|
+
).not.toThrow();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("adds timestamps when showConsoleTimestamps is true", () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
setupLogging({
|
|
86
|
+
disableFileLogging: true,
|
|
87
|
+
showConsoleTimestamps: true,
|
|
88
|
+
})
|
|
89
|
+
).not.toThrow();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("respects logLevel option", () => {
|
|
93
|
+
expect(() =>
|
|
94
|
+
setupLogging({
|
|
95
|
+
disableFileLogging: true,
|
|
96
|
+
level: "info",
|
|
97
|
+
})
|
|
98
|
+
).not.toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("creates log directory if it does not exist", () => {
|
|
102
|
+
const nonExistentDir = path.join(tempDir, "nested", "logs");
|
|
103
|
+
setupLogging({
|
|
104
|
+
disableConsoleLogging: true,
|
|
105
|
+
logDirectory: nonExistentDir,
|
|
106
|
+
});
|
|
107
|
+
expect(fs.existsSync(nonExistentDir)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("uses existing log directory if it exists", () => {
|
|
111
|
+
const existingDir = path.join(tempDir, "existing");
|
|
112
|
+
fs.mkdirSync(existingDir);
|
|
113
|
+
expect(() =>
|
|
114
|
+
setupLogging({
|
|
115
|
+
disableConsoleLogging: true,
|
|
116
|
+
logDirectory: existingDir,
|
|
117
|
+
})
|
|
118
|
+
).not.toThrow();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("adds custom transports when provided", () => {
|
|
122
|
+
const customTransport = new winston.transports.Console({level: "error"});
|
|
123
|
+
expect(() =>
|
|
124
|
+
setupLogging({
|
|
125
|
+
disableConsoleLogging: true,
|
|
126
|
+
disableFileLogging: true,
|
|
127
|
+
transports: [customTransport],
|
|
128
|
+
})
|
|
129
|
+
).not.toThrow();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("uses file logging at info level when level is info (no debug file)", () => {
|
|
133
|
+
setupLogging({
|
|
134
|
+
disableConsoleLogging: true,
|
|
135
|
+
level: "info",
|
|
136
|
+
logDirectory: tempDir,
|
|
137
|
+
});
|
|
138
|
+
// No assertion needed - just verifying branch coverage with level=info
|
|
139
|
+
expect(true).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("uses file logging at debug level by default (with debug file)", () => {
|
|
143
|
+
setupLogging({
|
|
144
|
+
disableConsoleLogging: true,
|
|
145
|
+
logDirectory: tempDir,
|
|
146
|
+
});
|
|
147
|
+
expect(true).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|