@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.
Files changed (77) 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 +333 -0
  5. package/README.md +76 -7
  6. package/biome.jsonc +1 -1
  7. package/dist/api.d.ts +68 -1
  8. package/dist/api.js +140 -5
  9. package/dist/api.query.test.js +1 -1
  10. package/dist/api.test.js +222 -484
  11. package/dist/auth.js +3 -1
  12. package/dist/errors.js +15 -12
  13. package/dist/example.js +7 -7
  14. package/dist/expressServer.d.ts +8 -2
  15. package/dist/expressServer.js +8 -1
  16. package/dist/githubAuth.d.ts +64 -0
  17. package/dist/githubAuth.js +293 -0
  18. package/dist/githubAuth.test.d.ts +1 -0
  19. package/dist/githubAuth.test.js +351 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +3 -0
  22. package/dist/logger.js +1 -1
  23. package/dist/middleware.js +1 -1
  24. package/dist/notifiers/googleChatNotifier.js +1 -1
  25. package/dist/notifiers/googleChatNotifier.test.js +1 -1
  26. package/dist/notifiers/slackNotifier.js +1 -1
  27. package/dist/notifiers/slackNotifier.test.js +1 -1
  28. package/dist/notifiers/zoomNotifier.js +1 -1
  29. package/dist/notifiers/zoomNotifier.test.js +1 -1
  30. package/dist/openApi.test.js +8 -5
  31. package/dist/openApiBuilder.d.ts +69 -1
  32. package/dist/openApiBuilder.js +109 -5
  33. package/dist/openApiValidator.d.ts +296 -0
  34. package/dist/openApiValidator.js +698 -0
  35. package/dist/openApiValidator.test.d.ts +1 -0
  36. package/dist/openApiValidator.test.js +346 -0
  37. package/dist/permissions.js +1 -1
  38. package/dist/plugins.test.js +3 -3
  39. package/dist/terrenoPlugin.d.ts +4 -0
  40. package/dist/terrenoPlugin.js +2 -0
  41. package/dist/tests/bunSetup.js +2 -2
  42. package/dist/tests.js +34 -24
  43. package/package.json +7 -2
  44. package/src/__snapshots__/openApi.test.ts.snap +399 -0
  45. package/src/__snapshots__/openApiBuilder.test.ts.snap +108 -0
  46. package/src/api.query.test.ts +1 -1
  47. package/src/api.test.ts +161 -374
  48. package/src/api.ts +210 -4
  49. package/src/auth.ts +3 -1
  50. package/src/errors.ts +15 -12
  51. package/src/example.ts +7 -7
  52. package/src/expressServer.ts +18 -2
  53. package/src/githubAuth.test.ts +223 -0
  54. package/src/githubAuth.ts +335 -0
  55. package/src/index.ts +3 -0
  56. package/src/logger.ts +1 -1
  57. package/src/middleware.ts +1 -1
  58. package/src/notifiers/googleChatNotifier.test.ts +1 -1
  59. package/src/notifiers/googleChatNotifier.ts +1 -1
  60. package/src/notifiers/slackNotifier.test.ts +1 -1
  61. package/src/notifiers/slackNotifier.ts +1 -1
  62. package/src/notifiers/zoomNotifier.test.ts +1 -1
  63. package/src/notifiers/zoomNotifier.ts +1 -1
  64. package/src/openApi.test.ts +8 -5
  65. package/src/openApiBuilder.ts +188 -15
  66. package/src/openApiValidator.test.ts +241 -0
  67. package/src/openApiValidator.ts +860 -0
  68. package/src/permissions.ts +1 -1
  69. package/src/plugins.test.ts +3 -3
  70. package/src/terrenoPlugin.ts +5 -0
  71. package/src/tests/bunSetup.ts +2 -2
  72. package/src/tests.ts +34 -24
  73. package/CLAUDE.md +0 -107
  74. package/dist/response.d.ts +0 -0
  75. package/dist/response.js +0 -1
  76. package/index.ts +0 -1
  77. 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/node";
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
- // Since express doesn't handle async routes well, wrap them with this function.
988
- export const asyncHandler = (fn: any) => (req: Request, res: Response, next: NextFunction) => {
989
- return Promise.resolve(fn(req, res, next)).catch(next);
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
- logger.info(`User logged in: ${user._id}, type: ${(user as any).type || "N/A"}`);
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/node";
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: String,
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);
@@ -1,4 +1,4 @@
1
- import * as Sentry from "@sentry/node";
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.NodeOptions;
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
+ });