@terreno/api 0.0.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/LICENSE +202 -0
- package/README.md +170 -0
- package/biome.jsonc +22 -0
- package/bunfig.toml +4 -0
- package/dist/api.d.ts +227 -0
- package/dist/api.js +1024 -0
- package/dist/api.test.d.ts +1 -0
- package/dist/api.test.js +2143 -0
- package/dist/auth.d.ts +50 -0
- package/dist/auth.js +512 -0
- package/dist/auth.test.d.ts +1 -0
- package/dist/auth.test.js +778 -0
- package/dist/errors.d.ts +75 -0
- package/dist/errors.js +216 -0
- package/dist/example.d.ts +1 -0
- package/dist/example.js +118 -0
- package/dist/expressServer.d.ts +35 -0
- package/dist/expressServer.js +436 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +30 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.js +249 -0
- package/dist/middleware.d.ts +10 -0
- package/dist/middleware.js +52 -0
- package/dist/notifiers/googleChatNotifier.d.ts +5 -0
- package/dist/notifiers/googleChatNotifier.js +130 -0
- package/dist/notifiers/googleChatNotifier.test.d.ts +1 -0
- package/dist/notifiers/googleChatNotifier.test.js +260 -0
- package/dist/notifiers/index.d.ts +3 -0
- package/dist/notifiers/index.js +19 -0
- package/dist/notifiers/slackNotifier.d.ts +5 -0
- package/dist/notifiers/slackNotifier.js +130 -0
- package/dist/notifiers/slackNotifier.test.d.ts +1 -0
- package/dist/notifiers/slackNotifier.test.js +259 -0
- package/dist/notifiers/zoomNotifier.d.ts +34 -0
- package/dist/notifiers/zoomNotifier.js +181 -0
- package/dist/notifiers/zoomNotifier.test.d.ts +1 -0
- package/dist/notifiers/zoomNotifier.test.js +370 -0
- package/dist/openApi.d.ts +60 -0
- package/dist/openApi.js +441 -0
- package/dist/openApi.test.d.ts +1 -0
- package/dist/openApi.test.js +445 -0
- package/dist/openApiBuilder.d.ts +419 -0
- package/dist/openApiBuilder.js +424 -0
- package/dist/openApiBuilder.test.d.ts +1 -0
- package/dist/openApiBuilder.test.js +509 -0
- package/dist/openApiEtag.d.ts +7 -0
- package/dist/openApiEtag.js +38 -0
- package/dist/permissions.d.ts +26 -0
- package/dist/permissions.js +331 -0
- package/dist/permissions.test.d.ts +1 -0
- package/dist/permissions.test.js +413 -0
- package/dist/plugins.d.ts +67 -0
- package/dist/plugins.js +315 -0
- package/dist/plugins.test.d.ts +1 -0
- package/dist/plugins.test.js +639 -0
- package/dist/populate.d.ts +14 -0
- package/dist/populate.js +315 -0
- package/dist/populate.test.d.ts +1 -0
- package/dist/populate.test.js +133 -0
- package/dist/response.d.ts +0 -0
- package/dist/response.js +1 -0
- package/dist/tests/bunSetup.d.ts +1 -0
- package/dist/tests/bunSetup.js +297 -0
- package/dist/tests/index.d.ts +1 -0
- package/dist/tests/index.js +17 -0
- package/dist/tests.d.ts +99 -0
- package/dist/tests.js +273 -0
- package/dist/transformers.d.ts +25 -0
- package/dist/transformers.js +217 -0
- package/dist/transformers.test.d.ts +1 -0
- package/dist/transformers.test.js +370 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.js +143 -0
- package/dist/utils.test.d.ts +1 -0
- package/dist/utils.test.js +14 -0
- package/index.ts +1 -0
- package/package.json +88 -0
- package/src/__snapshots__/openApi.test.ts.snap +4814 -0
- package/src/__snapshots__/openApiBuilder.test.ts.snap +1485 -0
- package/src/api.test.ts +1661 -0
- package/src/api.ts +1036 -0
- package/src/auth.test.ts +550 -0
- package/src/auth.ts +408 -0
- package/src/errors.ts +225 -0
- package/src/example.ts +99 -0
- package/src/express.d.ts +5 -0
- package/src/expressServer.ts +387 -0
- package/src/index.ts +14 -0
- package/src/logger.ts +190 -0
- package/src/middleware.ts +18 -0
- package/src/notifiers/googleChatNotifier.test.ts +114 -0
- package/src/notifiers/googleChatNotifier.ts +47 -0
- package/src/notifiers/index.ts +3 -0
- package/src/notifiers/slackNotifier.test.ts +113 -0
- package/src/notifiers/slackNotifier.ts +55 -0
- package/src/notifiers/zoomNotifier.test.ts +207 -0
- package/src/notifiers/zoomNotifier.ts +111 -0
- package/src/openApi.test.ts +331 -0
- package/src/openApi.ts +494 -0
- package/src/openApiBuilder.test.ts +442 -0
- package/src/openApiBuilder.ts +636 -0
- package/src/openApiEtag.ts +40 -0
- package/src/permissions.test.ts +219 -0
- package/src/permissions.ts +228 -0
- package/src/plugins.test.ts +390 -0
- package/src/plugins.ts +289 -0
- package/src/populate.test.ts +65 -0
- package/src/populate.ts +258 -0
- package/src/response.ts +0 -0
- package/src/tests/bunSetup.ts +234 -0
- package/src/tests/index.ts +1 -0
- package/src/tests.ts +218 -0
- package/src/transformers.test.ts +202 -0
- package/src/transformers.ts +170 -0
- package/src/utils.test.ts +14 -0
- package/src/utils.ts +47 -0
- package/tsconfig.json +60 -0
- package/types.d.ts +17 -0
package/src/tests.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import express, {type Express} from "express";
|
|
2
|
+
import mongoose, {type Model, model, Schema} from "mongoose";
|
|
3
|
+
import passportLocalMongoose from "passport-local-mongoose";
|
|
4
|
+
import supertest from "supertest";
|
|
5
|
+
import type TestAgent from "supertest/lib/agent";
|
|
6
|
+
|
|
7
|
+
import {logger} from "./logger";
|
|
8
|
+
import {createdUpdatedPlugin, DateOnly, isDisabledPlugin} from "./plugins";
|
|
9
|
+
|
|
10
|
+
export interface User {
|
|
11
|
+
admin: boolean;
|
|
12
|
+
name?: string;
|
|
13
|
+
username: string;
|
|
14
|
+
email: string;
|
|
15
|
+
age?: number;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SuperUser extends User {
|
|
20
|
+
superTitle: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StaffUser extends User {
|
|
24
|
+
department: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FoodCategory {
|
|
28
|
+
_id?: string;
|
|
29
|
+
name: string;
|
|
30
|
+
show: boolean;
|
|
31
|
+
created: Date;
|
|
32
|
+
updated: Date;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Food {
|
|
36
|
+
_id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
calories: number;
|
|
39
|
+
created: Date;
|
|
40
|
+
ownerId: mongoose.Types.ObjectId | User;
|
|
41
|
+
hidden?: boolean;
|
|
42
|
+
source: {
|
|
43
|
+
name: string;
|
|
44
|
+
href?: string;
|
|
45
|
+
dateAdded?: string;
|
|
46
|
+
};
|
|
47
|
+
tags: string[];
|
|
48
|
+
eatenBy: [Schema.Types.ObjectId | User];
|
|
49
|
+
// We want to test that map type works.
|
|
50
|
+
lastEatenWith: {[name: string]: Date};
|
|
51
|
+
categories: FoodCategory[];
|
|
52
|
+
expiration: string;
|
|
53
|
+
likesIds: {userId: string; likes: boolean}[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const userSchema = new Schema<User>({
|
|
57
|
+
admin: {default: false, type: Boolean},
|
|
58
|
+
age: Number,
|
|
59
|
+
name: String,
|
|
60
|
+
username: String,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
userSchema.plugin(passportLocalMongoose as any, {
|
|
64
|
+
attemptsField: "attempts",
|
|
65
|
+
interval: process.env.NODE_ENV === "test" ? 1 : 100,
|
|
66
|
+
limitAttempts: true,
|
|
67
|
+
maxAttempts: 3,
|
|
68
|
+
maxInterval: process.env.NODE_ENV === "test" ? 1 : 300000,
|
|
69
|
+
usernameCaseInsensitive: true,
|
|
70
|
+
usernameField: "email",
|
|
71
|
+
});
|
|
72
|
+
// userSchema.plugin(tokenPlugin);
|
|
73
|
+
userSchema.plugin(createdUpdatedPlugin);
|
|
74
|
+
userSchema.plugin(isDisabledPlugin);
|
|
75
|
+
userSchema.methods.postCreate = async function (body: any) {
|
|
76
|
+
this.age = body.age;
|
|
77
|
+
return this.save();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const UserModel = model<User>("User", userSchema);
|
|
81
|
+
|
|
82
|
+
const superUserSchema = new Schema<SuperUser>({
|
|
83
|
+
superTitle: {required: true, type: String},
|
|
84
|
+
});
|
|
85
|
+
export const SuperUserModel = UserModel.discriminator("SuperUser", superUserSchema);
|
|
86
|
+
|
|
87
|
+
const staffUserSchema = new Schema<StaffUser>({
|
|
88
|
+
department: {required: true, type: String},
|
|
89
|
+
});
|
|
90
|
+
export const StaffUserModel = UserModel.discriminator("Staff", staffUserSchema);
|
|
91
|
+
|
|
92
|
+
const foodCategorySchema = new Schema<FoodCategory>(
|
|
93
|
+
{
|
|
94
|
+
name: String,
|
|
95
|
+
show: Boolean,
|
|
96
|
+
},
|
|
97
|
+
{timestamps: {createdAt: "created", updatedAt: "updated"}}
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const likesSchema = new Schema<any>({
|
|
101
|
+
likes: Boolean,
|
|
102
|
+
userId: {ref: "User", type: "ObjectId"},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const foodSchema = new Schema<Food>(
|
|
106
|
+
{
|
|
107
|
+
calories: Number,
|
|
108
|
+
categories: [foodCategorySchema],
|
|
109
|
+
created: Date,
|
|
110
|
+
eatenBy: [
|
|
111
|
+
{
|
|
112
|
+
ref: "User",
|
|
113
|
+
required: true,
|
|
114
|
+
type: Schema.Types.ObjectId,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
expiration: DateOnly,
|
|
118
|
+
hidden: {default: false, type: Boolean},
|
|
119
|
+
lastEatenWith: {
|
|
120
|
+
of: Date,
|
|
121
|
+
type: Map,
|
|
122
|
+
},
|
|
123
|
+
likesIds: {required: true, type: [likesSchema]},
|
|
124
|
+
name: String,
|
|
125
|
+
ownerId: {ref: "User", type: "ObjectId"},
|
|
126
|
+
source: {
|
|
127
|
+
dateAdded: String,
|
|
128
|
+
href: String,
|
|
129
|
+
name: String,
|
|
130
|
+
},
|
|
131
|
+
tags: [String],
|
|
132
|
+
},
|
|
133
|
+
{strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
foodSchema.virtual("description").get(function (this: Food) {
|
|
137
|
+
return `${this.name} has ${this.calories} calories`;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export const FoodModel: Model<Food> = model<Food>("Food", foodSchema);
|
|
141
|
+
|
|
142
|
+
interface RequiredField {
|
|
143
|
+
name: string;
|
|
144
|
+
about?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const requiredSchema = new Schema<RequiredField>({
|
|
148
|
+
about: String,
|
|
149
|
+
name: {required: true, type: String},
|
|
150
|
+
});
|
|
151
|
+
export const RequiredModel = model<RequiredField>("Required", requiredSchema);
|
|
152
|
+
|
|
153
|
+
export function getBaseServer(): Express {
|
|
154
|
+
const app = express();
|
|
155
|
+
|
|
156
|
+
app.all("/*", (req, res, next) => {
|
|
157
|
+
res.header("Access-Control-Allow-Origin", "*");
|
|
158
|
+
res.header("Access-Control-Allow-Headers", "*");
|
|
159
|
+
// intercepts OPTIONS method
|
|
160
|
+
if (req.method === "OPTIONS") {
|
|
161
|
+
res.send(200);
|
|
162
|
+
} else {
|
|
163
|
+
next();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
app.use(express.json());
|
|
167
|
+
return app;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function authAsUser(
|
|
171
|
+
app: express.Application,
|
|
172
|
+
type: "admin" | "notAdmin"
|
|
173
|
+
): Promise<TestAgent> {
|
|
174
|
+
const email = type === "admin" ? "admin@example.com" : "notAdmin@example.com";
|
|
175
|
+
const password = type === "admin" ? "securePassword" : "password";
|
|
176
|
+
|
|
177
|
+
const agent = supertest.agent(app);
|
|
178
|
+
const res = await agent.post("/auth/login").send({email, password}).expect(200);
|
|
179
|
+
await agent.set("authorization", `Bearer ${res.body.data.token}`);
|
|
180
|
+
return agent;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function setupDb() {
|
|
184
|
+
await mongoose
|
|
185
|
+
.connect("mongodb://127.0.0.1/terreno?&connectTimeoutMS=360000")
|
|
186
|
+
.catch(logger.catch);
|
|
187
|
+
|
|
188
|
+
process.env.REFRESH_TOKEN_SECRET = "refresh_secret";
|
|
189
|
+
process.env.TOKEN_SECRET = "secret";
|
|
190
|
+
process.env.TOKEN_EXPIRES_IN = "30m";
|
|
191
|
+
process.env.TOKEN_ISSUER = "example.com";
|
|
192
|
+
process.env.SESSION_SECRET = "session";
|
|
193
|
+
|
|
194
|
+
// Broken out of the try/catch below so you can test the catch logger by shutting down mongo.
|
|
195
|
+
await Promise.all([UserModel.deleteMany({}), FoodModel.deleteMany({})]).catch(logger.catch);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const [notAdmin, admin, adminOther] = await Promise.all([
|
|
199
|
+
UserModel.create({email: "notAdmin@example.com", name: "Not Admin"}),
|
|
200
|
+
UserModel.create({admin: true, email: "admin@example.com", name: "Admin"}),
|
|
201
|
+
UserModel.create({admin: true, email: "admin+other@example.com", name: "Admin Other"}),
|
|
202
|
+
]);
|
|
203
|
+
await (notAdmin as any).setPassword("password");
|
|
204
|
+
await notAdmin.save();
|
|
205
|
+
|
|
206
|
+
await (admin as any).setPassword("securePassword");
|
|
207
|
+
await admin.save();
|
|
208
|
+
|
|
209
|
+
await (adminOther as any).setPassword("otherPassword");
|
|
210
|
+
|
|
211
|
+
await adminOther.save();
|
|
212
|
+
|
|
213
|
+
return [admin, notAdmin, adminOther];
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.error("Error setting up DB", error);
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import type express from "express";
|
|
3
|
+
import type {ObjectId} from "mongoose";
|
|
4
|
+
import supertest from "supertest";
|
|
5
|
+
import type TestAgent from "supertest/lib/agent";
|
|
6
|
+
|
|
7
|
+
import {modelRouter} from "./api";
|
|
8
|
+
import {addAuthRoutes, setupAuth} from "./auth";
|
|
9
|
+
import {Permissions} from "./permissions";
|
|
10
|
+
import {authAsUser, type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
|
|
11
|
+
import {AdminOwnerTransformer} from "./transformers";
|
|
12
|
+
|
|
13
|
+
describe("query and transform", () => {
|
|
14
|
+
let notAdmin: any;
|
|
15
|
+
let admin: any;
|
|
16
|
+
let server: TestAgent;
|
|
17
|
+
let app: express.Application;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
|
|
21
|
+
|
|
22
|
+
[admin, notAdmin] = await setupDb();
|
|
23
|
+
|
|
24
|
+
await Promise.all([
|
|
25
|
+
FoodModel.create({
|
|
26
|
+
calories: 1,
|
|
27
|
+
created: new Date(),
|
|
28
|
+
name: "Spinach",
|
|
29
|
+
ownerId: notAdmin._id,
|
|
30
|
+
}),
|
|
31
|
+
FoodModel.create({
|
|
32
|
+
calories: 100,
|
|
33
|
+
created: Date.now() - 10,
|
|
34
|
+
hidden: true,
|
|
35
|
+
name: "Apple",
|
|
36
|
+
ownerId: admin._id,
|
|
37
|
+
}),
|
|
38
|
+
FoodModel.create({
|
|
39
|
+
calories: 100,
|
|
40
|
+
created: Date.now() - 10,
|
|
41
|
+
name: "Carrots",
|
|
42
|
+
ownerId: admin._id,
|
|
43
|
+
}),
|
|
44
|
+
]);
|
|
45
|
+
app = getBaseServer();
|
|
46
|
+
setupAuth(app, UserModel as any);
|
|
47
|
+
addAuthRoutes(app, UserModel as any);
|
|
48
|
+
app.use(
|
|
49
|
+
"/food",
|
|
50
|
+
modelRouter(FoodModel, {
|
|
51
|
+
allowAnonymous: true,
|
|
52
|
+
permissions: {
|
|
53
|
+
create: [Permissions.IsAny],
|
|
54
|
+
delete: [Permissions.IsAny],
|
|
55
|
+
list: [Permissions.IsAny],
|
|
56
|
+
read: [Permissions.IsAny],
|
|
57
|
+
update: [Permissions.IsAny],
|
|
58
|
+
},
|
|
59
|
+
queryFilter: (user?: {_id: ObjectId | string; admin: boolean}) => {
|
|
60
|
+
if (!user?.admin) {
|
|
61
|
+
return {hidden: {$ne: true}};
|
|
62
|
+
}
|
|
63
|
+
return {};
|
|
64
|
+
},
|
|
65
|
+
transformer: AdminOwnerTransformer<Food>({
|
|
66
|
+
adminReadFields: ["name", "calories", "created", "ownerId"],
|
|
67
|
+
adminWriteFields: ["name", "calories", "created", "ownerId"],
|
|
68
|
+
anonReadFields: ["name"],
|
|
69
|
+
anonWriteFields: [],
|
|
70
|
+
authReadFields: ["name", "calories", "created"],
|
|
71
|
+
authWriteFields: ["name", "calories"],
|
|
72
|
+
ownerReadFields: ["name", "calories", "created", "ownerId"],
|
|
73
|
+
ownerWriteFields: ["name", "calories", "created"],
|
|
74
|
+
}),
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
server = supertest(app);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("filters list for non-admin", async () => {
|
|
81
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
82
|
+
const foodRes = await agent.get("/food").expect(200);
|
|
83
|
+
expect(foodRes.body.data).toHaveLength(2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not filter list for admin", async () => {
|
|
87
|
+
const agent = await authAsUser(app, "admin");
|
|
88
|
+
const foodRes = await agent.get("/food").expect(200);
|
|
89
|
+
expect(foodRes.body.data).toHaveLength(3);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("admin read transform", async () => {
|
|
93
|
+
const agent = await authAsUser(app, "admin");
|
|
94
|
+
const foodRes = await agent.get("/food").expect(200);
|
|
95
|
+
expect(foodRes.body.data).toHaveLength(3);
|
|
96
|
+
const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach");
|
|
97
|
+
expect(spinach.created).toBeDefined();
|
|
98
|
+
expect(spinach.id).toBeDefined();
|
|
99
|
+
expect(spinach.ownerId).toBeDefined();
|
|
100
|
+
expect(spinach.name).toBe("Spinach");
|
|
101
|
+
expect(spinach.calories).toBe(1);
|
|
102
|
+
expect(spinach.hidden).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("admin write transform", async () => {
|
|
106
|
+
const agent = await authAsUser(app, "admin");
|
|
107
|
+
const foodRes = await agent.get("/food").expect(200);
|
|
108
|
+
const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach");
|
|
109
|
+
const spinachRes = await agent.patch(`/food/${spinach.id}`).send({name: "Lettuce"}).expect(200);
|
|
110
|
+
expect(spinachRes.body.data.name).toBe("Lettuce");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("owner read transform", async () => {
|
|
114
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
115
|
+
const foodRes = await agent.get("/food").expect(200);
|
|
116
|
+
expect(foodRes.body.data).toHaveLength(2);
|
|
117
|
+
const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach");
|
|
118
|
+
expect(spinach.id).toBeDefined();
|
|
119
|
+
expect(spinach.name).toBe("Spinach");
|
|
120
|
+
expect(spinach.calories).toBe(1);
|
|
121
|
+
expect(spinach.created).toBeDefined();
|
|
122
|
+
expect(spinach.ownerId).toBeDefined();
|
|
123
|
+
expect(spinach.hidden).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("owner write transform", async () => {
|
|
127
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
128
|
+
const foodRes = await agent.get("/food").expect(200);
|
|
129
|
+
const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach");
|
|
130
|
+
await agent.patch(`/food/${spinach.id}`).send({ownerId: admin.id}).expect(403);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("owner write transform fails", async () => {
|
|
134
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
135
|
+
const foodRes = await agent.get("/food").expect(200);
|
|
136
|
+
const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach");
|
|
137
|
+
const spinachRes = await agent
|
|
138
|
+
.patch(`/food/${spinach.id}`)
|
|
139
|
+
.send({ownerId: notAdmin.id})
|
|
140
|
+
.expect(403);
|
|
141
|
+
expect(spinachRes.body.title.includes("User of type owner cannot write fields: ownerId")).toBe(
|
|
142
|
+
true
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("auth read transform", async () => {
|
|
147
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
148
|
+
const foodRes = await agent.get("/food").expect(200);
|
|
149
|
+
expect(foodRes.body.data).toHaveLength(2);
|
|
150
|
+
const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach");
|
|
151
|
+
expect(spinach.id).toBeDefined();
|
|
152
|
+
expect(spinach.name).toBe("Spinach");
|
|
153
|
+
expect(spinach.calories).toBe(1);
|
|
154
|
+
expect(spinach.created).toBeDefined();
|
|
155
|
+
// Owner, so this is defined.
|
|
156
|
+
expect(spinach.ownerId).toBeDefined();
|
|
157
|
+
expect(spinach.hidden).toBeUndefined();
|
|
158
|
+
|
|
159
|
+
const carrots = foodRes.body.data.find((food: Food) => food.name === "Carrots");
|
|
160
|
+
expect(carrots.id).toBeDefined();
|
|
161
|
+
expect(carrots.name).toBe("Carrots");
|
|
162
|
+
expect(carrots.calories).toBe(100);
|
|
163
|
+
expect(carrots.created).toBeDefined();
|
|
164
|
+
// Not owner, so undefined.
|
|
165
|
+
expect(carrots.ownerId).toBeUndefined();
|
|
166
|
+
expect(spinach.hidden).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("auth write transform", async () => {
|
|
170
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
171
|
+
const foodRes = await agent.get("/food");
|
|
172
|
+
const carrots = foodRes.body.data.find((food: Food) => food.name === "Carrots");
|
|
173
|
+
const carrotRes = await agent.patch(`/food/${carrots.id}`).send({calories: 2000}).expect(200);
|
|
174
|
+
expect(carrotRes.body.data.calories).toBe(2000);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("auth write transform fail", async () => {
|
|
178
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
179
|
+
const foodRes = await agent.get("/food");
|
|
180
|
+
const carrots = foodRes.body.data.find((food: Food) => food.name === "Carrots");
|
|
181
|
+
const writeRes = await agent
|
|
182
|
+
.patch(`/food/${carrots.id}`)
|
|
183
|
+
.send({created: "2020-01-01T00:00:00Z"})
|
|
184
|
+
.expect(403);
|
|
185
|
+
expect(writeRes.body.title.includes("User of type auth cannot write fields: created")).toBe(
|
|
186
|
+
true
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("anon read transform", async () => {
|
|
191
|
+
const res = await server.get("/food");
|
|
192
|
+
expect(res.body.data).toHaveLength(2);
|
|
193
|
+
expect(res.body.data.find((f: Food) => f.name === "Spinach")).toBeDefined();
|
|
194
|
+
expect(res.body.data.find((f: Food) => f.name === "Carrots")).toBeDefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("anon write transform fails", async () => {
|
|
198
|
+
const foodRes = await server.get("/food");
|
|
199
|
+
const carrots = foodRes.body.data.find((food: Food) => food.name === "Carrots");
|
|
200
|
+
await server.patch(`/food/${carrots.id}`).send({calories: 10}).expect(403);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type express from "express";
|
|
2
|
+
import type {Document} from "mongoose";
|
|
3
|
+
|
|
4
|
+
import type {modelRouterOptions} from "./api";
|
|
5
|
+
import type {User} from "./auth";
|
|
6
|
+
import {APIError} from "./errors";
|
|
7
|
+
import {logger} from "./logger";
|
|
8
|
+
|
|
9
|
+
export interface TerrenoTransformer<T> {
|
|
10
|
+
// Runs before create or update operations. Allows throwing out fields that the user should be
|
|
11
|
+
// able to write to, modify data, check permissions, etc.
|
|
12
|
+
transform?: (obj: Partial<T>, method: "create" | "update", user?: User) => Partial<T> | undefined;
|
|
13
|
+
// Runs after create/update operations but before data is returned from the API. Serialize fetched
|
|
14
|
+
// data, dropping fields based on user, changing data, etc.
|
|
15
|
+
serialize?: (obj: T, user?: User) => Partial<T> | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getUserType(user?: User, obj?: any): "anon" | "auth" | "owner" | "admin" {
|
|
19
|
+
if (user?.admin) {
|
|
20
|
+
return "admin";
|
|
21
|
+
}
|
|
22
|
+
if (obj && user && String(obj?.ownerId) === String(user?.id)) {
|
|
23
|
+
return "owner";
|
|
24
|
+
}
|
|
25
|
+
if (user?.id) {
|
|
26
|
+
return "auth";
|
|
27
|
+
}
|
|
28
|
+
return "anon";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function AdminOwnerTransformer<T>(options: {
|
|
32
|
+
// TODO: do something with KeyOf here.
|
|
33
|
+
anonReadFields?: string[];
|
|
34
|
+
authReadFields?: string[];
|
|
35
|
+
ownerReadFields?: string[];
|
|
36
|
+
adminReadFields?: string[];
|
|
37
|
+
anonWriteFields?: string[];
|
|
38
|
+
authWriteFields?: string[];
|
|
39
|
+
ownerWriteFields?: string[];
|
|
40
|
+
adminWriteFields?: string[];
|
|
41
|
+
}): TerrenoTransformer<T> {
|
|
42
|
+
function pickFields(obj: Partial<T>, fields: any[]): Partial<T> {
|
|
43
|
+
const newData: Partial<T> = {};
|
|
44
|
+
for (const field of fields) {
|
|
45
|
+
if (obj[field] !== undefined) {
|
|
46
|
+
newData[field] = obj[field];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return newData;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
serialize: (obj: T, user?: User) => {
|
|
54
|
+
const userType = getUserType(user, obj);
|
|
55
|
+
if (userType === "admin") {
|
|
56
|
+
return pickFields(obj, [...(options.adminReadFields ?? []), "id"]);
|
|
57
|
+
}
|
|
58
|
+
if (userType === "owner") {
|
|
59
|
+
return pickFields(obj, [...(options.ownerReadFields ?? []), "id"]);
|
|
60
|
+
}
|
|
61
|
+
if (userType === "auth") {
|
|
62
|
+
return pickFields(obj, [...(options.authReadFields ?? []), "id"]);
|
|
63
|
+
}
|
|
64
|
+
return pickFields(obj, [...(options.anonReadFields ?? []), "id"]);
|
|
65
|
+
},
|
|
66
|
+
// TODO: Migrate AdminOwnerTransform to use pre-hooks.
|
|
67
|
+
transform: (obj: Partial<T>, _method: "create" | "update", user?: User) => {
|
|
68
|
+
const userType = getUserType(user, obj);
|
|
69
|
+
let allowedFields: any;
|
|
70
|
+
if (userType === "admin") {
|
|
71
|
+
allowedFields = options.adminWriteFields ?? [];
|
|
72
|
+
} else if (userType === "owner") {
|
|
73
|
+
allowedFields = options.ownerWriteFields ?? [];
|
|
74
|
+
} else if (userType === "auth") {
|
|
75
|
+
allowedFields = options.authWriteFields ?? [];
|
|
76
|
+
} else {
|
|
77
|
+
allowedFields = options.anonWriteFields ?? [];
|
|
78
|
+
}
|
|
79
|
+
const unallowedFields = Object.keys(obj).filter((k) => !allowedFields.includes(k));
|
|
80
|
+
if (unallowedFields.length) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`User of type ${userType} cannot write fields: ${unallowedFields.join(", ")}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return obj;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function transform<T>(
|
|
91
|
+
options: modelRouterOptions<T>,
|
|
92
|
+
data: Partial<T> | Partial<T>[],
|
|
93
|
+
method: "create" | "update",
|
|
94
|
+
user?: User
|
|
95
|
+
) {
|
|
96
|
+
if (!options.transformer?.transform) {
|
|
97
|
+
return data;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
logger.warn(
|
|
101
|
+
"transform functions are deprecated, use preCreate/preUpdate/preDelete hooks instead"
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// TS doesn't realize this is defined otherwise...
|
|
105
|
+
const transformFn = options.transformer?.transform;
|
|
106
|
+
|
|
107
|
+
if (!Array.isArray(data)) {
|
|
108
|
+
return transformFn(data, method, user);
|
|
109
|
+
}
|
|
110
|
+
return data.map((d) => transformFn(d, method, user));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function serialize<T>(
|
|
114
|
+
req: express.Request,
|
|
115
|
+
options: modelRouterOptions<T>,
|
|
116
|
+
data: (Document<any, any, any> & T) | (Document<any, any, any> & T)[]
|
|
117
|
+
) {
|
|
118
|
+
const serializeFn = (serializeData: Document<any, any, any> & T, serializeUser?: User) => {
|
|
119
|
+
const dataObject = serializeData.toObject() as T;
|
|
120
|
+
(dataObject as any).id = serializeData._id;
|
|
121
|
+
|
|
122
|
+
// Search for any value that is a Map and transform it to a plain object.
|
|
123
|
+
// Otherwise Express drops the contents.
|
|
124
|
+
for (const key in dataObject) {
|
|
125
|
+
const value = dataObject[key];
|
|
126
|
+
if (value instanceof Map) {
|
|
127
|
+
dataObject[key] = Object.fromEntries(value);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (options.transformer?.serialize) {
|
|
132
|
+
return options.transformer?.serialize(dataObject, serializeUser);
|
|
133
|
+
}
|
|
134
|
+
return dataObject;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (options.transformer?.serialize) {
|
|
138
|
+
logger.warn(
|
|
139
|
+
"transform.serialize functions are deprecated, use post* hooks and serialize instead"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (!Array.isArray(data)) {
|
|
143
|
+
return serializeFn(data, req.user);
|
|
144
|
+
}
|
|
145
|
+
return data.map((d) => serializeFn(d, req.user));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Default response handler for modelRouter. Calls toObject on each doc and returns the result,
|
|
150
|
+
* using transformers.serializer if provided.
|
|
151
|
+
*/
|
|
152
|
+
export async function defaultResponseHandler<T>(
|
|
153
|
+
doc: (Document<any, any, any> & T) | (Document<any, any, any> & T)[] | null,
|
|
154
|
+
method: "list" | "create" | "read" | "update",
|
|
155
|
+
request: express.Request,
|
|
156
|
+
options: modelRouterOptions<T>
|
|
157
|
+
) {
|
|
158
|
+
if (!doc) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
return serialize(request, options, doc);
|
|
163
|
+
} catch (error: any) {
|
|
164
|
+
throw new APIError({
|
|
165
|
+
error,
|
|
166
|
+
status: 400,
|
|
167
|
+
title: `Error serializing ${method} response: ${error.message}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {describe, expect, it} from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {isValidObjectId} from "./utils";
|
|
4
|
+
|
|
5
|
+
describe("utils", () => {
|
|
6
|
+
it("checks valid ObjectIds", () => {
|
|
7
|
+
expect(isValidObjectId("62c44da0003d9f8ee8cc925c")).toBe(true);
|
|
8
|
+
expect(isValidObjectId("620000000000000000000000")).toBe(true);
|
|
9
|
+
// Mongoose's builtin "ObjectId.isValid" will falsely say this is an ObjectId.
|
|
10
|
+
expect(isValidObjectId("1234567890ab")).toBe(false);
|
|
11
|
+
expect(isValidObjectId("microsoft123")).toBe(false);
|
|
12
|
+
expect(isValidObjectId("62c44da0003d9f8ee8cc925x")).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
});
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import mongoose, {Types} from "mongoose";
|
|
2
|
+
|
|
3
|
+
import {logger} from "./logger";
|
|
4
|
+
|
|
5
|
+
// A better version of mongoose's ObjectId.isValid,
|
|
6
|
+
// which falsely will say any 12 character string is valid.
|
|
7
|
+
export function isValidObjectId(id: string): boolean {
|
|
8
|
+
try {
|
|
9
|
+
return new Types.ObjectId(id).toString() === id;
|
|
10
|
+
} catch (error) {
|
|
11
|
+
logger.error(`Error validating object id ${id}: ${error}`);
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const timeout = async (ms: number): Promise<NodeJS.Timeout> => {
|
|
17
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ensure that all mongoose models are set to strict mode.
|
|
22
|
+
* This validates that models will throw errors when attempting to set
|
|
23
|
+
* properties that aren't defined in the schema.
|
|
24
|
+
*
|
|
25
|
+
* @param ignoredModels - Array of model names to skip validation for
|
|
26
|
+
* @throws Error if any model is not set to strict mode or missing virtual settings
|
|
27
|
+
*/
|
|
28
|
+
export function checkModelsStrict(ignoredModels: string[] = []): void {
|
|
29
|
+
const models = mongoose.modelNames();
|
|
30
|
+
for (const model of models) {
|
|
31
|
+
const schema = mongoose.model(model).schema;
|
|
32
|
+
|
|
33
|
+
if (schema.get("toObject")?.virtuals !== true) {
|
|
34
|
+
throw new Error(`Model ${model} toObject.virtuals not set to true`);
|
|
35
|
+
}
|
|
36
|
+
if (schema.get("toJSON")?.virtuals !== true) {
|
|
37
|
+
throw new Error(`Model ${model} toJSON.virtuals not set to true`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (ignoredModels.includes(model)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (schema.get("strict") !== "throw") {
|
|
44
|
+
throw new Error(`Model ${model} is not set to strict mode.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|