@terreno/api 0.0.18 → 0.1.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/.claude/CLAUDE.local.md +204 -0
- package/.cursor/rules/00-root.mdc +338 -0
- package/.github/copilot-instructions.md +333 -0
- package/AGENTS.md +23 -3
- package/README.md +73 -3
- package/dist/api.d.ts +68 -1
- package/dist/api.js +139 -4
- package/dist/api.test.js +906 -2
- package/dist/auth.js +3 -1
- package/dist/errors.js +14 -11
- package/dist/example.js +7 -7
- package/dist/githubAuth.test.js +3 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/openApi.test.js +8 -5
- package/dist/openApiBuilder.d.ts +69 -1
- package/dist/openApiBuilder.js +109 -5
- package/dist/openApiValidator.d.ts +296 -0
- package/dist/openApiValidator.js +698 -0
- package/dist/openApiValidator.test.d.ts +1 -0
- package/dist/openApiValidator.test.js +346 -0
- package/dist/plugins.test.js +3 -3
- package/dist/terrenoPlugin.d.ts +4 -0
- package/dist/terrenoPlugin.js +2 -0
- package/dist/tests.js +34 -24
- package/package.json +4 -1
- package/src/__snapshots__/openApi.test.ts.snap +399 -0
- package/src/__snapshots__/openApiBuilder.test.ts.snap +108 -0
- package/src/api.test.ts +743 -2
- package/src/api.ts +209 -3
- package/src/auth.ts +3 -1
- package/src/errors.ts +14 -11
- package/src/example.ts +7 -7
- package/src/githubAuth.test.ts +3 -3
- package/src/index.ts +2 -0
- package/src/openApi.test.ts +8 -5
- package/src/openApiBuilder.ts +188 -15
- package/src/openApiValidator.test.ts +241 -0
- package/src/openApiValidator.ts +860 -0
- package/src/plugins.test.ts +3 -3
- package/src/terrenoPlugin.ts +5 -0
- package/src/tests.ts +34 -24
- package/.cursorrules +0 -107
- package/.windsurfrules +0 -107
- package/dist/response.d.ts +0 -0
- package/dist/response.js +0 -1
- package/index.ts +0 -1
- package/src/response.ts +0 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {modelRouter} from "./api";
|
|
4
|
+
import {addAuthRoutes, setupAuth} from "./auth";
|
|
5
|
+
import {
|
|
6
|
+
buildQuerySchemaFromFields,
|
|
7
|
+
configureOpenApiValidator,
|
|
8
|
+
isOpenApiValidatorConfigured,
|
|
9
|
+
resetOpenApiValidatorConfig,
|
|
10
|
+
validateRequestBody,
|
|
11
|
+
} from "./openApiValidator";
|
|
12
|
+
import {Permissions} from "./permissions";
|
|
13
|
+
import {authAsUser, FoodModel, getBaseServer, RequiredModel, setupDb, UserModel} from "./tests";
|
|
14
|
+
|
|
15
|
+
// RequiredModel has a clean schema that AJV can compile (no non-standard types).
|
|
16
|
+
// It has: name (String, required), about (String, optional)
|
|
17
|
+
const requiredRouterOptions = {
|
|
18
|
+
permissions: {
|
|
19
|
+
create: [Permissions.IsAuthenticated],
|
|
20
|
+
delete: [Permissions.IsAdmin],
|
|
21
|
+
list: [Permissions.IsAuthenticated],
|
|
22
|
+
read: [Permissions.IsAuthenticated],
|
|
23
|
+
update: [Permissions.IsAuthenticated],
|
|
24
|
+
},
|
|
25
|
+
queryFields: ["name"],
|
|
26
|
+
sort: "-name" as const,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const setupFreshApp = async () => {
|
|
30
|
+
const freshApp = getBaseServer();
|
|
31
|
+
setupAuth(freshApp, UserModel as any);
|
|
32
|
+
addAuthRoutes(freshApp, UserModel as any);
|
|
33
|
+
return freshApp;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("openApiValidator", () => {
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
resetOpenApiValidatorConfig();
|
|
39
|
+
await setupDb();
|
|
40
|
+
await RequiredModel.deleteMany({});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
resetOpenApiValidatorConfig();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("isConfigured flag", () => {
|
|
48
|
+
it("is false by default", () => {
|
|
49
|
+
expect(isOpenApiValidatorConfigured()).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("becomes true after configureOpenApiValidator()", () => {
|
|
53
|
+
configureOpenApiValidator();
|
|
54
|
+
expect(isOpenApiValidatorConfigured()).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("resets to false after resetOpenApiValidatorConfig()", () => {
|
|
58
|
+
configureOpenApiValidator();
|
|
59
|
+
expect(isOpenApiValidatorConfigured()).toBe(true);
|
|
60
|
+
resetOpenApiValidatorConfig();
|
|
61
|
+
expect(isOpenApiValidatorConfigured()).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("no-op when not configured", () => {
|
|
66
|
+
it("does not strip or validate when not configured", async () => {
|
|
67
|
+
const freshApp = await setupFreshApp();
|
|
68
|
+
freshApp.use("/required", modelRouter(RequiredModel, requiredRouterOptions));
|
|
69
|
+
const admin = await authAsUser(freshApp, "admin");
|
|
70
|
+
|
|
71
|
+
// When not configured, validation is a no-op — valid requests pass through
|
|
72
|
+
const res = await admin.post("/required").send({name: "Apple"}).expect(201);
|
|
73
|
+
expect(res.body.data.name).toBe("Apple");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("active after configuration", () => {
|
|
78
|
+
it("strips extra properties when removeAdditional is true", async () => {
|
|
79
|
+
configureOpenApiValidator({removeAdditional: true});
|
|
80
|
+
|
|
81
|
+
const freshApp = await setupFreshApp();
|
|
82
|
+
freshApp.use("/required", modelRouter(RequiredModel, requiredRouterOptions));
|
|
83
|
+
const admin = await authAsUser(freshApp, "admin");
|
|
84
|
+
|
|
85
|
+
const res = await admin
|
|
86
|
+
.post("/required")
|
|
87
|
+
.send({fakeField: "this should be stripped", name: "Apple"})
|
|
88
|
+
.expect(201);
|
|
89
|
+
|
|
90
|
+
expect(res.body.data.name).toBe("Apple");
|
|
91
|
+
expect(res.body.data.fakeField).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("rejects missing required fields", async () => {
|
|
95
|
+
configureOpenApiValidator();
|
|
96
|
+
|
|
97
|
+
const freshApp = await setupFreshApp();
|
|
98
|
+
freshApp.use("/required", modelRouter(RequiredModel, requiredRouterOptions));
|
|
99
|
+
const admin = await authAsUser(freshApp, "admin");
|
|
100
|
+
|
|
101
|
+
const res = await admin.post("/required").send({about: "no name"}).expect(400);
|
|
102
|
+
expect(res.body.title).toBe("Request validation failed");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("onAdditionalPropertiesRemoved hook", () => {
|
|
107
|
+
it("fires callback with removed property names", async () => {
|
|
108
|
+
const removedProps: string[] = [];
|
|
109
|
+
|
|
110
|
+
configureOpenApiValidator({
|
|
111
|
+
onAdditionalPropertiesRemoved: (props) => {
|
|
112
|
+
removedProps.push(...props);
|
|
113
|
+
},
|
|
114
|
+
removeAdditional: true,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const freshApp = await setupFreshApp();
|
|
118
|
+
freshApp.use("/required", modelRouter(RequiredModel, requiredRouterOptions));
|
|
119
|
+
const admin = await authAsUser(freshApp, "admin");
|
|
120
|
+
|
|
121
|
+
await admin
|
|
122
|
+
.post("/required")
|
|
123
|
+
.send({extraA: "stripped", extraB: "also stripped", name: "Apple"})
|
|
124
|
+
.expect(201);
|
|
125
|
+
|
|
126
|
+
expect(removedProps).toContain("extraA");
|
|
127
|
+
expect(removedProps).toContain("extraB");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("per-route validation: false override", () => {
|
|
132
|
+
it("skips validation when validation is false", async () => {
|
|
133
|
+
configureOpenApiValidator({removeAdditional: true});
|
|
134
|
+
|
|
135
|
+
const freshApp = await setupFreshApp();
|
|
136
|
+
freshApp.use(
|
|
137
|
+
"/required",
|
|
138
|
+
modelRouter(RequiredModel, {
|
|
139
|
+
...requiredRouterOptions,
|
|
140
|
+
validation: false,
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
const admin = await authAsUser(freshApp, "admin");
|
|
144
|
+
|
|
145
|
+
// With validation: false, extra properties are NOT stripped by validator
|
|
146
|
+
// RequiredModel does not have strict: "throw" so the extra field will just be ignored by Mongoose
|
|
147
|
+
const res = await admin
|
|
148
|
+
.post("/required")
|
|
149
|
+
.send({fakeField: "not stripped", name: "Apple"})
|
|
150
|
+
.expect(201);
|
|
151
|
+
|
|
152
|
+
expect(res.body.data.name).toBe("Apple");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("sanitization of non-standard mongoose-to-swagger types", () => {
|
|
157
|
+
it("validates models with ObjectId and DateOnly fields after sanitization", async () => {
|
|
158
|
+
configureOpenApiValidator({removeAdditional: true});
|
|
159
|
+
|
|
160
|
+
const freshApp = await setupFreshApp();
|
|
161
|
+
freshApp.use(
|
|
162
|
+
"/food",
|
|
163
|
+
modelRouter(FoodModel, {
|
|
164
|
+
permissions: {
|
|
165
|
+
create: [Permissions.IsAuthenticated],
|
|
166
|
+
delete: [Permissions.IsAdmin],
|
|
167
|
+
list: [Permissions.IsAuthenticated],
|
|
168
|
+
read: [Permissions.IsAuthenticated],
|
|
169
|
+
update: [Permissions.IsAuthenticated],
|
|
170
|
+
},
|
|
171
|
+
queryFields: ["name", "calories", "hidden"],
|
|
172
|
+
sort: "-created",
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
const admin = await authAsUser(freshApp, "admin");
|
|
176
|
+
|
|
177
|
+
const res = await admin
|
|
178
|
+
.post("/food")
|
|
179
|
+
.send({calories: 100, likesIds: [], name: "Apple", source: {name: "Test"}})
|
|
180
|
+
.expect(201);
|
|
181
|
+
|
|
182
|
+
expect(res.body.data.name).toBe("Apple");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("buildQuerySchemaFromFields", () => {
|
|
187
|
+
it("always includes limit, page, and sort", () => {
|
|
188
|
+
const schema = buildQuerySchemaFromFields(FoodModel, []);
|
|
189
|
+
expect(schema.limit).toBeDefined();
|
|
190
|
+
expect(schema.page).toBeDefined();
|
|
191
|
+
expect(schema.sort).toBeDefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("includes queryFields from model schema", () => {
|
|
195
|
+
const schema = buildQuerySchemaFromFields(FoodModel, ["name", "calories"]);
|
|
196
|
+
expect(schema.name).toBeDefined();
|
|
197
|
+
expect(schema.calories).toBeDefined();
|
|
198
|
+
expect(schema.hidden).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("marks query fields as not required", () => {
|
|
202
|
+
const schema = buildQuerySchemaFromFields(FoodModel, ["name"]);
|
|
203
|
+
expect(schema.name.required).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("validateRequestBody middleware", () => {
|
|
208
|
+
it("is a no-op when not configured", () => {
|
|
209
|
+
resetOpenApiValidatorConfig();
|
|
210
|
+
|
|
211
|
+
const middleware = validateRequestBody({
|
|
212
|
+
name: {required: true, type: "string"},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
let nextCalled = false;
|
|
216
|
+
const req = {body: {}} as any;
|
|
217
|
+
const res = {} as any;
|
|
218
|
+
const next = () => {
|
|
219
|
+
nextCalled = true;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
middleware(req, res, next);
|
|
223
|
+
expect(nextCalled).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("validates when configured", () => {
|
|
227
|
+
configureOpenApiValidator();
|
|
228
|
+
|
|
229
|
+
const middleware = validateRequestBody({
|
|
230
|
+
name: {required: true, type: "string"},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const req = {body: {}, method: "POST", path: "/test"} as any;
|
|
234
|
+
const res = {} as any;
|
|
235
|
+
|
|
236
|
+
expect(() => {
|
|
237
|
+
middleware(req, res, () => {});
|
|
238
|
+
}).toThrow();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|