@terreno/api 0.13.0 → 0.13.2
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/dist/api.js +9 -0
- package/dist/api.test.js +2 -2
- package/dist/consentApp.d.ts +2 -2
- package/dist/consentApp.js +2 -1
- package/dist/githubAuth.test.js +409 -0
- package/dist/models/consentForm.js +8 -9
- package/dist/models/versionConfig.d.ts +1 -1
- package/dist/notifiers/slackNotifier.d.ts +2 -2
- package/dist/notifiers/slackNotifier.js +38 -7
- package/dist/plugins.d.ts +3 -3
- package/dist/plugins.js +8 -4
- package/dist/populate.test.js +5 -1
- package/dist/secretProviders.js +4 -1
- package/dist/tests.js +1 -0
- package/dist/transformers.d.ts +5 -5
- package/dist/transformers.js +38 -37
- package/dist/utils.js +13 -3
- package/package.json +1 -1
- package/src/api.test.ts +2 -2
- package/src/api.ts +9 -0
- package/src/consentApp.ts +3 -3
- package/src/githubAuth.test.ts +327 -0
- package/src/models/consentForm.ts +8 -10
- package/src/models/versionConfig.ts +1 -1
- package/src/notifiers/slackNotifier.ts +7 -6
- package/src/openApiEtag.ts +1 -1
- package/src/plugins.ts +13 -8
- package/src/populate.test.ts +45 -20
- package/src/secretProviders.ts +4 -3
- package/src/tests.ts +18 -14
- package/src/transformers.ts +32 -30
- package/src/utils.ts +13 -3
package/src/githubAuth.test.ts
CHANGED
|
@@ -6,11 +6,48 @@ import passportLocalMongoose from "passport-local-mongoose";
|
|
|
6
6
|
import supertest from "supertest";
|
|
7
7
|
import type TestAgent from "supertest/lib/agent";
|
|
8
8
|
|
|
9
|
+
import {generateTokens, type UserModel} from "./auth";
|
|
9
10
|
import {setupServer} from "./expressServer";
|
|
10
11
|
import {type GitHubUserFields, githubUserPlugin, setupGitHubAuth} from "./githubAuth";
|
|
11
12
|
import {logger} from "./logger";
|
|
12
13
|
import {createdUpdatedPlugin, isDisabledPlugin} from "./plugins";
|
|
13
14
|
|
|
15
|
+
interface FakeStrategyOutcome {
|
|
16
|
+
type: "success" | "redirect" | "fail";
|
|
17
|
+
user?: unknown;
|
|
18
|
+
url?: string;
|
|
19
|
+
challenge?: {message: string};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let fakeGithubOutcome: FakeStrategyOutcome = {type: "redirect", url: "http://github.com/mock"};
|
|
23
|
+
|
|
24
|
+
interface FakePassportStrategy {
|
|
25
|
+
name: string;
|
|
26
|
+
success: (user: unknown) => void;
|
|
27
|
+
fail: (challenge: {message: string}) => void;
|
|
28
|
+
redirect: (url: string) => void;
|
|
29
|
+
error: (err: Error) => void;
|
|
30
|
+
authenticate: (req: express.Request) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const installFakeGithubStrategy = (): void => {
|
|
34
|
+
const strategy: Pick<FakePassportStrategy, "name" | "authenticate"> = {
|
|
35
|
+
authenticate(this: FakePassportStrategy): void {
|
|
36
|
+
if (fakeGithubOutcome.type === "success") {
|
|
37
|
+
this.success(fakeGithubOutcome.user);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (fakeGithubOutcome.type === "fail") {
|
|
41
|
+
this.fail(fakeGithubOutcome.challenge ?? {message: "auth failed"});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
this.redirect(fakeGithubOutcome.url ?? "http://github.com/mock");
|
|
45
|
+
},
|
|
46
|
+
name: "github",
|
|
47
|
+
};
|
|
48
|
+
passport.use("github", strategy as unknown as passport.Strategy);
|
|
49
|
+
};
|
|
50
|
+
|
|
14
51
|
interface TestUser extends GitHubUserFields {
|
|
15
52
|
admin: boolean;
|
|
16
53
|
name?: string;
|
|
@@ -19,6 +56,13 @@ interface TestUser extends GitHubUserFields {
|
|
|
19
56
|
disabled?: boolean;
|
|
20
57
|
}
|
|
21
58
|
|
|
59
|
+
interface PasswordedDocument {
|
|
60
|
+
setPassword: (password: string) => Promise<unknown>;
|
|
61
|
+
save: () => Promise<unknown>;
|
|
62
|
+
hash?: string;
|
|
63
|
+
salt?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
22
66
|
// Create schema for GitHub-enabled user
|
|
23
67
|
const testUserSchema = new Schema<TestUser>({
|
|
24
68
|
admin: {default: false, description: "Whether the user has admin privileges", type: Boolean},
|
|
@@ -527,3 +571,286 @@ describe("addGitHubAuthRoutes link endpoints", () => {
|
|
|
527
571
|
expect((updatedUser as any).githubProfileUrl).toBeUndefined();
|
|
528
572
|
});
|
|
529
573
|
});
|
|
574
|
+
|
|
575
|
+
describe("GitHub callback handler (fake strategy)", () => {
|
|
576
|
+
let app: express.Application;
|
|
577
|
+
let agent: TestAgent;
|
|
578
|
+
|
|
579
|
+
beforeEach(async () => {
|
|
580
|
+
setSystemTime();
|
|
581
|
+
await connectDb();
|
|
582
|
+
await GitHubTestUserModel.deleteMany({});
|
|
583
|
+
|
|
584
|
+
function addRoutes(router: express.Router): void {
|
|
585
|
+
router.get("/test", (_req, res) => res.json({ok: true}));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
app = setupServer({
|
|
589
|
+
addMiddleware: (a) => {
|
|
590
|
+
// The handler reads (req as unknown as {session?: {returnTo?: string}}).session?.returnTo.
|
|
591
|
+
// setupServer does not install express-session, so prime a fake session from a request
|
|
592
|
+
// header for tests.
|
|
593
|
+
a.use((req, _res, next) => {
|
|
594
|
+
const headerReturnTo = req.headers["x-mock-return-to"];
|
|
595
|
+
if (typeof headerReturnTo === "string") {
|
|
596
|
+
(req as unknown as {session: {returnTo: string}}).session = {returnTo: headerReturnTo};
|
|
597
|
+
}
|
|
598
|
+
next();
|
|
599
|
+
});
|
|
600
|
+
},
|
|
601
|
+
addRoutes,
|
|
602
|
+
githubAuth: {
|
|
603
|
+
allowAccountLinking: true,
|
|
604
|
+
callbackURL: "http://localhost:9000/auth/github/callback",
|
|
605
|
+
clientId: "test-client-id",
|
|
606
|
+
clientSecret: "test-client-secret",
|
|
607
|
+
},
|
|
608
|
+
skipListen: true,
|
|
609
|
+
userModel: GitHubTestUserModel as unknown as UserModel,
|
|
610
|
+
});
|
|
611
|
+
// Swap the github strategy with our fake after setupServer registered it.
|
|
612
|
+
installFakeGithubStrategy();
|
|
613
|
+
agent = supertest.agent(app);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
afterEach(() => {
|
|
617
|
+
setSystemTime();
|
|
618
|
+
fakeGithubOutcome = {type: "redirect", url: "http://github.com/mock"};
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("GET /auth/github/callback returns JSON tokens on success", async () => {
|
|
622
|
+
const user = await GitHubTestUserModel.create({
|
|
623
|
+
email: "cb@example.com",
|
|
624
|
+
githubId: "cb-gh-1",
|
|
625
|
+
name: "CB User",
|
|
626
|
+
} as unknown as TestUser);
|
|
627
|
+
|
|
628
|
+
fakeGithubOutcome = {type: "success", user};
|
|
629
|
+
|
|
630
|
+
const res = await agent.get("/auth/github/callback").expect(200);
|
|
631
|
+
expect(res.body.data.token).toBeDefined();
|
|
632
|
+
expect(res.body.data.refreshToken).toBeDefined();
|
|
633
|
+
expect(res.body.data.userId).toBeDefined();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it("GET /auth/github/callback redirects to returnTo with tokens when session.returnTo is set", async () => {
|
|
637
|
+
const user = await GitHubTestUserModel.create({
|
|
638
|
+
email: "cb2@example.com",
|
|
639
|
+
githubId: "cb-gh-2",
|
|
640
|
+
name: "CB User 2",
|
|
641
|
+
} as unknown as TestUser);
|
|
642
|
+
|
|
643
|
+
fakeGithubOutcome = {type: "success", user};
|
|
644
|
+
const res = await agent
|
|
645
|
+
.get("/auth/github/callback")
|
|
646
|
+
.set("x-mock-return-to", "https://example.com/cb")
|
|
647
|
+
.expect(302);
|
|
648
|
+
expect(res.headers.location).toContain("https://example.com/cb");
|
|
649
|
+
expect(res.headers.location).toContain("token=");
|
|
650
|
+
expect(res.headers.location).toContain("refreshToken=");
|
|
651
|
+
expect(res.headers.location).toContain("userId=");
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("GET /auth/github/callback redirects on failure", async () => {
|
|
655
|
+
fakeGithubOutcome = {challenge: {message: "denied"}, type: "fail"};
|
|
656
|
+
const res = await agent.get("/auth/github/callback").expect(302);
|
|
657
|
+
expect(res.headers.location).toContain("/auth/github/failure");
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("GET /auth/github/callback returns 500 when token generation fails", async () => {
|
|
661
|
+
const user = await GitHubTestUserModel.create({
|
|
662
|
+
email: "cb3@example.com",
|
|
663
|
+
githubId: "cb-gh-3",
|
|
664
|
+
name: "CB User 3",
|
|
665
|
+
} as unknown as TestUser);
|
|
666
|
+
|
|
667
|
+
fakeGithubOutcome = {type: "success", user};
|
|
668
|
+
|
|
669
|
+
const savedSecret = process.env.TOKEN_SECRET;
|
|
670
|
+
process.env.TOKEN_SECRET = "";
|
|
671
|
+
try {
|
|
672
|
+
const res = await agent.get("/auth/github/callback").expect(500);
|
|
673
|
+
expect(res.body.message).toBe("Authentication failed");
|
|
674
|
+
} finally {
|
|
675
|
+
process.env.TOKEN_SECRET = savedSecret;
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
describe("GET /auth/github/link with JWT (fake strategy)", () => {
|
|
681
|
+
let app: express.Application;
|
|
682
|
+
let agent: TestAgent;
|
|
683
|
+
|
|
684
|
+
beforeEach(async () => {
|
|
685
|
+
setSystemTime();
|
|
686
|
+
await connectDb();
|
|
687
|
+
await GitHubTestUserModel.deleteMany({});
|
|
688
|
+
|
|
689
|
+
function addRoutes(router: express.Router): void {
|
|
690
|
+
router.get("/test", (_req, res) => res.json({ok: true}));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
app = setupServer({
|
|
694
|
+
addRoutes,
|
|
695
|
+
githubAuth: {
|
|
696
|
+
allowAccountLinking: true,
|
|
697
|
+
callbackURL: "http://localhost:9000/auth/github/callback",
|
|
698
|
+
clientId: "test-client-id",
|
|
699
|
+
clientSecret: "test-client-secret",
|
|
700
|
+
},
|
|
701
|
+
skipListen: true,
|
|
702
|
+
userModel: GitHubTestUserModel as unknown as UserModel,
|
|
703
|
+
});
|
|
704
|
+
installFakeGithubStrategy();
|
|
705
|
+
agent = supertest.agent(app);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
afterEach(() => {
|
|
709
|
+
setSystemTime();
|
|
710
|
+
fakeGithubOutcome = {type: "redirect", url: "http://github.com/mock"};
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("GET /auth/github/link forwards to GitHub auth when JWT is valid", async () => {
|
|
714
|
+
const user = await GitHubTestUserModel.create({
|
|
715
|
+
email: "linkjwt@example.com",
|
|
716
|
+
name: "Link JWT User",
|
|
717
|
+
} as unknown as TestUser);
|
|
718
|
+
await (user as unknown as PasswordedDocument).setPassword("password123");
|
|
719
|
+
await user.save();
|
|
720
|
+
|
|
721
|
+
const loginRes = await agent
|
|
722
|
+
.post("/auth/login")
|
|
723
|
+
.send({email: "linkjwt@example.com", password: "password123"})
|
|
724
|
+
.expect(200);
|
|
725
|
+
|
|
726
|
+
fakeGithubOutcome = {type: "redirect", url: "http://github.com/auth"};
|
|
727
|
+
const res = await agent
|
|
728
|
+
.get("/auth/github/link")
|
|
729
|
+
.set("authorization", `Bearer ${loginRes.body.data.token}`)
|
|
730
|
+
.expect(302);
|
|
731
|
+
expect(res.headers.location).toBe("http://github.com/auth");
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
describe("DELETE /auth/github/unlink edge cases", () => {
|
|
736
|
+
let app: express.Application;
|
|
737
|
+
let agent: TestAgent;
|
|
738
|
+
|
|
739
|
+
beforeEach(async () => {
|
|
740
|
+
setSystemTime();
|
|
741
|
+
await connectDb();
|
|
742
|
+
await GitHubTestUserModel.deleteMany({});
|
|
743
|
+
|
|
744
|
+
function addRoutes(router: express.Router): void {
|
|
745
|
+
router.get("/test", (_req, res) => res.json({ok: true}));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
app = setupServer({
|
|
749
|
+
addRoutes,
|
|
750
|
+
githubAuth: {
|
|
751
|
+
allowAccountLinking: true,
|
|
752
|
+
callbackURL: "http://localhost:9000/auth/github/callback",
|
|
753
|
+
clientId: "test-client-id",
|
|
754
|
+
clientSecret: "test-client-secret",
|
|
755
|
+
},
|
|
756
|
+
skipListen: true,
|
|
757
|
+
userModel: GitHubTestUserModel as unknown as UserModel,
|
|
758
|
+
});
|
|
759
|
+
installFakeGithubStrategy();
|
|
760
|
+
agent = supertest.agent(app);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
afterEach(() => {
|
|
764
|
+
setSystemTime();
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("returns 400 when user has no password (no other auth method)", async () => {
|
|
768
|
+
const user = await GitHubTestUserModel.create({
|
|
769
|
+
email: "ghonly@example.com",
|
|
770
|
+
githubId: "ghonly-1",
|
|
771
|
+
githubUsername: "ghonly",
|
|
772
|
+
} as unknown as TestUser);
|
|
773
|
+
|
|
774
|
+
const {token} = await generateTokens({_id: (user as unknown as {_id: unknown})._id});
|
|
775
|
+
|
|
776
|
+
const res = await agent
|
|
777
|
+
.delete("/auth/github/unlink")
|
|
778
|
+
.set("authorization", `Bearer ${token}`)
|
|
779
|
+
.expect(400);
|
|
780
|
+
expect(res.body.message).toContain("Cannot unlink GitHub account");
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("returns 500 when save throws during unlink", async () => {
|
|
784
|
+
const user = await GitHubTestUserModel.create({
|
|
785
|
+
email: "savefail@example.com",
|
|
786
|
+
githubId: "savefail-1",
|
|
787
|
+
} as unknown as TestUser);
|
|
788
|
+
await (user as unknown as PasswordedDocument).setPassword("password123");
|
|
789
|
+
await user.save();
|
|
790
|
+
|
|
791
|
+
const loginRes = await agent
|
|
792
|
+
.post("/auth/login")
|
|
793
|
+
.send({email: "savefail@example.com", password: "password123"})
|
|
794
|
+
.expect(200);
|
|
795
|
+
|
|
796
|
+
interface MockableFindById {
|
|
797
|
+
findById: (id: unknown) => {
|
|
798
|
+
select: (fields: string) => Promise<PasswordedDocument | null>;
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
const mockableModel = GitHubTestUserModel as unknown as MockableFindById;
|
|
802
|
+
const originalFindById = mockableModel.findById;
|
|
803
|
+
mockableModel.findById = () => ({
|
|
804
|
+
select: async () => ({
|
|
805
|
+
hash: "x",
|
|
806
|
+
salt: "y",
|
|
807
|
+
save: async () => {
|
|
808
|
+
throw new Error("boom");
|
|
809
|
+
},
|
|
810
|
+
setPassword: async () => undefined,
|
|
811
|
+
}),
|
|
812
|
+
});
|
|
813
|
+
try {
|
|
814
|
+
const res = await agent
|
|
815
|
+
.delete("/auth/github/unlink")
|
|
816
|
+
.set("authorization", `Bearer ${loginRes.body.data.token}`)
|
|
817
|
+
.expect(500);
|
|
818
|
+
expect(res.body.message).toBe("Failed to unlink GitHub account");
|
|
819
|
+
} finally {
|
|
820
|
+
mockableModel.findById = originalFindById;
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
describe("GitHub strategy verify callback edge cases", () => {
|
|
826
|
+
const testApp = {get: () => {}, use: () => {}} as unknown as express.Application;
|
|
827
|
+
|
|
828
|
+
beforeEach(async () => {
|
|
829
|
+
await connectDb();
|
|
830
|
+
await GitHubTestUserModel.deleteMany({});
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("returns 404 when linking a user whose record disappears", async () => {
|
|
834
|
+
const existingUser = await GitHubTestUserModel.create({
|
|
835
|
+
email: "gone@example.com",
|
|
836
|
+
} as unknown as TestUser);
|
|
837
|
+
|
|
838
|
+
setupGitHubAuth(testApp, GitHubTestUserModel as unknown as UserModel, {
|
|
839
|
+
allowAccountLinking: true,
|
|
840
|
+
callbackURL: "http://localhost:9000/auth/github/callback",
|
|
841
|
+
clientId: "id",
|
|
842
|
+
clientSecret: "secret",
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
// Delete user before verify runs to hit the 404 path.
|
|
846
|
+
await GitHubTestUserModel.deleteOne({_id: (existingUser as unknown as {_id: unknown})._id});
|
|
847
|
+
|
|
848
|
+
const req = {user: existingUser};
|
|
849
|
+
const result = await invokeGitHubVerify(req, "access", "refresh", {
|
|
850
|
+
id: "gh-missing-1",
|
|
851
|
+
username: "missing",
|
|
852
|
+
});
|
|
853
|
+
expect(result.err).toBeDefined();
|
|
854
|
+
expect(result.err?.status).toBe(404);
|
|
855
|
+
});
|
|
856
|
+
});
|
|
@@ -2,16 +2,14 @@ import mongoose from "mongoose";
|
|
|
2
2
|
import {createdUpdatedPlugin, findExactlyOne, findOneOrNone, isDeletedPlugin} from "../plugins";
|
|
3
3
|
import type {ConsentFormDocument, ConsentFormModel} from "../types/consentForm";
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const consentFormTypeValues = Object.values(consentFormTypeMap);
|
|
5
|
+
const consentFormTypeValues = [
|
|
6
|
+
"agreement",
|
|
7
|
+
"privacy",
|
|
8
|
+
"hipaa",
|
|
9
|
+
"research",
|
|
10
|
+
"terms",
|
|
11
|
+
"custom",
|
|
12
|
+
] as const;
|
|
15
13
|
|
|
16
14
|
const consentFormSchema = new mongoose.Schema<ConsentFormDocument, ConsentFormModel>(
|
|
17
15
|
{
|
|
@@ -17,7 +17,7 @@ export interface VersionConfigDocument extends mongoose.Document {
|
|
|
17
17
|
|
|
18
18
|
export interface VersionConfigModel extends mongoose.Model<VersionConfigDocument> {
|
|
19
19
|
findOneOrNone(
|
|
20
|
-
query: Record<string,
|
|
20
|
+
query: Record<string, unknown>,
|
|
21
21
|
errorArgs?: Partial<APIErrorConstructor>
|
|
22
22
|
): Promise<(Document & VersionConfigDocument) | null>;
|
|
23
23
|
}
|
|
@@ -7,7 +7,7 @@ import {logger} from "../logger";
|
|
|
7
7
|
// If `url` is provided, it will be used directly instead of looking up from environment.
|
|
8
8
|
// DEPRECATED: Looking up webhook URLs from the SLACK_WEBHOOKS environment variable by channel name
|
|
9
9
|
// is deprecated and will be removed in a future version. Please pass the `url` parameter directly.
|
|
10
|
-
export async
|
|
10
|
+
export const sendToSlack = async (
|
|
11
11
|
text: string,
|
|
12
12
|
{
|
|
13
13
|
slackChannel,
|
|
@@ -15,7 +15,7 @@ export async function sendToSlack(
|
|
|
15
15
|
env,
|
|
16
16
|
url,
|
|
17
17
|
}: {slackChannel?: string; shouldThrow?: boolean; env?: string; url?: string} = {}
|
|
18
|
-
) {
|
|
18
|
+
) => {
|
|
19
19
|
let slackWebhookUrl = url;
|
|
20
20
|
|
|
21
21
|
if (!slackWebhookUrl) {
|
|
@@ -53,14 +53,15 @@ export async function sendToSlack(
|
|
|
53
53
|
await axios.post(slackWebhookUrl, {
|
|
54
54
|
text: formattedText,
|
|
55
55
|
});
|
|
56
|
-
} catch (error:
|
|
57
|
-
|
|
56
|
+
} catch (error: unknown) {
|
|
57
|
+
const errorObj = error as {text?: string; message?: string};
|
|
58
|
+
logger.error(`Error posting to slack: ${errorObj.text ?? errorObj.message}`);
|
|
58
59
|
Sentry.captureException(error);
|
|
59
60
|
if (shouldThrow) {
|
|
60
61
|
throw new APIError({
|
|
61
62
|
status: 500,
|
|
62
|
-
title: `Error posting to slack: ${
|
|
63
|
+
title: `Error posting to slack: ${errorObj.text ?? errorObj.message}`,
|
|
63
64
|
});
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
|
-
}
|
|
67
|
+
};
|
package/src/openApiEtag.ts
CHANGED
|
@@ -14,7 +14,7 @@ export const openApiEtagMiddleware = (req: Request, res: Response, next: NextFun
|
|
|
14
14
|
|
|
15
15
|
const originalJson = res.json.bind(res);
|
|
16
16
|
|
|
17
|
-
res.json = (body:
|
|
17
|
+
res.json = (body: unknown) => {
|
|
18
18
|
const jsonString = JSON.stringify(body);
|
|
19
19
|
const etag = `"${crypto.createHash("sha256").update(jsonString).digest("hex").substring(0, 16)}"`;
|
|
20
20
|
|
package/src/plugins.ts
CHANGED
|
@@ -85,14 +85,14 @@ export const createdUpdatedPlugin = (schema: Schema<any, any, any, any>): void =
|
|
|
85
85
|
}
|
|
86
86
|
// If we aren't specifying created, use now.
|
|
87
87
|
if (!this.created) {
|
|
88
|
-
this.created =
|
|
88
|
+
this.created = DateTime.now().toJSDate();
|
|
89
89
|
}
|
|
90
90
|
// All writes change the updated time.
|
|
91
|
-
this.updated =
|
|
91
|
+
this.updated = DateTime.now().toJSDate();
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
schema.pre(/save|updateOne|insertMany/, function () {
|
|
95
|
-
void this.updateOne({}, {$set: {updated:
|
|
95
|
+
void this.updateOne({}, {$set: {updated: DateTime.now().toJSDate()}});
|
|
96
96
|
});
|
|
97
97
|
};
|
|
98
98
|
|
|
@@ -253,7 +253,7 @@ export interface FindExactlyOnePlugin<T> {
|
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
export class DateOnly extends SchemaType {
|
|
256
|
-
constructor(key: string, options: SchemaTypeOptions<
|
|
256
|
+
constructor(key: string, options: SchemaTypeOptions<Date>) {
|
|
257
257
|
super(key, options, "DateOnly");
|
|
258
258
|
}
|
|
259
259
|
|
|
@@ -262,6 +262,7 @@ export class DateOnly extends SchemaType {
|
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
$conditionalHandlers = {
|
|
265
|
+
// noExplicitAny: $conditionalHandlers is not exposed on SchemaType's prototype in Mongoose's public type definitions
|
|
265
266
|
...(SchemaType as any).prototype.$conditionalHandlers,
|
|
266
267
|
$gt: this.handleSingle,
|
|
267
268
|
$gte: this.handleSingle,
|
|
@@ -273,6 +274,7 @@ export class DateOnly extends SchemaType {
|
|
|
273
274
|
// When using $gt, $gte, $lt, $lte, etc, we need to cast the value to a Date
|
|
274
275
|
castForQuery($conditional, val, context): Date | undefined {
|
|
275
276
|
if ($conditional == null) {
|
|
277
|
+
// noExplicitAny: applySetters is an internal Mongoose SchemaType method not in public type definitions
|
|
276
278
|
return (this as any).applySetters(val, context);
|
|
277
279
|
}
|
|
278
280
|
|
|
@@ -287,7 +289,7 @@ export class DateOnly extends SchemaType {
|
|
|
287
289
|
|
|
288
290
|
// When either setting a value to a DateOnly or fetching from the DB,
|
|
289
291
|
// we want to strip off the time portion.
|
|
290
|
-
cast(val:
|
|
292
|
+
cast(val: unknown): Date | undefined {
|
|
291
293
|
if (val instanceof Date) {
|
|
292
294
|
const date = DateTime.fromJSDate(val).toUTC().startOf("day");
|
|
293
295
|
if (!date.isValid) {
|
|
@@ -314,7 +316,7 @@ export class DateOnly extends SchemaType {
|
|
|
314
316
|
}
|
|
315
317
|
// Handle $gte, $lte, etc
|
|
316
318
|
if (typeof val === "object") {
|
|
317
|
-
return val;
|
|
319
|
+
return val as Date;
|
|
318
320
|
}
|
|
319
321
|
throw new MongooseError.CastError(
|
|
320
322
|
"DateOnly",
|
|
@@ -324,10 +326,13 @@ export class DateOnly extends SchemaType {
|
|
|
324
326
|
);
|
|
325
327
|
}
|
|
326
328
|
|
|
327
|
-
get(val:
|
|
328
|
-
return (val instanceof Date
|
|
329
|
+
get(val: unknown): this {
|
|
330
|
+
return (val instanceof Date
|
|
331
|
+
? DateTime.fromJSDate(val).startOf("day").toJSDate()
|
|
332
|
+
: val) as unknown as this;
|
|
329
333
|
}
|
|
330
334
|
}
|
|
331
335
|
|
|
332
336
|
// Register DateOnly with Mongoose's Schema.Types
|
|
337
|
+
// noExplicitAny: DateOnly is a custom SchemaType not declared in Mongoose's Schema.Types interface
|
|
333
338
|
(mongoose.Schema.Types as any).DateOnly = DateOnly;
|
package/src/populate.test.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import {beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
-
import mongoose, {Schema} from "mongoose";
|
|
2
|
+
import mongoose, {type Document, type HydratedDocument, Schema} from "mongoose";
|
|
3
3
|
|
|
4
4
|
import {fixMixedFields, getOpenApiSpecForModel, unpopulate} from "./populate";
|
|
5
|
-
import {FoodModel, setupDb, UserModel} from "./tests";
|
|
5
|
+
import {FoodModel, setupDb, type User, UserModel} from "./tests";
|
|
6
6
|
|
|
7
7
|
describe("populate functions", () => {
|
|
8
|
-
let admin:
|
|
9
|
-
let notAdmin:
|
|
8
|
+
let admin: HydratedDocument<User>;
|
|
9
|
+
let notAdmin: HydratedDocument<User>;
|
|
10
10
|
|
|
11
|
+
// noExplicitAny: typing as HydratedDocument<Food> causes cascading errors on populated field access patterns (e.g. populated.ownerId.name)
|
|
11
12
|
let spinach: any;
|
|
12
13
|
|
|
13
14
|
beforeEach(async () => {
|
|
@@ -44,6 +45,7 @@ describe("populate functions", () => {
|
|
|
44
45
|
expect(populated.likesIds[1].userId.id).toBe(notAdmin.id);
|
|
45
46
|
expect(populated.likesIds[1].userId.name).toBe("Not Admin");
|
|
46
47
|
|
|
48
|
+
// noExplicitAny: unpopulate returns Document<T> which doesn't expose model properties; would require refactoring the return type
|
|
47
49
|
let unpopulated: any = unpopulate(populated, "ownerId");
|
|
48
50
|
expect(spinach.ownerId.name).toBeUndefined();
|
|
49
51
|
expect(unpopulated.ownerId.toString()).toBe(admin.id);
|
|
@@ -68,7 +70,7 @@ describe("populate functions", () => {
|
|
|
68
70
|
describe("unpopulate edge cases", () => {
|
|
69
71
|
it("throws error when path is empty", () => {
|
|
70
72
|
const doc = {name: "test"};
|
|
71
|
-
expect(() => unpopulate(doc as
|
|
73
|
+
expect(() => unpopulate(doc as unknown as Document<unknown>, "")).toThrow("path is required");
|
|
72
74
|
});
|
|
73
75
|
|
|
74
76
|
it("unpopulates single populated field", () => {
|
|
@@ -76,7 +78,9 @@ describe("unpopulate edge cases", () => {
|
|
|
76
78
|
name: "test",
|
|
77
79
|
ownerId: {_id: "owner-123", email: "owner@test.com"},
|
|
78
80
|
};
|
|
79
|
-
const result = unpopulate(doc as
|
|
81
|
+
const result = unpopulate(doc as unknown as Document<unknown>, "ownerId") as unknown as {
|
|
82
|
+
ownerId: string;
|
|
83
|
+
};
|
|
80
84
|
expect(result.ownerId).toBe("owner-123");
|
|
81
85
|
});
|
|
82
86
|
|
|
@@ -85,7 +89,9 @@ describe("unpopulate edge cases", () => {
|
|
|
85
89
|
items: [{_id: "item-1", name: "Item 1"}, {_id: "item-2", name: "Item 2"}, "item-3"],
|
|
86
90
|
name: "test",
|
|
87
91
|
};
|
|
88
|
-
const result = unpopulate(doc as
|
|
92
|
+
const result = unpopulate(doc as unknown as Document<unknown>, "items") as unknown as {
|
|
93
|
+
items: string[];
|
|
94
|
+
};
|
|
89
95
|
expect(result.items).toEqual(["item-1", "item-2", "item-3"]);
|
|
90
96
|
});
|
|
91
97
|
|
|
@@ -99,13 +105,17 @@ describe("unpopulate edge cases", () => {
|
|
|
99
105
|
],
|
|
100
106
|
},
|
|
101
107
|
};
|
|
102
|
-
const result = unpopulate(doc as
|
|
108
|
+
const result = unpopulate(doc as unknown as Document<unknown>, "nested.items") as unknown as {
|
|
109
|
+
nested: {items: string[]};
|
|
110
|
+
};
|
|
103
111
|
expect(result.nested.items).toEqual(["item-1", "item-2"]);
|
|
104
112
|
});
|
|
105
113
|
|
|
106
114
|
it("returns original doc when path does not exist", () => {
|
|
107
115
|
const doc = {name: "test"};
|
|
108
|
-
const result = unpopulate(doc as
|
|
116
|
+
const result = unpopulate(doc as unknown as Document<unknown>, "nonexistent") as unknown as {
|
|
117
|
+
name: string;
|
|
118
|
+
};
|
|
109
119
|
expect(result).toEqual(doc);
|
|
110
120
|
});
|
|
111
121
|
|
|
@@ -117,7 +127,10 @@ describe("unpopulate edge cases", () => {
|
|
|
117
127
|
],
|
|
118
128
|
name: "test",
|
|
119
129
|
};
|
|
120
|
-
const result = unpopulate(
|
|
130
|
+
const result = unpopulate(
|
|
131
|
+
doc as unknown as Document<unknown>,
|
|
132
|
+
"containers.items"
|
|
133
|
+
) as unknown as {containers: {items: string[]}[]};
|
|
121
134
|
expect(result.containers[0].items).toEqual(["item-1", "item-2"]);
|
|
122
135
|
expect(result.containers[1].items).toEqual(["item-3", "item-4"]);
|
|
123
136
|
});
|
|
@@ -131,12 +144,14 @@ describe("fixMixedFields", () => {
|
|
|
131
144
|
|
|
132
145
|
it("returns early when properties is missing", () => {
|
|
133
146
|
const schema = new Schema({});
|
|
134
|
-
expect(() => fixMixedFields(schema, null as
|
|
147
|
+
expect(() => fixMixedFields(schema, null as unknown as Record<string, unknown>)).not.toThrow();
|
|
135
148
|
});
|
|
136
149
|
|
|
137
150
|
it("replaces Mixed fields with only description", () => {
|
|
138
151
|
const schema = new Schema({data: {description: "any data", type: Schema.Types.Mixed}});
|
|
139
|
-
const properties:
|
|
152
|
+
const properties: Record<string, Record<string, unknown>> = {
|
|
153
|
+
data: {description: "any data", type: "object"},
|
|
154
|
+
};
|
|
140
155
|
fixMixedFields(schema, properties);
|
|
141
156
|
expect(properties.data).toEqual({description: "any data"});
|
|
142
157
|
});
|
|
@@ -144,14 +159,14 @@ describe("fixMixedFields", () => {
|
|
|
144
159
|
it("recurses into arrays of sub-documents", () => {
|
|
145
160
|
const subSchema = new Schema({meta: {type: Schema.Types.Mixed}});
|
|
146
161
|
const schema = new Schema({items: [subSchema]});
|
|
147
|
-
const properties
|
|
162
|
+
const properties = {
|
|
148
163
|
items: {
|
|
149
164
|
items: {
|
|
150
165
|
properties: {
|
|
151
|
-
meta: {type: "object"},
|
|
166
|
+
meta: {type: "object"} as Record<string, unknown>,
|
|
152
167
|
},
|
|
153
168
|
},
|
|
154
|
-
type: "array",
|
|
169
|
+
type: "array" as const,
|
|
155
170
|
},
|
|
156
171
|
};
|
|
157
172
|
fixMixedFields(schema, properties);
|
|
@@ -211,7 +226,7 @@ describe("getOpenApiSpecForModel edge cases", () => {
|
|
|
211
226
|
populatePaths: [{path: "eatenBy"}],
|
|
212
227
|
});
|
|
213
228
|
expect(result.properties.eatenBy).toBeDefined();
|
|
214
|
-
expect((result.properties.eatenBy as
|
|
229
|
+
expect((result.properties.eatenBy as Record<string, unknown>).items).toBeDefined();
|
|
215
230
|
});
|
|
216
231
|
|
|
217
232
|
it("populates nested ref in sub-schema (likesIds.userId)", () => {
|
|
@@ -224,7 +239,7 @@ describe("getOpenApiSpecForModel edge cases", () => {
|
|
|
224
239
|
it("includes virtuals from model schema", () => {
|
|
225
240
|
const result = getOpenApiSpecForModel(FoodModel);
|
|
226
241
|
expect(result.properties.description).toBeDefined();
|
|
227
|
-
expect((result.properties.description as
|
|
242
|
+
expect((result.properties.description as Record<string, unknown>).type).toBe("any");
|
|
228
243
|
});
|
|
229
244
|
|
|
230
245
|
it("includes virtuals from child schemas", () => {
|
|
@@ -240,7 +255,10 @@ describe("getOpenApiSpecForModel edge cases", () => {
|
|
|
240
255
|
mongoose.models.ParentWithChildVirtual ||
|
|
241
256
|
mongoose.model("ParentWithChildVirtual", parentSchema);
|
|
242
257
|
const result = getOpenApiSpecForModel(ParentModel);
|
|
243
|
-
const detail = result.properties.detail as
|
|
258
|
+
const detail = result.properties.detail as Record<
|
|
259
|
+
string,
|
|
260
|
+
Record<string, Record<string, unknown>>
|
|
261
|
+
>;
|
|
244
262
|
expect(detail.properties.displayAmount).toBeDefined();
|
|
245
263
|
expect(detail.properties.displayAmount.type).toBe("any");
|
|
246
264
|
});
|
|
@@ -251,7 +269,10 @@ describe("filterKeys (via getOpenApiSpecForModel populatePaths)", () => {
|
|
|
251
269
|
const result = getOpenApiSpecForModel(FoodModel, {
|
|
252
270
|
populatePaths: [{fields: ["name.nested"], path: "ownerId"}],
|
|
253
271
|
});
|
|
254
|
-
const ownerProps = (result.properties.ownerId as
|
|
272
|
+
const ownerProps = (result.properties.ownerId as Record<string, unknown>).properties as Record<
|
|
273
|
+
string,
|
|
274
|
+
unknown
|
|
275
|
+
>;
|
|
255
276
|
expect(ownerProps.name).toBeDefined();
|
|
256
277
|
expect(typeof ownerProps.name).toBe("object");
|
|
257
278
|
});
|
|
@@ -261,8 +282,12 @@ describe("filterKeys (via getOpenApiSpecForModel populatePaths)", () => {
|
|
|
261
282
|
populatePaths: [{fields: ["__proto__.polluted"], path: "ownerId"}],
|
|
262
283
|
});
|
|
263
284
|
expect(result.properties).toBeDefined();
|
|
285
|
+
// noExplicitAny: testing that prototype pollution did not add a 'polluted' property to Object.prototype
|
|
264
286
|
expect((Object.prototype as any).polluted).toBeUndefined();
|
|
265
|
-
const ownerProps = (result.properties.ownerId as
|
|
287
|
+
const ownerProps = (result.properties.ownerId as Record<string, unknown>).properties as Record<
|
|
288
|
+
string,
|
|
289
|
+
unknown
|
|
290
|
+
>;
|
|
266
291
|
expect(ownerProps).toBeDefined();
|
|
267
292
|
expect(Object.keys(ownerProps)).not.toContain("__proto__");
|
|
268
293
|
});
|