@terreno/api 0.12.2 → 0.13.1
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/configurationPlugin.js +7 -1
- 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 +9 -1
- package/dist/notifiers/slackNotifier.d.ts +2 -2
- package/dist/notifiers/slackNotifier.js +38 -7
- package/dist/openApiEtag.js +0 -7
- package/dist/plugins.d.ts +3 -3
- package/dist/plugins.js +8 -4
- package/dist/populate.test.js +5 -1
- package/dist/scriptRunner.js +2 -2
- package/dist/secretProviders.js +4 -1
- package/dist/tests.js +1 -0
- package/dist/utils.js +13 -3
- package/package.json +1 -1
- package/src/configurationPlugin.ts +7 -1
- package/src/consentApp.ts +3 -3
- package/src/githubAuth.test.ts +327 -0
- package/src/models/consentForm.ts +10 -1
- package/src/notifiers/slackNotifier.ts +7 -6
- package/src/openApiEtag.ts +0 -7
- package/src/plugins.ts +13 -8
- package/src/populate.test.ts +45 -20
- package/src/scriptRunner.ts +2 -2
- package/src/secretProviders.ts +4 -3
- package/src/tests.ts +18 -14
- package/src/utils.ts +13 -3
package/dist/populate.test.js
CHANGED
|
@@ -92,6 +92,7 @@ var tests_1 = require("./tests");
|
|
|
92
92
|
(0, bun_test_1.describe)("populate functions", function () {
|
|
93
93
|
var admin;
|
|
94
94
|
var notAdmin;
|
|
95
|
+
// noExplicitAny: typing as HydratedDocument<Food> causes cascading errors on populated field access patterns (e.g. populated.ownerId.name)
|
|
95
96
|
var spinach;
|
|
96
97
|
(0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
97
98
|
var _a, _b;
|
|
@@ -228,7 +229,9 @@ var tests_1 = require("./tests");
|
|
|
228
229
|
});
|
|
229
230
|
(0, bun_test_1.it)("replaces Mixed fields with only description", function () {
|
|
230
231
|
var schema = new mongoose_1.Schema({ data: { description: "any data", type: mongoose_1.Schema.Types.Mixed } });
|
|
231
|
-
var properties = {
|
|
232
|
+
var properties = {
|
|
233
|
+
data: { description: "any data", type: "object" },
|
|
234
|
+
};
|
|
232
235
|
(0, populate_1.fixMixedFields)(schema, properties);
|
|
233
236
|
(0, bun_test_1.expect)(properties.data).toEqual({ description: "any data" });
|
|
234
237
|
});
|
|
@@ -338,6 +341,7 @@ var tests_1 = require("./tests");
|
|
|
338
341
|
populatePaths: [{ fields: ["__proto__.polluted"], path: "ownerId" }],
|
|
339
342
|
});
|
|
340
343
|
(0, bun_test_1.expect)(result.properties).toBeDefined();
|
|
344
|
+
// noExplicitAny: testing that prototype pollution did not add a 'polluted' property to Object.prototype
|
|
341
345
|
(0, bun_test_1.expect)(Object.prototype.polluted).toBeUndefined();
|
|
342
346
|
var ownerProps = result.properties.ownerId.properties;
|
|
343
347
|
(0, bun_test_1.expect)(ownerProps).toBeDefined();
|
package/dist/scriptRunner.js
CHANGED
|
@@ -102,7 +102,7 @@ var progressSchema = new mongoose_1.Schema({
|
|
|
102
102
|
message: { description: "Human-readable progress message", type: String },
|
|
103
103
|
percentage: { description: "Progress percentage from 0 to 100", max: 100, min: 0, type: Number },
|
|
104
104
|
stage: { description: "Current stage of the task", type: String },
|
|
105
|
-
}, { _id: false });
|
|
105
|
+
}, { _id: false, strict: "throw" });
|
|
106
106
|
var logSchema = new mongoose_1.Schema({
|
|
107
107
|
level: {
|
|
108
108
|
description: "Log level",
|
|
@@ -112,7 +112,7 @@ var logSchema = new mongoose_1.Schema({
|
|
|
112
112
|
},
|
|
113
113
|
message: { description: "Log message", required: true, type: String },
|
|
114
114
|
timestamp: { description: "When this log entry was created", required: true, type: Date },
|
|
115
|
-
}, { _id: false });
|
|
115
|
+
}, { _id: false, strict: "throw" });
|
|
116
116
|
var backgroundTaskSchema = new mongoose_1.Schema({
|
|
117
117
|
completedAt: {
|
|
118
118
|
description: "When the task completed (success or failure)",
|
package/dist/secretProviders.js
CHANGED
|
@@ -168,7 +168,10 @@ var GcpSecretProvider = /** @class */ (function () {
|
|
|
168
168
|
case 4:
|
|
169
169
|
SecretManagerServiceClient = (_b = mod.SecretManagerServiceClient) !== null && _b !== void 0 ? _b : (_c = mod.default) === null || _c === void 0 ? void 0 : _c.SecretManagerServiceClient;
|
|
170
170
|
if (!SecretManagerServiceClient) {
|
|
171
|
-
throw new
|
|
171
|
+
throw new errors_1.APIError({
|
|
172
|
+
status: 500,
|
|
173
|
+
title: "SecretManagerServiceClient not found in @google-cloud/secret-manager module",
|
|
174
|
+
});
|
|
172
175
|
}
|
|
173
176
|
this.client = new SecretManagerServiceClient();
|
|
174
177
|
_d.label = 5;
|
package/dist/tests.js
CHANGED
|
@@ -156,6 +156,7 @@ var foodSchema = new mongoose_1.Schema({
|
|
|
156
156
|
type: mongoose_1.Schema.Types.ObjectId,
|
|
157
157
|
},
|
|
158
158
|
],
|
|
159
|
+
// noExplicitAny: DateOnly is a custom SchemaType not recognized by Mongoose's built-in type definitions
|
|
159
160
|
expiration: { description: "Expiration date of the food", type: plugins_1.DateOnly },
|
|
160
161
|
hidden: {
|
|
161
162
|
default: false,
|
package/dist/utils.js
CHANGED
|
@@ -82,6 +82,7 @@ var __values = (this && this.__values) || function(o) {
|
|
|
82
82
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
83
83
|
exports.checkModelsStrict = exports.timeout = exports.isValidObjectId = void 0;
|
|
84
84
|
var mongoose_1 = __importStar(require("mongoose"));
|
|
85
|
+
var errors_1 = require("./errors");
|
|
85
86
|
var logger_1 = require("./logger");
|
|
86
87
|
// A better version of mongoose's ObjectId.isValid,
|
|
87
88
|
// which falsely will say any 12 character string is valid.
|
|
@@ -119,16 +120,25 @@ var checkModelsStrict = function (ignoredModels) {
|
|
|
119
120
|
var model = models_1_1.value;
|
|
120
121
|
var schema = mongoose_1.default.model(model).schema;
|
|
121
122
|
if (((_b = schema.get("toObject")) === null || _b === void 0 ? void 0 : _b.virtuals) !== true) {
|
|
122
|
-
throw new
|
|
123
|
+
throw new errors_1.APIError({
|
|
124
|
+
status: 500,
|
|
125
|
+
title: "Model ".concat(model, " toObject.virtuals not set to true"),
|
|
126
|
+
});
|
|
123
127
|
}
|
|
124
128
|
if (((_c = schema.get("toJSON")) === null || _c === void 0 ? void 0 : _c.virtuals) !== true) {
|
|
125
|
-
throw new
|
|
129
|
+
throw new errors_1.APIError({
|
|
130
|
+
status: 500,
|
|
131
|
+
title: "Model ".concat(model, " toJSON.virtuals not set to true"),
|
|
132
|
+
});
|
|
126
133
|
}
|
|
127
134
|
if (ignoredModels.includes(model)) {
|
|
128
135
|
continue;
|
|
129
136
|
}
|
|
130
137
|
if (schema.get("strict") !== "throw") {
|
|
131
|
-
throw new
|
|
138
|
+
throw new errors_1.APIError({
|
|
139
|
+
status: 500,
|
|
140
|
+
title: "Model ".concat(model, " is not set to strict mode."),
|
|
141
|
+
});
|
|
132
142
|
}
|
|
133
143
|
}
|
|
134
144
|
}
|
package/package.json
CHANGED
|
@@ -139,7 +139,13 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
|
|
|
139
139
|
// Add a sentinel field with a unique index to enforce singleton at the DB level.
|
|
140
140
|
// All config documents get _singleton: "config", and the unique index prevents duplicates.
|
|
141
141
|
schema.add({
|
|
142
|
-
_singleton: {
|
|
142
|
+
_singleton: {
|
|
143
|
+
default: "config",
|
|
144
|
+
description: "Sentinel field enforcing singleton constraint",
|
|
145
|
+
immutable: true,
|
|
146
|
+
select: false,
|
|
147
|
+
type: String,
|
|
148
|
+
},
|
|
143
149
|
});
|
|
144
150
|
schema.index({_singleton: 1}, {unique: true});
|
|
145
151
|
|
package/src/consentApp.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* endpoints for fetching pending consents and submitting responses.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type
|
|
9
|
+
import {type Application, Router} from "express";
|
|
10
10
|
import {DateTime} from "luxon";
|
|
11
11
|
import {asyncHandler, modelRouter} from "./api";
|
|
12
12
|
import type {User} from "./auth";
|
|
@@ -47,7 +47,7 @@ export class ConsentApp implements TerrenoPlugin {
|
|
|
47
47
|
this.options = options;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
register(app:
|
|
50
|
+
register(app: Application): void {
|
|
51
51
|
const {auditTrail, resolveConsentForms, aiConfig} = this.options;
|
|
52
52
|
|
|
53
53
|
// Admin CRUD for consent forms
|
|
@@ -192,7 +192,7 @@ export class ConsentApp implements TerrenoPlugin {
|
|
|
192
192
|
);
|
|
193
193
|
|
|
194
194
|
// User-facing consent endpoints
|
|
195
|
-
const router =
|
|
195
|
+
const router = Router();
|
|
196
196
|
|
|
197
197
|
// GET /consents/pending - fetch pending consent forms for the current user
|
|
198
198
|
router.get(
|
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,6 +2,15 @@ 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 consentFormTypeValues = [
|
|
6
|
+
"agreement",
|
|
7
|
+
"privacy",
|
|
8
|
+
"hipaa",
|
|
9
|
+
"research",
|
|
10
|
+
"terms",
|
|
11
|
+
"custom",
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
5
14
|
const consentFormSchema = new mongoose.Schema<ConsentFormDocument, ConsentFormModel>(
|
|
6
15
|
{
|
|
7
16
|
active: {
|
|
@@ -95,7 +104,7 @@ const consentFormSchema = new mongoose.Schema<ConsentFormDocument, ConsentFormMo
|
|
|
95
104
|
},
|
|
96
105
|
type: {
|
|
97
106
|
description: "Category of consent form",
|
|
98
|
-
enum:
|
|
107
|
+
enum: consentFormTypeValues,
|
|
99
108
|
required: true,
|
|
100
109
|
type: String,
|
|
101
110
|
},
|
|
@@ -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
|
@@ -7,32 +7,25 @@ import type {NextFunction, Request, Response} from "express";
|
|
|
7
7
|
* to intercept requests to /openapi.json and add conditional request support.
|
|
8
8
|
*/
|
|
9
9
|
export const openApiEtagMiddleware = (req: Request, res: Response, next: NextFunction): void => {
|
|
10
|
-
// Only handle GET requests to /openapi.json
|
|
11
10
|
if (req.method !== "GET" || req.path !== "/openapi.json") {
|
|
12
11
|
next();
|
|
13
12
|
return;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
// Store original res.json to intercept the response
|
|
17
15
|
const originalJson = res.json.bind(res);
|
|
18
16
|
|
|
19
17
|
res.json = (body: any) => {
|
|
20
|
-
// Generate ETag based on the JSON content
|
|
21
18
|
const jsonString = JSON.stringify(body);
|
|
22
19
|
const etag = `"${crypto.createHash("sha256").update(jsonString).digest("hex").substring(0, 16)}"`;
|
|
23
20
|
|
|
24
|
-
// Set ETag header
|
|
25
21
|
res.set("ETag", etag);
|
|
26
22
|
|
|
27
|
-
// Check If-None-Match header for conditional requests
|
|
28
23
|
const ifNoneMatch = req.get("If-None-Match");
|
|
29
24
|
if (ifNoneMatch === etag) {
|
|
30
|
-
// Resource hasn't changed, return 304 Not Modified
|
|
31
25
|
res.status(304).end();
|
|
32
26
|
return res;
|
|
33
27
|
}
|
|
34
28
|
|
|
35
|
-
// Resource has changed or no conditional header, return the content
|
|
36
29
|
return originalJson(body);
|
|
37
30
|
};
|
|
38
31
|
|
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;
|