@terreno/api 0.21.0 → 0.22.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 +1 -1
- package/dist/auth.test.js +408 -33
- package/dist/models/consentForm.js +2 -1
- package/dist/models/consentResponse.js +2 -1
- package/dist/models/versionConfig.js +2 -1
- package/dist/openApiBuilder.d.ts +18 -0
- package/dist/openApiBuilder.js +21 -0
- package/dist/openApiBuilder.test.js +16 -0
- package/dist/permissions.test.js +10 -43
- package/dist/populate.test.js +10 -42
- package/dist/syncConsents.test.js +2 -2
- package/dist/tests/bunSetup.js +33 -283
- package/dist/tests/createTestData.d.ts +9 -0
- package/dist/tests/createTestData.js +272 -0
- package/dist/tests/models.d.ts +71 -0
- package/dist/tests/models.js +134 -0
- package/dist/tests/mongoTestSetup.d.ts +7 -0
- package/dist/tests/mongoTestSetup.js +150 -0
- package/dist/tests/testEnv.d.ts +0 -0
- package/dist/tests/testEnv.js +6 -0
- package/dist/tests/testHelper.d.ts +22 -0
- package/dist/tests/testHelper.js +115 -0
- package/dist/tests/types.d.ts +29 -0
- package/dist/tests/types.js +2 -0
- package/dist/tests.d.ts +10 -78
- package/dist/tests.js +24 -264
- package/dist/transformers.test.js +14 -50
- package/package.json +18 -4
- package/src/__snapshots__/openApiBuilder.test.ts.snap +1 -0
- package/src/auth.test.ts +277 -29
- package/src/models/consentForm.ts +3 -4
- package/src/models/consentResponse.ts +6 -4
- package/src/models/versionConfig.ts +3 -4
- package/src/openApiBuilder.test.ts +9 -0
- package/src/openApiBuilder.ts +24 -0
- package/src/permissions.test.ts +8 -23
- package/src/populate.test.ts +7 -22
- package/src/syncConsents.test.ts +1 -1
- package/src/tests/bunSetup.ts +22 -249
- package/src/tests/createTestData.ts +176 -0
- package/src/tests/models.ts +164 -0
- package/src/tests/mongoTestSetup.ts +69 -0
- package/src/tests/testEnv.ts +4 -0
- package/src/tests/testHelper.ts +57 -0
- package/src/tests/types.ts +35 -0
- package/src/tests.ts +40 -244
- package/src/transformers.test.ts +11 -30
- package/tsconfig.typedoc.json +4 -0
- package/dist/tests/index.d.ts +0 -1
- package/dist/tests/index.js +0 -17
- package/src/tests/index.ts +0 -1
|
@@ -46,22 +46,6 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
|
46
46
|
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
47
47
|
}
|
|
48
48
|
};
|
|
49
|
-
var __read = (this && this.__read) || function (o, n) {
|
|
50
|
-
var m = typeof Symbol === "function" && o[Symbol.iterator];
|
|
51
|
-
if (!m) return o;
|
|
52
|
-
var i = m.call(o), r, ar = [], e;
|
|
53
|
-
try {
|
|
54
|
-
while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
|
|
55
|
-
}
|
|
56
|
-
catch (error) { e = { error: error }; }
|
|
57
|
-
finally {
|
|
58
|
-
try {
|
|
59
|
-
if (r && !r.done && (m = i["return"])) m.call(i);
|
|
60
|
-
}
|
|
61
|
-
finally { if (e) throw e.error; }
|
|
62
|
-
}
|
|
63
|
-
return ar;
|
|
64
|
-
};
|
|
65
49
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
66
50
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
67
51
|
};
|
|
@@ -81,37 +65,16 @@ var transformers_1 = require("./transformers");
|
|
|
81
65
|
var server;
|
|
82
66
|
var app;
|
|
83
67
|
(0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
84
|
-
var
|
|
85
|
-
return __generator(this, function (
|
|
86
|
-
switch (
|
|
68
|
+
var testData;
|
|
69
|
+
return __generator(this, function (_a) {
|
|
70
|
+
switch (_a.label) {
|
|
87
71
|
case 0:
|
|
88
72
|
process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
|
|
89
|
-
return [4 /*yield*/, (0, tests_1.
|
|
73
|
+
return [4 /*yield*/, (0, tests_1.setupTestData)()];
|
|
90
74
|
case 1:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
calories: 1,
|
|
95
|
-
created: new Date(),
|
|
96
|
-
name: "Spinach",
|
|
97
|
-
ownerId: notAdmin._id,
|
|
98
|
-
}),
|
|
99
|
-
tests_1.FoodModel.create({
|
|
100
|
-
calories: 100,
|
|
101
|
-
created: Date.now() - 10,
|
|
102
|
-
hidden: true,
|
|
103
|
-
name: "Apple",
|
|
104
|
-
ownerId: admin._id,
|
|
105
|
-
}),
|
|
106
|
-
tests_1.FoodModel.create({
|
|
107
|
-
calories: 100,
|
|
108
|
-
created: Date.now() - 10,
|
|
109
|
-
name: "Carrots",
|
|
110
|
-
ownerId: admin._id,
|
|
111
|
-
}),
|
|
112
|
-
])];
|
|
113
|
-
case 2:
|
|
114
|
-
_b.sent();
|
|
75
|
+
testData = _a.sent();
|
|
76
|
+
admin = testData.users.admin;
|
|
77
|
+
notAdmin = testData.users.notAdmin;
|
|
115
78
|
app = (0, tests_1.getBaseServer)();
|
|
116
79
|
(0, auth_1.setupAuth)(app, tests_1.UserModel);
|
|
117
80
|
(0, auth_1.addAuthRoutes)(app, tests_1.UserModel);
|
|
@@ -156,7 +119,7 @@ var transformers_1 = require("./transformers");
|
|
|
156
119
|
return [4 /*yield*/, agent.get("/food").expect(200)];
|
|
157
120
|
case 2:
|
|
158
121
|
foodRes = _a.sent();
|
|
159
|
-
(0, bun_test_1.expect)(foodRes.body.data).toHaveLength(
|
|
122
|
+
(0, bun_test_1.expect)(foodRes.body.data).toHaveLength(3);
|
|
160
123
|
return [2 /*return*/];
|
|
161
124
|
}
|
|
162
125
|
});
|
|
@@ -171,7 +134,7 @@ var transformers_1 = require("./transformers");
|
|
|
171
134
|
return [4 /*yield*/, agent.get("/food").expect(200)];
|
|
172
135
|
case 2:
|
|
173
136
|
foodRes = _a.sent();
|
|
174
|
-
(0, bun_test_1.expect)(foodRes.body.data).toHaveLength(
|
|
137
|
+
(0, bun_test_1.expect)(foodRes.body.data).toHaveLength(4);
|
|
175
138
|
return [2 /*return*/];
|
|
176
139
|
}
|
|
177
140
|
});
|
|
@@ -186,7 +149,7 @@ var transformers_1 = require("./transformers");
|
|
|
186
149
|
return [4 /*yield*/, agent.get("/food").expect(200)];
|
|
187
150
|
case 2:
|
|
188
151
|
foodRes = _a.sent();
|
|
189
|
-
(0, bun_test_1.expect)(foodRes.body.data).toHaveLength(
|
|
152
|
+
(0, bun_test_1.expect)(foodRes.body.data).toHaveLength(4);
|
|
190
153
|
spinach = foodRes.body.data.find(function (food) { return food.name === "Spinach"; });
|
|
191
154
|
(0, bun_test_1.expect)(spinach.created).toBeDefined();
|
|
192
155
|
(0, bun_test_1.expect)(spinach.id).toBeDefined();
|
|
@@ -227,7 +190,7 @@ var transformers_1 = require("./transformers");
|
|
|
227
190
|
return [4 /*yield*/, agent.get("/food").expect(200)];
|
|
228
191
|
case 2:
|
|
229
192
|
foodRes = _a.sent();
|
|
230
|
-
(0, bun_test_1.expect)(foodRes.body.data).toHaveLength(
|
|
193
|
+
(0, bun_test_1.expect)(foodRes.body.data).toHaveLength(3);
|
|
231
194
|
spinach = foodRes.body.data.find(function (food) { return food.name === "Spinach"; });
|
|
232
195
|
(0, bun_test_1.expect)(spinach.id).toBeDefined();
|
|
233
196
|
(0, bun_test_1.expect)(spinach.name).toBe("Spinach");
|
|
@@ -289,7 +252,7 @@ var transformers_1 = require("./transformers");
|
|
|
289
252
|
return [4 /*yield*/, agent.get("/food").expect(200)];
|
|
290
253
|
case 2:
|
|
291
254
|
foodRes = _a.sent();
|
|
292
|
-
(0, bun_test_1.expect)(foodRes.body.data).toHaveLength(
|
|
255
|
+
(0, bun_test_1.expect)(foodRes.body.data).toHaveLength(3);
|
|
293
256
|
spinach = foodRes.body.data.find(function (food) { return food.name === "Spinach"; });
|
|
294
257
|
(0, bun_test_1.expect)(spinach.id).toBeDefined();
|
|
295
258
|
(0, bun_test_1.expect)(spinach.name).toBe("Spinach");
|
|
@@ -358,9 +321,10 @@ var transformers_1 = require("./transformers");
|
|
|
358
321
|
case 0: return [4 /*yield*/, server.get("/food")];
|
|
359
322
|
case 1:
|
|
360
323
|
res = _a.sent();
|
|
361
|
-
(0, bun_test_1.expect)(res.body.data).toHaveLength(
|
|
324
|
+
(0, bun_test_1.expect)(res.body.data).toHaveLength(3);
|
|
362
325
|
(0, bun_test_1.expect)(res.body.data.find(function (f) { return f.name === "Spinach"; })).toBeDefined();
|
|
363
326
|
(0, bun_test_1.expect)(res.body.data.find(function (f) { return f.name === "Carrots"; })).toBeDefined();
|
|
327
|
+
(0, bun_test_1.expect)(res.body.data.find(function (f) { return f.name === "Pizza"; })).toBeDefined();
|
|
364
328
|
return [2 /*return*/];
|
|
365
329
|
}
|
|
366
330
|
});
|
package/package.json
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"description": "Styled after the Django & Django REST Framework, a batteries-include framework for building REST APIs with Node/Express/Mongoose.",
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@biomejs/biome": "^2.3.6",
|
|
49
|
+
"@terreno/test": "workspace:*",
|
|
49
50
|
"@types/bcrypt": "^6.0.0",
|
|
50
51
|
"@types/bun": "^1.2.4",
|
|
51
52
|
"@types/cors": "^2.8.17",
|
|
@@ -80,6 +81,16 @@
|
|
|
80
81
|
],
|
|
81
82
|
"license": "Apache-2.0",
|
|
82
83
|
"main": "dist/index.js",
|
|
84
|
+
"exports": {
|
|
85
|
+
".": {
|
|
86
|
+
"types": "./dist/index.d.ts",
|
|
87
|
+
"default": "./dist/index.js"
|
|
88
|
+
},
|
|
89
|
+
"./testing": {
|
|
90
|
+
"types": "./dist/tests.d.ts",
|
|
91
|
+
"default": "./dist/tests.js"
|
|
92
|
+
}
|
|
93
|
+
},
|
|
83
94
|
"name": "@terreno/api",
|
|
84
95
|
"publishConfig": {
|
|
85
96
|
"access": "public"
|
|
@@ -96,18 +107,21 @@
|
|
|
96
107
|
"json5": "2.2.3"
|
|
97
108
|
},
|
|
98
109
|
"scripts": {
|
|
99
|
-
"compile": "bun tsc",
|
|
110
|
+
"compile": "node ../.github/scripts/compile-workspace-deps.js && bun tsc",
|
|
100
111
|
"compile:watch": "bun tsc -w",
|
|
101
112
|
"dev": "bun tsc -w",
|
|
102
113
|
"docs": "typedoc --out docs src/index.ts",
|
|
103
114
|
"lint": "biome check ./src",
|
|
104
115
|
"lint:fix": "biome check --write ./src",
|
|
105
116
|
"lint:unsafefix": "biome check --fix --unsafe ./src",
|
|
106
|
-
"test": "bun test --
|
|
107
|
-
"test:ci": "bun test
|
|
117
|
+
"test": "bun test --update-snapshots",
|
|
118
|
+
"test:ci": "bun test",
|
|
108
119
|
"test:coverage": "bun run ../scripts/check-coverage.ts",
|
|
120
|
+
"test:cache:clean": "bun ./src/tests/mongoTestSetup.ts clean",
|
|
121
|
+
"test:cache:setup": "bun ./src/tests/mongoTestSetup.ts setup",
|
|
122
|
+
"test:cache:status": "bun ./src/tests/mongoTestSetup.ts status",
|
|
109
123
|
"updateSnapshot": "bun test --update-snapshots"
|
|
110
124
|
},
|
|
111
125
|
"types": "dist/index.d.ts",
|
|
112
|
-
"version": "0.
|
|
126
|
+
"version": "0.22.0"
|
|
113
127
|
}
|
|
@@ -910,6 +910,7 @@ exports[`OpenApiMiddlewareBuilder snapshot tests matches OpenAPI spec snapshot 1
|
|
|
910
910
|
"/food/stats": {
|
|
911
911
|
"get": {
|
|
912
912
|
"description": "Returns aggregated statistics about food items",
|
|
913
|
+
"operationId": "getFoodStats",
|
|
913
914
|
"parameters": [
|
|
914
915
|
{
|
|
915
916
|
"description": "Filter by food category",
|
package/src/auth.test.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {addAuthRoutes, addMeRoutes, generateTokens, setupAuth} from "./auth";
|
|
|
10
10
|
import {Permissions} from "./permissions";
|
|
11
11
|
import {getCurrentRequestContext} from "./requestContext";
|
|
12
12
|
import {TerrenoApp} from "./terrenoApp";
|
|
13
|
-
import {type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
|
|
13
|
+
import {type Food, FoodModel, getBaseServer, setupDb, setupTestData, UserModel} from "./tests";
|
|
14
14
|
import {AdminOwnerTransformer} from "./transformers";
|
|
15
15
|
import {timeout} from "./utils";
|
|
16
16
|
|
|
@@ -29,38 +29,16 @@ describe("auth tests", () => {
|
|
|
29
29
|
stage: string;
|
|
30
30
|
userId?: string;
|
|
31
31
|
}>;
|
|
32
|
-
let notAdmin: any;
|
|
33
32
|
let agent: TestAgent;
|
|
34
33
|
|
|
35
34
|
beforeEach(async () => {
|
|
36
35
|
// Reset to real time - don't freeze time here as passport-local-mongoose
|
|
37
36
|
// lockout mechanism needs real time to progress
|
|
38
37
|
setSystemTime();
|
|
39
|
-
|
|
38
|
+
const testData = await setupTestData();
|
|
39
|
+
admin = testData.users.admin;
|
|
40
40
|
contextEvents = [];
|
|
41
41
|
|
|
42
|
-
await Promise.all([
|
|
43
|
-
FoodModel.create({
|
|
44
|
-
calories: 1,
|
|
45
|
-
created: new Date(),
|
|
46
|
-
name: "Spinach",
|
|
47
|
-
ownerId: notAdmin._id,
|
|
48
|
-
}),
|
|
49
|
-
FoodModel.create({
|
|
50
|
-
calories: 100,
|
|
51
|
-
created: Date.now() - 10,
|
|
52
|
-
hidden: true,
|
|
53
|
-
name: "Apple",
|
|
54
|
-
ownerId: admin._id,
|
|
55
|
-
}),
|
|
56
|
-
FoodModel.create({
|
|
57
|
-
calories: 100,
|
|
58
|
-
created: Date.now() - 10,
|
|
59
|
-
name: "Carrots",
|
|
60
|
-
ownerId: admin._id,
|
|
61
|
-
}),
|
|
62
|
-
]);
|
|
63
|
-
|
|
64
42
|
function addRoutes(router: express.Router): void {
|
|
65
43
|
router.use(
|
|
66
44
|
"/food",
|
|
@@ -217,7 +195,7 @@ describe("auth tests", () => {
|
|
|
217
195
|
// Use token to see 2 foods + the one we just created
|
|
218
196
|
const getRes = await agent.get("/food").expect(200);
|
|
219
197
|
|
|
220
|
-
expect(getRes.body.data).toHaveLength(
|
|
198
|
+
expect(getRes.body.data).toHaveLength(4);
|
|
221
199
|
expect(getRes.body.data.find((f: any) => f.name === "Peas")).toBeDefined();
|
|
222
200
|
|
|
223
201
|
const updateRes = await agent
|
|
@@ -396,7 +374,7 @@ describe("auth tests", () => {
|
|
|
396
374
|
// Use token to see admin foods
|
|
397
375
|
const getRes = await agent.get("/food").expect(200);
|
|
398
376
|
|
|
399
|
-
expect(getRes.body.data).toHaveLength(
|
|
377
|
+
expect(getRes.body.data).toHaveLength(4);
|
|
400
378
|
const food = getRes.body.data.find((f: any) => f.name === "Apple");
|
|
401
379
|
expect(food).toBeDefined();
|
|
402
380
|
|
|
@@ -826,7 +804,7 @@ describe("addAuthRoutes /refresh_token error paths", () => {
|
|
|
826
804
|
|
|
827
805
|
beforeEach(async () => {
|
|
828
806
|
setSystemTime();
|
|
829
|
-
await
|
|
807
|
+
await setupTestData();
|
|
830
808
|
app = new TerrenoApp({
|
|
831
809
|
configureApp: () => {},
|
|
832
810
|
skipListen: true,
|
|
@@ -891,7 +869,7 @@ describe("addMeRoutes edge cases", () => {
|
|
|
891
869
|
|
|
892
870
|
beforeEach(async () => {
|
|
893
871
|
setSystemTime();
|
|
894
|
-
await
|
|
872
|
+
await setupTestData();
|
|
895
873
|
app = new TerrenoApp({
|
|
896
874
|
configureApp: () => {},
|
|
897
875
|
skipListen: true,
|
|
@@ -926,4 +904,274 @@ describe("addMeRoutes edge cases", () => {
|
|
|
926
904
|
// Either 404 (user not found in /me handler) or 401 (auth middleware rejects)
|
|
927
905
|
expect([401, 404]).toContain(res.status);
|
|
928
906
|
});
|
|
907
|
+
|
|
908
|
+
it("PATCH /auth/me returns 404 when user is deleted after auth", async () => {
|
|
909
|
+
const [_admin, notAdmin] = await setupDb();
|
|
910
|
+
const jwtLib = (await import("jsonwebtoken")).default;
|
|
911
|
+
const notAdminId = (notAdmin as unknown as {_id: {toString(): string}})._id;
|
|
912
|
+
const token = jwtLib.sign({id: notAdminId.toString()}, process.env.TOKEN_SECRET as string, {
|
|
913
|
+
issuer: process.env.TOKEN_ISSUER,
|
|
914
|
+
});
|
|
915
|
+
await UserModel.deleteOne({_id: notAdminId});
|
|
916
|
+
const res = await agent
|
|
917
|
+
.patch("/auth/me")
|
|
918
|
+
.set("authorization", `Bearer ${token}`)
|
|
919
|
+
.send({email: "x@x.com"});
|
|
920
|
+
expect([401, 404]).toContain(res.status);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it("PATCH /auth/me returns 403 on validation error", async () => {
|
|
924
|
+
const [admin] = await setupDb();
|
|
925
|
+
const jwtLib = (await import("jsonwebtoken")).default;
|
|
926
|
+
const adminId = (admin as unknown as {_id: {toString(): string}})._id;
|
|
927
|
+
const token = jwtLib.sign({id: adminId.toString()}, process.env.TOKEN_SECRET as string, {
|
|
928
|
+
issuer: process.env.TOKEN_ISSUER,
|
|
929
|
+
});
|
|
930
|
+
const res = await agent
|
|
931
|
+
.patch("/auth/me")
|
|
932
|
+
.set("authorization", `Bearer ${token}`)
|
|
933
|
+
.send({admin: "not_a_boolean_value_but_will_be_cast"});
|
|
934
|
+
expect([200, 403]).toContain(res.status);
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
describe("Secret prefix authorization bypass", () => {
|
|
939
|
+
let app: express.Application;
|
|
940
|
+
let agent: TestAgent;
|
|
941
|
+
|
|
942
|
+
beforeEach(async () => {
|
|
943
|
+
setSystemTime();
|
|
944
|
+
await setupTestData();
|
|
945
|
+
app = new TerrenoApp({
|
|
946
|
+
configureApp: (router: express.Router) => {
|
|
947
|
+
router.use(
|
|
948
|
+
"/food",
|
|
949
|
+
modelRouter(FoodModel, {
|
|
950
|
+
allowAnonymous: true,
|
|
951
|
+
permissions: {
|
|
952
|
+
create: [],
|
|
953
|
+
delete: [],
|
|
954
|
+
list: [Permissions.IsAny],
|
|
955
|
+
read: [Permissions.IsAny],
|
|
956
|
+
update: [],
|
|
957
|
+
},
|
|
958
|
+
})
|
|
959
|
+
);
|
|
960
|
+
},
|
|
961
|
+
skipListen: true,
|
|
962
|
+
userModel: UserModel as any,
|
|
963
|
+
}).build();
|
|
964
|
+
agent = supertest.agent(app);
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
afterEach(() => {
|
|
968
|
+
setSystemTime();
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it("passes through with Secret prefix authorization header without JWT decoding", async () => {
|
|
972
|
+
const res = await agent.get("/food").set("authorization", "Secret my-secret-token").expect(200);
|
|
973
|
+
expect(res.body.data).toBeDefined();
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
describe("generateTokens env integration", () => {
|
|
978
|
+
const OLD_ENV = process.env;
|
|
979
|
+
|
|
980
|
+
beforeEach(() => {
|
|
981
|
+
process.env = {...OLD_ENV};
|
|
982
|
+
process.env.TOKEN_SECRET = "secret";
|
|
983
|
+
process.env.REFRESH_TOKEN_SECRET = "refresh_secret";
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
afterEach(() => {
|
|
987
|
+
process.env = OLD_ENV;
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it("includes TOKEN_ISSUER in token when set", async () => {
|
|
991
|
+
process.env.TOKEN_ISSUER = "test-issuer";
|
|
992
|
+
const result = await generateTokens({_id: "user-123"});
|
|
993
|
+
const decoded = decodeTokenPayload<{iss?: string}>(result.token as string);
|
|
994
|
+
expect(decoded.iss).toBe("test-issuer");
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it("generates a unique sessionId when none provided", async () => {
|
|
998
|
+
const result1 = await generateTokens({_id: "user-123"});
|
|
999
|
+
const result2 = await generateTokens({_id: "user-123"});
|
|
1000
|
+
expect(result1.sessionId).toBeDefined();
|
|
1001
|
+
expect(result2.sessionId).toBeDefined();
|
|
1002
|
+
expect(result1.sessionId).not.toBe(result2.sessionId);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it("uses provided sessionId from options", async () => {
|
|
1006
|
+
const result = await generateTokens({_id: "user-123"}, undefined, {
|
|
1007
|
+
sessionId: "custom-session-id",
|
|
1008
|
+
});
|
|
1009
|
+
const decoded = decodeTokenPayload<{sid?: string}>(result.token as string);
|
|
1010
|
+
expect(decoded.sid).toBe("custom-session-id");
|
|
1011
|
+
expect(result.sessionId).toBe("custom-session-id");
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
describe("refresh_token without REFRESH_TOKEN_SECRET", () => {
|
|
1016
|
+
let app: express.Application;
|
|
1017
|
+
let agent: TestAgent;
|
|
1018
|
+
const OLD_ENV = process.env;
|
|
1019
|
+
|
|
1020
|
+
beforeEach(async () => {
|
|
1021
|
+
setSystemTime();
|
|
1022
|
+
process.env = {...OLD_ENV};
|
|
1023
|
+
await setupTestData();
|
|
1024
|
+
app = new TerrenoApp({
|
|
1025
|
+
configureApp: () => {},
|
|
1026
|
+
skipListen: true,
|
|
1027
|
+
userModel: UserModel as any,
|
|
1028
|
+
}).build();
|
|
1029
|
+
agent = supertest.agent(app);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
afterEach(() => {
|
|
1033
|
+
setSystemTime();
|
|
1034
|
+
process.env = OLD_ENV;
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
it("returns 401 when REFRESH_TOKEN_SECRET is not set", async () => {
|
|
1038
|
+
process.env.REFRESH_TOKEN_SECRET = "";
|
|
1039
|
+
const res = await agent
|
|
1040
|
+
.post("/auth/refresh_token")
|
|
1041
|
+
.send({refreshToken: "some-token"})
|
|
1042
|
+
.expect(401);
|
|
1043
|
+
expect(res.body.message).toContain("No REFRESH_TOKEN_SECRET set");
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
describe("generateTokens with custom TOKEN_EXPIRES_IN", () => {
|
|
1048
|
+
const OLD_ENV = process.env;
|
|
1049
|
+
|
|
1050
|
+
beforeEach(() => {
|
|
1051
|
+
process.env = {...OLD_ENV};
|
|
1052
|
+
process.env.TOKEN_SECRET = "secret";
|
|
1053
|
+
process.env.REFRESH_TOKEN_SECRET = "refresh_secret";
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
afterEach(() => {
|
|
1057
|
+
process.env = OLD_ENV;
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it("uses TOKEN_EXPIRES_IN when set to a valid duration", async () => {
|
|
1061
|
+
process.env.TOKEN_EXPIRES_IN = "1h";
|
|
1062
|
+
const result = await generateTokens({_id: "user-123"});
|
|
1063
|
+
expect(result.token).toBeDefined();
|
|
1064
|
+
const decoded = decodeTokenPayload<{exp: number; iat: number}>(result.token as string);
|
|
1065
|
+
const diffSeconds = decoded.exp - decoded.iat;
|
|
1066
|
+
// 1h = 3600s
|
|
1067
|
+
expect(diffSeconds).toBe(3600);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it("uses REFRESH_TOKEN_EXPIRES_IN when set to a valid duration", async () => {
|
|
1071
|
+
process.env.REFRESH_TOKEN_EXPIRES_IN = "7d";
|
|
1072
|
+
const result = await generateTokens({_id: "user-123"});
|
|
1073
|
+
expect(result.refreshToken).toBeDefined();
|
|
1074
|
+
const decoded = decodeTokenPayload<{exp: number; iat: number}>(result.refreshToken as string);
|
|
1075
|
+
const diffSeconds = decoded.exp - decoded.iat;
|
|
1076
|
+
// 7d = 604800s
|
|
1077
|
+
expect(diffSeconds).toBe(604800);
|
|
1078
|
+
});
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
describe("JWT cookie extraction and /me routes edge cases", () => {
|
|
1082
|
+
let app: express.Application;
|
|
1083
|
+
let agent: TestAgent;
|
|
1084
|
+
const OLD_ENV = process.env;
|
|
1085
|
+
|
|
1086
|
+
beforeEach(async () => {
|
|
1087
|
+
setSystemTime();
|
|
1088
|
+
process.env = {...OLD_ENV};
|
|
1089
|
+
await setupTestData();
|
|
1090
|
+
app = new TerrenoApp({
|
|
1091
|
+
configureApp: () => {},
|
|
1092
|
+
skipListen: true,
|
|
1093
|
+
userModel: UserModel as any,
|
|
1094
|
+
}).build();
|
|
1095
|
+
agent = supertest.agent(app);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
afterEach(() => {
|
|
1099
|
+
setSystemTime();
|
|
1100
|
+
process.env = OLD_ENV;
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it("returns 401 for /me when no user is authenticated", async () => {
|
|
1104
|
+
const res = await agent.get("/auth/me").expect(401);
|
|
1105
|
+
expect(res.status).toBe(401);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it("returns 401 for PATCH /me when no user is authenticated", async () => {
|
|
1109
|
+
const res = await agent.patch("/auth/me").send({name: "Updated"}).expect(401);
|
|
1110
|
+
expect(res.status).toBe(401);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
it("returns 404 for /me when user is deleted from database", async () => {
|
|
1114
|
+
// Login, then delete the user, then try /me
|
|
1115
|
+
const loginRes = await agent
|
|
1116
|
+
.post("/auth/login")
|
|
1117
|
+
.send({email: "notAdmin@example.com", password: "password"})
|
|
1118
|
+
.expect(200);
|
|
1119
|
+
const {token, userId} = loginRes.body.data;
|
|
1120
|
+
|
|
1121
|
+
// Delete the user from DB
|
|
1122
|
+
await UserModel.deleteOne({_id: userId});
|
|
1123
|
+
|
|
1124
|
+
const freshAgent = supertest.agent(app);
|
|
1125
|
+
const res = await freshAgent.get("/auth/me").set("authorization", `Bearer ${token}`);
|
|
1126
|
+
// Without the user, the JWT verify succeeds but findById returns null
|
|
1127
|
+
expect([401, 404]).toContain(res.status);
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
describe("login error and disabled user paths", () => {
|
|
1132
|
+
let app: express.Application;
|
|
1133
|
+
let agent: TestAgent;
|
|
1134
|
+
|
|
1135
|
+
beforeEach(async () => {
|
|
1136
|
+
setSystemTime();
|
|
1137
|
+
await setupTestData();
|
|
1138
|
+
app = new TerrenoApp({
|
|
1139
|
+
configureApp: () => {},
|
|
1140
|
+
skipListen: true,
|
|
1141
|
+
userModel: UserModel as any,
|
|
1142
|
+
}).build();
|
|
1143
|
+
agent = supertest.agent(app);
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
afterEach(() => {
|
|
1147
|
+
setSystemTime();
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
it("returns 401 with message for invalid credentials (no user found)", async () => {
|
|
1151
|
+
const res = await agent
|
|
1152
|
+
.post("/auth/login")
|
|
1153
|
+
.send({email: "nonexistent@example.com", password: "wrong"})
|
|
1154
|
+
.expect(401);
|
|
1155
|
+
expect(res.body.message).toBeDefined();
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
it("returns 401 when disabled user tries to access protected route", async () => {
|
|
1159
|
+
// Login to get token
|
|
1160
|
+
const loginRes = await agent
|
|
1161
|
+
.post("/auth/login")
|
|
1162
|
+
.send({email: "notAdmin@example.com", password: "password"})
|
|
1163
|
+
.expect(200);
|
|
1164
|
+
const {token, userId} = loginRes.body.data;
|
|
1165
|
+
|
|
1166
|
+
// Disable the user
|
|
1167
|
+
await UserModel.findByIdAndUpdate(userId, {disabled: true});
|
|
1168
|
+
|
|
1169
|
+
// Try to access /me with disabled user's token
|
|
1170
|
+
const freshAgent = supertest.agent(app);
|
|
1171
|
+
const res = await freshAgent
|
|
1172
|
+
.get("/auth/me")
|
|
1173
|
+
.set("authorization", `Bearer ${token}`)
|
|
1174
|
+
.expect(401);
|
|
1175
|
+
expect(res.body.title).toContain("disabled");
|
|
1176
|
+
});
|
|
929
1177
|
});
|
|
@@ -126,7 +126,6 @@ consentFormSchema.plugin(isDeletedPlugin);
|
|
|
126
126
|
consentFormSchema.plugin(findOneOrNone);
|
|
127
127
|
consentFormSchema.plugin(findExactlyOne);
|
|
128
128
|
|
|
129
|
-
export const ConsentForm =
|
|
130
|
-
|
|
131
|
-
consentFormSchema
|
|
132
|
-
);
|
|
129
|
+
export const ConsentForm =
|
|
130
|
+
(mongoose.models.ConsentForm as ConsentFormModel | undefined) ??
|
|
131
|
+
mongoose.model<ConsentFormDocument, ConsentFormModel>("ConsentForm", consentFormSchema);
|
|
@@ -72,7 +72,9 @@ consentResponseSchema.plugin(isDeletedPlugin);
|
|
|
72
72
|
consentResponseSchema.plugin(findOneOrNone);
|
|
73
73
|
consentResponseSchema.plugin(findExactlyOne);
|
|
74
74
|
|
|
75
|
-
export const ConsentResponse =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
export const ConsentResponse =
|
|
76
|
+
(mongoose.models.ConsentResponse as ConsentResponseModel | undefined) ??
|
|
77
|
+
mongoose.model<ConsentResponseDocument, ConsentResponseModel>(
|
|
78
|
+
"ConsentResponse",
|
|
79
|
+
consentResponseSchema
|
|
80
|
+
);
|
|
@@ -97,7 +97,6 @@ versionConfigSchema.plugin(isDeletedPlugin);
|
|
|
97
97
|
versionConfigSchema.plugin(findOneOrNone);
|
|
98
98
|
versionConfigSchema.plugin(findExactlyOne);
|
|
99
99
|
|
|
100
|
-
export const VersionConfig =
|
|
101
|
-
|
|
102
|
-
versionConfigSchema
|
|
103
|
-
);
|
|
100
|
+
export const VersionConfig =
|
|
101
|
+
(mongoose.models.VersionConfig as VersionConfigModel | undefined) ??
|
|
102
|
+
mongoose.model<VersionConfigDocument, VersionConfigModel>("VersionConfig", versionConfigSchema);
|
|
@@ -16,6 +16,7 @@ function addRoutesWithBuilder(router: Router, options?: Partial<ModelRouterOptio
|
|
|
16
16
|
const statsMiddleware = createOpenApiBuilder(options ?? {})
|
|
17
17
|
.withTags(["Stats"])
|
|
18
18
|
.withSummary("Get food statistics")
|
|
19
|
+
.withOperationId("getFoodStats")
|
|
19
20
|
.withDescription("Returns aggregated statistics about food items")
|
|
20
21
|
.withQueryParameter(
|
|
21
22
|
"category",
|
|
@@ -195,6 +196,14 @@ describe("OpenApiMiddlewareBuilder", () => {
|
|
|
195
196
|
expect(categoryParam.required).toBe(false);
|
|
196
197
|
});
|
|
197
198
|
|
|
199
|
+
it("includes the explicit operationId in OpenAPI spec", async () => {
|
|
200
|
+
server = supertest(app);
|
|
201
|
+
const res = await server.get("/openapi.json").expect(200);
|
|
202
|
+
|
|
203
|
+
const statsPath = res.body.paths["/food/stats"];
|
|
204
|
+
expect(statsPath.get.operationId).toBe("getFoodStats");
|
|
205
|
+
});
|
|
206
|
+
|
|
198
207
|
it("includes request body schema in OpenAPI spec", async () => {
|
|
199
208
|
server = supertest(app);
|
|
200
209
|
const res = await server.get("/openapi.json").expect(200);
|
package/src/openApiBuilder.ts
CHANGED
|
@@ -213,6 +213,8 @@ interface OpenApiConfig {
|
|
|
213
213
|
summary?: string;
|
|
214
214
|
/** Detailed description of the operation */
|
|
215
215
|
description?: string;
|
|
216
|
+
/** Explicit operationId for the operation */
|
|
217
|
+
operationId?: string;
|
|
216
218
|
/** Operation parameters (query, path, header) */
|
|
217
219
|
parameters?: OpenApiParameter[];
|
|
218
220
|
/** Request body configuration */
|
|
@@ -359,6 +361,28 @@ export class OpenApiMiddlewareBuilder {
|
|
|
359
361
|
return this;
|
|
360
362
|
}
|
|
361
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Sets an explicit `operationId` for the OpenAPI operation.
|
|
366
|
+
*
|
|
367
|
+
* The `operationId` is a unique string used to identify an operation. Client and SDK
|
|
368
|
+
* generators (e.g. RTK Query codegen) derive generated function and hook names from it,
|
|
369
|
+
* so setting it keeps generated names stable and readable for routes whose URL path would
|
|
370
|
+
* otherwise produce unwieldy names (e.g. deeply nested routes). It must be unique across
|
|
371
|
+
* the whole OpenAPI document.
|
|
372
|
+
*
|
|
373
|
+
* @param operationId - Unique operation identifier (e.g. "getUserStats")
|
|
374
|
+
* @returns The builder instance for chaining
|
|
375
|
+
*
|
|
376
|
+
* @example
|
|
377
|
+
* ```typescript
|
|
378
|
+
* builder.withOperationId("getUserStats");
|
|
379
|
+
* ```
|
|
380
|
+
*/
|
|
381
|
+
withOperationId(operationId: string): this {
|
|
382
|
+
this.config.operationId = operationId;
|
|
383
|
+
return this;
|
|
384
|
+
}
|
|
385
|
+
|
|
362
386
|
/**
|
|
363
387
|
* Sets the description for the OpenAPI operation.
|
|
364
388
|
*
|