@terreno/api 0.0.17 → 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 +333 -0
- package/README.md +76 -7
- package/biome.jsonc +1 -1
- package/dist/api.d.ts +68 -1
- package/dist/api.js +140 -5
- package/dist/api.query.test.js +1 -1
- package/dist/api.test.js +222 -484
- package/dist/auth.js +3 -1
- package/dist/errors.js +15 -12
- package/dist/example.js +7 -7
- package/dist/expressServer.d.ts +8 -2
- package/dist/expressServer.js +8 -1
- package/dist/githubAuth.d.ts +64 -0
- package/dist/githubAuth.js +293 -0
- package/dist/githubAuth.test.d.ts +1 -0
- package/dist/githubAuth.test.js +351 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/logger.js +1 -1
- package/dist/middleware.js +1 -1
- package/dist/notifiers/googleChatNotifier.js +1 -1
- package/dist/notifiers/googleChatNotifier.test.js +1 -1
- package/dist/notifiers/slackNotifier.js +1 -1
- package/dist/notifiers/slackNotifier.test.js +1 -1
- package/dist/notifiers/zoomNotifier.js +1 -1
- package/dist/notifiers/zoomNotifier.test.js +1 -1
- 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/permissions.js +1 -1
- package/dist/plugins.test.js +3 -3
- package/dist/terrenoPlugin.d.ts +4 -0
- package/dist/terrenoPlugin.js +2 -0
- package/dist/tests/bunSetup.js +2 -2
- package/dist/tests.js +34 -24
- package/package.json +7 -2
- package/src/__snapshots__/openApi.test.ts.snap +399 -0
- package/src/__snapshots__/openApiBuilder.test.ts.snap +108 -0
- package/src/api.query.test.ts +1 -1
- package/src/api.test.ts +161 -374
- package/src/api.ts +210 -4
- package/src/auth.ts +3 -1
- package/src/errors.ts +15 -12
- package/src/example.ts +7 -7
- package/src/expressServer.ts +18 -2
- package/src/githubAuth.test.ts +223 -0
- package/src/githubAuth.ts +335 -0
- package/src/index.ts +3 -0
- package/src/logger.ts +1 -1
- package/src/middleware.ts +1 -1
- package/src/notifiers/googleChatNotifier.test.ts +1 -1
- package/src/notifiers/googleChatNotifier.ts +1 -1
- package/src/notifiers/slackNotifier.test.ts +1 -1
- package/src/notifiers/slackNotifier.ts +1 -1
- package/src/notifiers/zoomNotifier.test.ts +1 -1
- package/src/notifiers/zoomNotifier.ts +1 -1
- 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/permissions.ts +1 -1
- package/src/plugins.test.ts +3 -3
- package/src/terrenoPlugin.ts +5 -0
- package/src/tests/bunSetup.ts +2 -2
- package/src/tests.ts +34 -24
- package/CLAUDE.md +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
package/src/api.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
|
-
import * as Sentry from "@sentry/
|
|
6
|
+
import * as Sentry from "@sentry/bun";
|
|
7
7
|
import express, {type NextFunction, type Request, type Response} from "express";
|
|
8
8
|
import cloneDeep from "lodash/cloneDeep";
|
|
9
9
|
import mongoose, {type Document, type Model} from "mongoose";
|
|
@@ -18,6 +18,12 @@ import {
|
|
|
18
18
|
listOpenApiMiddleware,
|
|
19
19
|
patchOpenApiMiddleware,
|
|
20
20
|
} from "./openApi";
|
|
21
|
+
import {
|
|
22
|
+
buildQuerySchemaFromFields,
|
|
23
|
+
type ModelRouterValidationOptions,
|
|
24
|
+
validateModelRequestBody,
|
|
25
|
+
validateQueryParams,
|
|
26
|
+
} from "./openApiValidator";
|
|
21
27
|
import {checkPermissions, permissionMiddleware, type RESTPermissions} from "./permissions";
|
|
22
28
|
import type {PopulatePath} from "./populate";
|
|
23
29
|
import {
|
|
@@ -263,6 +269,19 @@ export interface ModelRouterOptions<T> {
|
|
|
263
269
|
* that you want to be documented and typed in the SDK.
|
|
264
270
|
*/
|
|
265
271
|
openApiExtraModelProperties?: any;
|
|
272
|
+
/**
|
|
273
|
+
* Enable runtime validation of request bodies against the OpenAPI schema.
|
|
274
|
+
* When enabled, requests that don't match the documented schema will return 400 errors.
|
|
275
|
+
*
|
|
276
|
+
* Can be set to:
|
|
277
|
+
* - `true`: Enable validation for create and update operations
|
|
278
|
+
* - `false`: Disable validation (default)
|
|
279
|
+
* - Object with `validateCreate` and `validateUpdate` booleans for fine-grained control
|
|
280
|
+
*
|
|
281
|
+
* Note: Global validation can be enabled via `configureOpenApiValidator()`.
|
|
282
|
+
* This option overrides the global setting for this specific router.
|
|
283
|
+
*/
|
|
284
|
+
validation?: boolean | ModelRouterValidationOptions;
|
|
266
285
|
}
|
|
267
286
|
|
|
268
287
|
// Ensures query params are allowed. Also checks nested query params when using $and/$or.
|
|
@@ -309,6 +328,78 @@ function checkQueryParamAllowed(
|
|
|
309
328
|
// return result;
|
|
310
329
|
// }
|
|
311
330
|
|
|
331
|
+
// Helper to determine if validation should be enabled for a specific operation.
|
|
332
|
+
// When options.validation is not set, returns true — the middleware's own
|
|
333
|
+
// isConfigured check will decide whether to actually validate.
|
|
334
|
+
function shouldValidate(
|
|
335
|
+
options: ModelRouterOptions<any>,
|
|
336
|
+
operation: "create" | "update" | "query"
|
|
337
|
+
): boolean {
|
|
338
|
+
// Check route-specific validation option first
|
|
339
|
+
if (options.validation !== undefined) {
|
|
340
|
+
if (typeof options.validation === "boolean") {
|
|
341
|
+
return options.validation;
|
|
342
|
+
}
|
|
343
|
+
if (operation === "create") {
|
|
344
|
+
return options.validation.validateCreate ?? true;
|
|
345
|
+
}
|
|
346
|
+
if (operation === "update") {
|
|
347
|
+
return options.validation.validateUpdate ?? true;
|
|
348
|
+
}
|
|
349
|
+
return options.validation.validateQuery ?? true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Default: let middleware's isConfigured check decide
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Get body validation middleware if validation is enabled
|
|
357
|
+
function getBodyValidationMiddleware<T>(
|
|
358
|
+
model: Model<T>,
|
|
359
|
+
options: ModelRouterOptions<T>,
|
|
360
|
+
operation: "create" | "update"
|
|
361
|
+
): (req: Request, res: Response, next: NextFunction) => void {
|
|
362
|
+
const validationOptions: import("./openApiValidator").RequestBodyValidatorOptions = {};
|
|
363
|
+
if (!shouldValidate(options, operation)) {
|
|
364
|
+
validationOptions.enabled = false;
|
|
365
|
+
}
|
|
366
|
+
if (typeof options.validation === "object") {
|
|
367
|
+
if (options.validation.onError) {
|
|
368
|
+
validationOptions.onError = options.validation.onError;
|
|
369
|
+
}
|
|
370
|
+
if (options.validation.onAdditionalPropertiesRemoved) {
|
|
371
|
+
validationOptions.onAdditionalPropertiesRemoved =
|
|
372
|
+
options.validation.onAdditionalPropertiesRemoved;
|
|
373
|
+
}
|
|
374
|
+
const excludeFields =
|
|
375
|
+
operation === "create"
|
|
376
|
+
? options.validation.excludeFromCreate
|
|
377
|
+
: options.validation.excludeFromUpdate;
|
|
378
|
+
if (excludeFields?.length) {
|
|
379
|
+
validationOptions.excludeFields = excludeFields;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return validateModelRequestBody(model, validationOptions);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Get query validation middleware if validation is enabled
|
|
387
|
+
function getQueryValidationMiddleware<T>(
|
|
388
|
+
model: Model<T>,
|
|
389
|
+
options: ModelRouterOptions<T>
|
|
390
|
+
): (req: Request, res: Response, next: NextFunction) => void {
|
|
391
|
+
const querySchema = buildQuerySchemaFromFields(model, options.queryFields);
|
|
392
|
+
const validationOptions: import("./openApiValidator").QueryValidatorOptions = {};
|
|
393
|
+
if (!shouldValidate(options, "query")) {
|
|
394
|
+
validationOptions.enabled = false;
|
|
395
|
+
}
|
|
396
|
+
if (typeof options.validation === "object" && options.validation.onError) {
|
|
397
|
+
validationOptions.onError = options.validation.onError;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return validateQueryParams(querySchema, validationOptions);
|
|
401
|
+
}
|
|
402
|
+
|
|
312
403
|
/**
|
|
313
404
|
* Create a set of CRUD routes given a Mongoose model and configuration options.
|
|
314
405
|
*
|
|
@@ -325,12 +416,18 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
325
416
|
|
|
326
417
|
const responseHandler = options.responseHandler ?? defaultResponseHandler;
|
|
327
418
|
|
|
419
|
+
// Always install validation middleware — they are no-ops until configureOpenApiValidator() is called
|
|
420
|
+
const createValidation = getBodyValidationMiddleware(model, options, "create");
|
|
421
|
+
const updateValidation = getBodyValidationMiddleware(model, options, "update");
|
|
422
|
+
const queryValidation = getQueryValidationMiddleware(model, options);
|
|
423
|
+
|
|
328
424
|
router.post(
|
|
329
425
|
"/",
|
|
330
426
|
[
|
|
331
427
|
authenticateMiddleware(options.allowAnonymous),
|
|
332
428
|
createOpenApiMiddleware(model, options),
|
|
333
429
|
permissionMiddleware(model, options),
|
|
430
|
+
createValidation,
|
|
334
431
|
],
|
|
335
432
|
asyncHandler(async (req: Request, res: Response) => {
|
|
336
433
|
let body: Partial<T> | (Partial<T> | undefined)[] | null | undefined;
|
|
@@ -439,6 +536,7 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
439
536
|
authenticateMiddleware(options.allowAnonymous),
|
|
440
537
|
permissionMiddleware(model, options),
|
|
441
538
|
listOpenApiMiddleware(model, options),
|
|
539
|
+
queryValidation,
|
|
442
540
|
],
|
|
443
541
|
asyncHandler(async (req: Request, res: Response) => {
|
|
444
542
|
let query: any = {};
|
|
@@ -625,6 +723,7 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
625
723
|
authenticateMiddleware(options.allowAnonymous),
|
|
626
724
|
patchOpenApiMiddleware(model, options),
|
|
627
725
|
permissionMiddleware(model, options),
|
|
726
|
+
updateValidation,
|
|
628
727
|
],
|
|
629
728
|
asyncHandler(async (req: Request, res: Response) => {
|
|
630
729
|
let doc: mongoose.Document & T = (req as any).obj;
|
|
@@ -984,9 +1083,116 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
984
1083
|
return router;
|
|
985
1084
|
}
|
|
986
1085
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1086
|
+
/**
|
|
1087
|
+
* Options for the asyncHandler function.
|
|
1088
|
+
*/
|
|
1089
|
+
export interface AsyncHandlerOptions {
|
|
1090
|
+
/**
|
|
1091
|
+
* Schema for validating request body.
|
|
1092
|
+
* When provided and validation is enabled, the request body will be validated
|
|
1093
|
+
* against this schema before the handler runs.
|
|
1094
|
+
*/
|
|
1095
|
+
bodySchema?: Record<string, import("./openApiBuilder").OpenApiSchemaProperty>;
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Schema for validating query parameters.
|
|
1099
|
+
* When provided and validation is enabled, query params will be validated
|
|
1100
|
+
* against this schema before the handler runs.
|
|
1101
|
+
*/
|
|
1102
|
+
querySchema?: Record<string, import("./openApiBuilder").OpenApiSchemaProperty>;
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Override global validation setting for this handler.
|
|
1106
|
+
* - `true`: Enable validation regardless of global setting
|
|
1107
|
+
* - `false`: Disable validation regardless of global setting
|
|
1108
|
+
* - `undefined`: Use global setting
|
|
1109
|
+
*/
|
|
1110
|
+
validate?: boolean;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Wraps async route handlers to properly catch and forward errors.
|
|
1115
|
+
*
|
|
1116
|
+
* Since Express doesn't handle async routes well, wrap them with this function.
|
|
1117
|
+
* Optionally supports integrated request validation.
|
|
1118
|
+
*
|
|
1119
|
+
* @param fn - The async route handler function
|
|
1120
|
+
* @param options - Optional configuration for validation
|
|
1121
|
+
* @returns Express middleware function
|
|
1122
|
+
*
|
|
1123
|
+
* @example
|
|
1124
|
+
* ```typescript
|
|
1125
|
+
* // Basic usage without validation
|
|
1126
|
+
* router.post("/users", asyncHandler(async (req, res) => {
|
|
1127
|
+
* // handler code
|
|
1128
|
+
* }));
|
|
1129
|
+
*
|
|
1130
|
+
* // With integrated validation
|
|
1131
|
+
* router.post("/users", asyncHandler(async (req, res) => {
|
|
1132
|
+
* // handler code - body is already validated
|
|
1133
|
+
* }, {
|
|
1134
|
+
* bodySchema: {
|
|
1135
|
+
* name: {type: "string", required: true},
|
|
1136
|
+
* email: {type: "string", format: "email", required: true},
|
|
1137
|
+
* },
|
|
1138
|
+
* validate: true,
|
|
1139
|
+
* }));
|
|
1140
|
+
* ```
|
|
1141
|
+
*/
|
|
1142
|
+
export const asyncHandler = (fn: any, options?: AsyncHandlerOptions) => {
|
|
1143
|
+
// If no validation options, return simple handler
|
|
1144
|
+
if (!options?.bodySchema && !options?.querySchema) {
|
|
1145
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
1146
|
+
return Promise.resolve(fn(req, res, next)).catch(next);
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Import validation functions dynamically to avoid circular deps at module load
|
|
1151
|
+
const {
|
|
1152
|
+
validateRequestBody,
|
|
1153
|
+
validateQueryParams,
|
|
1154
|
+
getOpenApiValidatorConfig,
|
|
1155
|
+
} = require("./openApiValidator");
|
|
1156
|
+
|
|
1157
|
+
// Build validation middleware
|
|
1158
|
+
const validators: ((req: Request, res: Response, next: NextFunction) => void)[] = [];
|
|
1159
|
+
|
|
1160
|
+
// Determine if validation should be enabled
|
|
1161
|
+
const shouldValidate = options.validate ?? getOpenApiValidatorConfig().validateRequests ?? false;
|
|
1162
|
+
|
|
1163
|
+
if (shouldValidate) {
|
|
1164
|
+
if (options.bodySchema) {
|
|
1165
|
+
validators.push(validateRequestBody(options.bodySchema, {enabled: true}));
|
|
1166
|
+
}
|
|
1167
|
+
if (options.querySchema) {
|
|
1168
|
+
validators.push(validateQueryParams(options.querySchema, {enabled: true}));
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
1173
|
+
// Run validators sequentially, then the handler
|
|
1174
|
+
const runValidators = (index: number): void => {
|
|
1175
|
+
if (index >= validators.length) {
|
|
1176
|
+
// All validators passed, run the actual handler
|
|
1177
|
+
Promise.resolve(fn(req, res, next)).catch(next);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
try {
|
|
1182
|
+
validators[index](req, res, (err?: any) => {
|
|
1183
|
+
if (err) {
|
|
1184
|
+
next(err);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
runValidators(index + 1);
|
|
1188
|
+
});
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
next(err);
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
runValidators(0);
|
|
1195
|
+
};
|
|
990
1196
|
};
|
|
991
1197
|
|
|
992
1198
|
// For backwards compatibility with the old names.
|
package/src/auth.ts
CHANGED
|
@@ -299,7 +299,9 @@ export function addAuthRoutes(
|
|
|
299
299
|
logger.warn(`Invalid login: ${info}`);
|
|
300
300
|
return res.status(401).json({message: info?.message});
|
|
301
301
|
}
|
|
302
|
-
|
|
302
|
+
if (process.env.NODE_ENV !== "test") {
|
|
303
|
+
logger.info(`User logged in: ${user._id}, type: ${(user as any).type || "N/A"}`);
|
|
304
|
+
}
|
|
303
305
|
const tokens = await generateTokens(user, authOptions);
|
|
304
306
|
return res.json({
|
|
305
307
|
data: {refreshToken: tokens.refreshToken, token: tokens.token, userId: user?._id},
|
package/src/errors.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// https://jsonapi.org/format/#errors
|
|
2
|
-
import * as Sentry from "@sentry/
|
|
2
|
+
import * as Sentry from "@sentry/bun";
|
|
3
3
|
import type {NextFunction, Request, Response} from "express";
|
|
4
4
|
import {Schema} from "mongoose";
|
|
5
5
|
|
|
@@ -136,21 +136,24 @@ export class APIError extends Error {
|
|
|
136
136
|
// model.
|
|
137
137
|
export function errorsPlugin(schema: Schema): void {
|
|
138
138
|
const errorSchema = new Schema({
|
|
139
|
-
code: String,
|
|
140
|
-
detail: String,
|
|
141
|
-
id: String,
|
|
139
|
+
code: {description: "Application-specific error code", type: String},
|
|
140
|
+
detail: {description: "Human-readable explanation of the error", type: String},
|
|
141
|
+
id: {description: "Unique identifier for this error occurrence", type: String},
|
|
142
142
|
links: {
|
|
143
|
-
about: String,
|
|
144
|
-
type: String,
|
|
143
|
+
about: {description: "Link to documentation about this error", type: String},
|
|
144
|
+
type: {description: "Link describing the error type", type: String},
|
|
145
145
|
},
|
|
146
|
-
meta: Schema.Types.Mixed,
|
|
146
|
+
meta: {description: "Non-standard meta information about the error", type: Schema.Types.Mixed},
|
|
147
147
|
source: {
|
|
148
|
-
header: String,
|
|
149
|
-
parameter: String,
|
|
150
|
-
pointer:
|
|
148
|
+
header: {description: "HTTP header that caused the error", type: String},
|
|
149
|
+
parameter: {description: "Query parameter that caused the error", type: String},
|
|
150
|
+
pointer: {
|
|
151
|
+
description: "JSON pointer to the request field that caused the error",
|
|
152
|
+
type: String,
|
|
153
|
+
},
|
|
151
154
|
},
|
|
152
|
-
status: Number,
|
|
153
|
-
title: {required: true, type: String},
|
|
155
|
+
status: {description: "HTTP status code for this error", type: Number},
|
|
156
|
+
title: {description: "Short summary of the error", required: true, type: String},
|
|
154
157
|
});
|
|
155
158
|
|
|
156
159
|
schema.add({apiErrors: errorSchema});
|
package/src/example.ts
CHANGED
|
@@ -32,8 +32,8 @@ interface Food {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
const userSchema = new Schema<User>({
|
|
35
|
-
admin: {default: false, type: Boolean},
|
|
36
|
-
username: String,
|
|
35
|
+
admin: {default: false, description: "Whether the user has admin privileges", type: Boolean},
|
|
36
|
+
username: {description: "The user's username", type: String},
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
userSchema.plugin(passportLocalMongoose as any, {usernameField: "email"});
|
|
@@ -42,11 +42,11 @@ userSchema.plugin(baseUserPlugin);
|
|
|
42
42
|
const UserModel = model<User>("User", userSchema);
|
|
43
43
|
|
|
44
44
|
const schema = new Schema<Food>({
|
|
45
|
-
calories: Number,
|
|
46
|
-
created: Date,
|
|
47
|
-
hidden: {default: false, type: Boolean},
|
|
48
|
-
name: String,
|
|
49
|
-
ownerId: {ref: "User", type: "ObjectId"},
|
|
45
|
+
calories: {description: "Number of calories in the food", type: Number},
|
|
46
|
+
created: {description: "When this food was created", type: Date},
|
|
47
|
+
hidden: {default: false, description: "Whether this food is hidden from listings", type: Boolean},
|
|
48
|
+
name: {description: "The name of the food", type: String},
|
|
49
|
+
ownerId: {description: "The user who owns this food entry", ref: "User", type: "ObjectId"},
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
const FoodModel = model<Food>("Food", schema);
|
package/src/expressServer.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as Sentry from "@sentry/
|
|
1
|
+
import * as Sentry from "@sentry/bun";
|
|
2
2
|
import openapi from "@wesleytodd/openapi";
|
|
3
3
|
import cors from "cors";
|
|
4
4
|
import cron from "cron";
|
|
@@ -12,6 +12,7 @@ import qs from "qs";
|
|
|
12
12
|
import type {ModelRouterOptions} from "./api";
|
|
13
13
|
import {addAuthRoutes, addMeRoutes, setupAuth, type UserModel as UserMongooseModel} from "./auth";
|
|
14
14
|
import {apiErrorMiddleware, apiUnauthorizedMiddleware} from "./errors";
|
|
15
|
+
import {addGitHubAuthRoutes, type GitHubAuthOptions, setupGitHubAuth} from "./githubAuth";
|
|
15
16
|
import {type LoggingOptions, logger, setupLogging} from "./logger";
|
|
16
17
|
import {sendToSlack} from "./notifiers";
|
|
17
18
|
import {openApiEtagMiddleware} from "./openApiEtag";
|
|
@@ -168,6 +169,8 @@ interface InitializeRoutesOptions {
|
|
|
168
169
|
logRequests?: boolean;
|
|
169
170
|
loggingOptions?: LoggingOptions;
|
|
170
171
|
authOptions?: AuthOptions;
|
|
172
|
+
/** GitHub OAuth configuration. When provided, enables GitHub authentication. */
|
|
173
|
+
githubAuth?: GitHubAuthOptions;
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
function initializeRoutes(
|
|
@@ -241,6 +244,13 @@ function initializeRoutes(
|
|
|
241
244
|
}
|
|
242
245
|
|
|
243
246
|
addMeRoutes(app, UserModel as any, options?.authOptions);
|
|
247
|
+
|
|
248
|
+
// Set up GitHub OAuth if configured
|
|
249
|
+
if (options.githubAuth) {
|
|
250
|
+
setupGitHubAuth(app, UserModel as any, options.githubAuth);
|
|
251
|
+
addGitHubAuthRoutes(app, UserModel as any, options.githubAuth, options.authOptions);
|
|
252
|
+
}
|
|
253
|
+
|
|
244
254
|
addRoutes(app, {openApi: oapi});
|
|
245
255
|
|
|
246
256
|
Sentry.setupExpressErrorHandler(app);
|
|
@@ -264,6 +274,11 @@ export interface SetupServerOptions {
|
|
|
264
274
|
addRoutes: AddRoutes;
|
|
265
275
|
loggingOptions?: LoggingOptions;
|
|
266
276
|
authOptions?: AuthOptions;
|
|
277
|
+
/**
|
|
278
|
+
* GitHub OAuth configuration. When provided, enables GitHub authentication.
|
|
279
|
+
* Requires the user schema to have GitHub fields (use githubUserPlugin).
|
|
280
|
+
*/
|
|
281
|
+
githubAuth?: GitHubAuthOptions;
|
|
267
282
|
skipListen?: boolean;
|
|
268
283
|
corsOrigin?:
|
|
269
284
|
| string
|
|
@@ -279,7 +294,7 @@ export interface SetupServerOptions {
|
|
|
279
294
|
) => void);
|
|
280
295
|
addMiddleware?: AddRoutes;
|
|
281
296
|
ignoreTraces?: string[];
|
|
282
|
-
sentryOptions?: Sentry.
|
|
297
|
+
sentryOptions?: Sentry.BunOptions;
|
|
283
298
|
}
|
|
284
299
|
|
|
285
300
|
// Sets up the routes and returns a function to launch the API.
|
|
@@ -295,6 +310,7 @@ export function setupServer(options: SetupServerOptions) {
|
|
|
295
310
|
addMiddleware: options.addMiddleware,
|
|
296
311
|
authOptions: options.authOptions,
|
|
297
312
|
corsOrigin: options.corsOrigin,
|
|
313
|
+
githubAuth: options.githubAuth,
|
|
298
314
|
});
|
|
299
315
|
} catch (error: any) {
|
|
300
316
|
logger.error(`Error initializing routes: ${error.stack}`);
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it, setSystemTime} from "bun:test";
|
|
2
|
+
import type express from "express";
|
|
3
|
+
import mongoose, {model, Schema} from "mongoose";
|
|
4
|
+
import passportLocalMongoose from "passport-local-mongoose";
|
|
5
|
+
import supertest from "supertest";
|
|
6
|
+
import type TestAgent from "supertest/lib/agent";
|
|
7
|
+
|
|
8
|
+
import {setupServer} from "./expressServer";
|
|
9
|
+
import {type GitHubUserFields, githubUserPlugin} from "./githubAuth";
|
|
10
|
+
import {logger} from "./logger";
|
|
11
|
+
import {createdUpdatedPlugin, isDisabledPlugin} from "./plugins";
|
|
12
|
+
|
|
13
|
+
interface TestUser extends GitHubUserFields {
|
|
14
|
+
admin: boolean;
|
|
15
|
+
name?: string;
|
|
16
|
+
username: string;
|
|
17
|
+
email: string;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Create schema for GitHub-enabled user
|
|
22
|
+
const testUserSchema = new Schema<TestUser>({
|
|
23
|
+
admin: {default: false, description: "Whether the user has admin privileges", type: Boolean},
|
|
24
|
+
name: {description: "The user's display name", type: String},
|
|
25
|
+
username: {description: "The user's username", type: String},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
testUserSchema.plugin(passportLocalMongoose as any, {
|
|
29
|
+
attemptsField: "attempts",
|
|
30
|
+
interval: 1,
|
|
31
|
+
limitAttempts: true,
|
|
32
|
+
maxAttempts: 3,
|
|
33
|
+
maxInterval: 1,
|
|
34
|
+
usernameCaseInsensitive: true,
|
|
35
|
+
usernameField: "email",
|
|
36
|
+
});
|
|
37
|
+
testUserSchema.plugin(createdUpdatedPlugin);
|
|
38
|
+
testUserSchema.plugin(isDisabledPlugin);
|
|
39
|
+
testUserSchema.plugin(githubUserPlugin);
|
|
40
|
+
|
|
41
|
+
// Get or create model to avoid model redefinition errors
|
|
42
|
+
const GitHubTestUserModel =
|
|
43
|
+
mongoose.models.GitHubTestUser || model<TestUser>("GitHubTestUser", testUserSchema);
|
|
44
|
+
|
|
45
|
+
// Connect to database before tests
|
|
46
|
+
const connectDb = async () => {
|
|
47
|
+
if (mongoose.connection.readyState === 0) {
|
|
48
|
+
await mongoose
|
|
49
|
+
.connect("mongodb://127.0.0.1/terreno?&connectTimeoutMS=360000")
|
|
50
|
+
.catch(logger.catch);
|
|
51
|
+
}
|
|
52
|
+
process.env.REFRESH_TOKEN_SECRET = "refresh_secret";
|
|
53
|
+
process.env.TOKEN_SECRET = "secret";
|
|
54
|
+
process.env.TOKEN_EXPIRES_IN = "30m";
|
|
55
|
+
process.env.TOKEN_ISSUER = "example.com";
|
|
56
|
+
process.env.SESSION_SECRET = "session";
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe("githubUserPlugin", () => {
|
|
60
|
+
it("adds GitHub fields to schema", () => {
|
|
61
|
+
const paths = testUserSchema.paths;
|
|
62
|
+
expect(paths.githubId).toBeDefined();
|
|
63
|
+
expect(paths.githubUsername).toBeDefined();
|
|
64
|
+
expect(paths.githubProfileUrl).toBeDefined();
|
|
65
|
+
expect(paths.githubAvatarUrl).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("githubId is indexed and sparse", () => {
|
|
69
|
+
const githubIdPath = testUserSchema.path("githubId");
|
|
70
|
+
expect((githubIdPath as any).options.index).toBe(true);
|
|
71
|
+
expect((githubIdPath as any).options.sparse).toBe(true);
|
|
72
|
+
expect((githubIdPath as any).options.unique).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("GitHub auth routes", () => {
|
|
77
|
+
let app: express.Application;
|
|
78
|
+
let agent: TestAgent;
|
|
79
|
+
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
setSystemTime();
|
|
82
|
+
await connectDb();
|
|
83
|
+
|
|
84
|
+
await GitHubTestUserModel.deleteMany({});
|
|
85
|
+
|
|
86
|
+
// Create test user with password
|
|
87
|
+
const testUser = await GitHubTestUserModel.create({
|
|
88
|
+
admin: false,
|
|
89
|
+
email: "test@example.com",
|
|
90
|
+
name: "Test User",
|
|
91
|
+
});
|
|
92
|
+
await (testUser as any).setPassword("password123");
|
|
93
|
+
await testUser.save();
|
|
94
|
+
|
|
95
|
+
function addRoutes(router: express.Router): void {
|
|
96
|
+
router.get("/test", (_req, res) => res.json({ok: true}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
app = setupServer({
|
|
100
|
+
addRoutes,
|
|
101
|
+
githubAuth: {
|
|
102
|
+
allowAccountLinking: true,
|
|
103
|
+
callbackURL: "http://localhost:9000/auth/github/callback",
|
|
104
|
+
clientId: "test-client-id",
|
|
105
|
+
clientSecret: "test-client-secret",
|
|
106
|
+
},
|
|
107
|
+
skipListen: true,
|
|
108
|
+
userModel: GitHubTestUserModel as any,
|
|
109
|
+
});
|
|
110
|
+
agent = supertest.agent(app);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(async () => {
|
|
114
|
+
setSystemTime();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("GET /auth/github redirects to GitHub OAuth", async () => {
|
|
118
|
+
const res = await agent.get("/auth/github").expect(302);
|
|
119
|
+
expect(res.headers.location).toContain("github.com");
|
|
120
|
+
expect(res.headers.location).toContain("client_id=test-client-id");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("GET /auth/github/failure returns 401", async () => {
|
|
124
|
+
const res = await agent.get("/auth/github/failure").expect(401);
|
|
125
|
+
expect(res.body.message).toBe("GitHub authentication failed");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("DELETE /auth/github/unlink requires authentication", async () => {
|
|
129
|
+
const res = await agent.delete("/auth/github/unlink").expect(401);
|
|
130
|
+
expect(res.body).toBeDefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("DELETE /auth/github/unlink works when authenticated with password", async () => {
|
|
134
|
+
// Login as test user
|
|
135
|
+
const loginRes = await agent
|
|
136
|
+
.post("/auth/login")
|
|
137
|
+
.send({email: "test@example.com", password: "password123"})
|
|
138
|
+
.expect(200);
|
|
139
|
+
|
|
140
|
+
// Link github to this user
|
|
141
|
+
const user = await GitHubTestUserModel.findOne({email: "test@example.com"});
|
|
142
|
+
if (user) {
|
|
143
|
+
(user as any).githubId = "99999";
|
|
144
|
+
(user as any).githubUsername = "testghuser";
|
|
145
|
+
await user.save();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Unlink
|
|
149
|
+
const res = await agent
|
|
150
|
+
.delete("/auth/github/unlink")
|
|
151
|
+
.set("authorization", `Bearer ${loginRes.body.data.token}`)
|
|
152
|
+
.expect(200);
|
|
153
|
+
|
|
154
|
+
expect(res.body.data.message).toBe("GitHub account unlinked successfully");
|
|
155
|
+
|
|
156
|
+
// Verify github fields are cleared
|
|
157
|
+
const updatedUser = await GitHubTestUserModel.findOne({email: "test@example.com"});
|
|
158
|
+
expect((updatedUser as any).githubId).toBeUndefined();
|
|
159
|
+
expect((updatedUser as any).githubUsername).toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("user can have both password and GitHub auth", async () => {
|
|
163
|
+
const user = await GitHubTestUserModel.findOne({email: "test@example.com"});
|
|
164
|
+
expect(user).toBeDefined();
|
|
165
|
+
if (!user) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Link GitHub
|
|
170
|
+
(user as any).githubId = "88888";
|
|
171
|
+
(user as any).githubUsername = "linkeduser";
|
|
172
|
+
await user.save();
|
|
173
|
+
|
|
174
|
+
// Can still login with password
|
|
175
|
+
const res = await agent
|
|
176
|
+
.post("/auth/login")
|
|
177
|
+
.send({email: "test@example.com", password: "password123"})
|
|
178
|
+
.expect(200);
|
|
179
|
+
|
|
180
|
+
expect(res.body.data.token).toBeDefined();
|
|
181
|
+
|
|
182
|
+
// User has both auth methods - successful login proves password works
|
|
183
|
+
// and we verify GitHub fields are set
|
|
184
|
+
const updatedUser = await GitHubTestUserModel.findOne({email: "test@example.com"});
|
|
185
|
+
expect(updatedUser).toBeDefined();
|
|
186
|
+
expect((updatedUser as any).githubId).toBe("88888");
|
|
187
|
+
expect((updatedUser as any).githubUsername).toBe("linkeduser");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("GitHub auth disabled", () => {
|
|
192
|
+
let app: express.Application;
|
|
193
|
+
let agent: TestAgent;
|
|
194
|
+
|
|
195
|
+
beforeEach(async () => {
|
|
196
|
+
setSystemTime();
|
|
197
|
+
await connectDb();
|
|
198
|
+
|
|
199
|
+
await GitHubTestUserModel.deleteMany({});
|
|
200
|
+
|
|
201
|
+
function addRoutes(router: express.Router): void {
|
|
202
|
+
router.get("/test", (_req, res) => res.json({ok: true}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Setup server WITHOUT GitHub auth
|
|
206
|
+
app = setupServer({
|
|
207
|
+
addRoutes,
|
|
208
|
+
skipListen: true,
|
|
209
|
+
userModel: GitHubTestUserModel as any,
|
|
210
|
+
});
|
|
211
|
+
agent = supertest.agent(app);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
afterEach(async () => {
|
|
215
|
+
setSystemTime();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("GitHub routes are not available when githubAuth is not configured", async () => {
|
|
219
|
+
await agent.get("/auth/github").expect(404);
|
|
220
|
+
await agent.get("/auth/github/callback").expect(404);
|
|
221
|
+
await agent.delete("/auth/github/unlink").expect(404);
|
|
222
|
+
});
|
|
223
|
+
});
|