@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.
Files changed (48) hide show
  1. package/.claude/CLAUDE.local.md +204 -0
  2. package/.cursor/rules/00-root.mdc +338 -0
  3. package/.github/copilot-instructions.md +333 -0
  4. package/AGENTS.md +23 -3
  5. package/README.md +73 -3
  6. package/dist/api.d.ts +68 -1
  7. package/dist/api.js +139 -4
  8. package/dist/api.test.js +906 -2
  9. package/dist/auth.js +3 -1
  10. package/dist/errors.js +14 -11
  11. package/dist/example.js +7 -7
  12. package/dist/githubAuth.test.js +3 -3
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.js +2 -0
  15. package/dist/openApi.test.js +8 -5
  16. package/dist/openApiBuilder.d.ts +69 -1
  17. package/dist/openApiBuilder.js +109 -5
  18. package/dist/openApiValidator.d.ts +296 -0
  19. package/dist/openApiValidator.js +698 -0
  20. package/dist/openApiValidator.test.d.ts +1 -0
  21. package/dist/openApiValidator.test.js +346 -0
  22. package/dist/plugins.test.js +3 -3
  23. package/dist/terrenoPlugin.d.ts +4 -0
  24. package/dist/terrenoPlugin.js +2 -0
  25. package/dist/tests.js +34 -24
  26. package/package.json +4 -1
  27. package/src/__snapshots__/openApi.test.ts.snap +399 -0
  28. package/src/__snapshots__/openApiBuilder.test.ts.snap +108 -0
  29. package/src/api.test.ts +743 -2
  30. package/src/api.ts +209 -3
  31. package/src/auth.ts +3 -1
  32. package/src/errors.ts +14 -11
  33. package/src/example.ts +7 -7
  34. package/src/githubAuth.test.ts +3 -3
  35. package/src/index.ts +2 -0
  36. package/src/openApi.test.ts +8 -5
  37. package/src/openApiBuilder.ts +188 -15
  38. package/src/openApiValidator.test.ts +241 -0
  39. package/src/openApiValidator.ts +860 -0
  40. package/src/plugins.test.ts +3 -3
  41. package/src/terrenoPlugin.ts +5 -0
  42. package/src/tests.ts +34 -24
  43. package/.cursorrules +0 -107
  44. package/.windsurfrules +0 -107
  45. package/dist/response.d.ts +0 -0
  46. package/dist/response.js +0 -1
  47. package/index.ts +0 -1
  48. 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
+ });