@oneuptime/common 12.0.7 → 12.0.8

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 (186) hide show
  1. package/Models/DatabaseModels/TelemetryEntity.ts +45 -0
  2. package/Models/DatabaseModels/TelemetryEntityRelationship.ts +26 -0
  3. package/Models/DatabaseModels/WorkflowLog.ts +38 -0
  4. package/Server/API/UserAPI.ts +16 -1
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.ts +41 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.ts +17 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  8. package/Server/Middleware/MasterAdminAuthorization.ts +11 -6
  9. package/Server/Services/TelemetryEntityRelationshipService.ts +3 -0
  10. package/Server/Services/TelemetryEntityService.ts +114 -1
  11. package/Server/Types/Workflow/Components/API/Utils.ts +44 -1
  12. package/Server/Types/Workflow/TriggerCode.ts +12 -0
  13. package/Server/Types/Workflow/Workflow.ts +5 -0
  14. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +689 -0
  15. package/Server/Utils/VM/VMAPI.ts +56 -8
  16. package/Tests/App/Dashboard/MonitorCriteriaAttributeFilter.test.tsx +574 -0
  17. package/Tests/Server/API/UserProjectsAPI.test.ts +478 -4
  18. package/Tests/Server/Services/TelemetryEntityManualCreate.test.ts +248 -0
  19. package/Tests/Server/Types/Workflow/Components/ApiComponentHeaders.test.ts +192 -0
  20. package/Tests/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.test.ts +152 -0
  21. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +322 -0
  22. package/Tests/Server/Utils/VM/VMAPISubstitution.test.ts +243 -0
  23. package/Tests/Types/SerializableObjectDictionaryImportCycle.test.ts +197 -0
  24. package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +140 -0
  25. package/Tests/Types/Workflow/StepTrace.test.ts +235 -0
  26. package/Tests/Types/Workflow/TemplateSyntax.test.ts +379 -0
  27. package/Tests/Types/Workflow/Templates.test.ts +403 -0
  28. package/Tests/UI/Components/CustomTimeRangeModal.test.tsx +459 -0
  29. package/Tests/UI/Components/DictionaryAttributeFilterRow.test.tsx +477 -0
  30. package/Tests/UI/Components/Forms/AnchoredFieldPopupKeyboard.test.tsx +382 -0
  31. package/Tests/UI/Components/Forms/ColorPickerPicking.test.tsx +569 -0
  32. package/Tests/UI/Components/Forms/ValidationJSON.test.ts +241 -0
  33. package/Tests/UI/Components/LogTimeRangePicker.test.tsx +42 -0
  34. package/Tests/UI/Components/ModalPortalDismissal.test.tsx +90 -0
  35. package/Tests/UI/Components/TelemetryTimeRangePicker.test.tsx +49 -7
  36. package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +325 -0
  37. package/Tests/UI/Components/Workflow/GraphLint.test.ts +821 -0
  38. package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +287 -0
  39. package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +185 -0
  40. package/Tests/UI/Components/Workflow/ModelSchema.test.ts +174 -0
  41. package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +152 -0
  42. package/Tests/UI/Components/Workflow/Utils.test.ts +192 -0
  43. package/Tests/UI/Monitor/MonitorStepCriteriaView.test.tsx +432 -0
  44. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +514 -0
  45. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +240 -0
  46. package/Tests/Utils/Schema/AnalyticsModelSchema.test.ts +469 -0
  47. package/Tests/Utils/Schema/ModelSchema.test.ts +268 -0
  48. package/Tests/Utils/Telemetry/EntityKeyNonTelemetry.test.ts +193 -0
  49. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +31 -0
  50. package/Types/SerializableObjectDictionary.ts +133 -39
  51. package/Types/Telemetry/EntitySource.ts +40 -0
  52. package/Types/Telemetry/EntityType.ts +27 -0
  53. package/Types/Telemetry/EntityTypeGroups.ts +65 -0
  54. package/Types/Workflow/Component.ts +29 -0
  55. package/Types/Workflow/Components/API.ts +35 -0
  56. package/Types/Workflow/Components/BaseModel.ts +7 -3
  57. package/Types/Workflow/StepTrace.ts +181 -0
  58. package/Types/Workflow/TemplateSyntax.ts +499 -0
  59. package/Types/Workflow/Templates.ts +316 -0
  60. package/UI/Components/Date/CustomTimeRangeModal.tsx +278 -0
  61. package/UI/Components/Date/TimeRangePickerDropdown.tsx +250 -0
  62. package/UI/Components/Dictionary/Dictionary.tsx +23 -6
  63. package/UI/Components/Forms/Fields/ColorPicker.tsx +66 -10
  64. package/UI/Components/Forms/Fields/IconPicker.tsx +48 -10
  65. package/UI/Components/Forms/Types/Field.ts +7 -0
  66. package/UI/Components/Forms/Validation.ts +63 -1
  67. package/UI/Components/Input/Input.tsx +31 -3
  68. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +11 -216
  69. package/UI/Components/Modal/Modal.tsx +37 -4
  70. package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +11 -210
  71. package/UI/Components/Workflow/ArgumentsForm.tsx +194 -7
  72. package/UI/Components/Workflow/Component.tsx +28 -26
  73. package/UI/Components/Workflow/ComponentSettingsModal.tsx +69 -8
  74. package/UI/Components/Workflow/GraphLint.ts +591 -0
  75. package/UI/Components/Workflow/ModelColumnEditor.tsx +396 -0
  76. package/UI/Components/Workflow/ModelSchema.ts +196 -0
  77. package/UI/Components/Workflow/RunForm.tsx +41 -7
  78. package/UI/Components/Workflow/RunStatusWatcher.ts +122 -0
  79. package/UI/Components/Workflow/StepTraceViewer.tsx +186 -0
  80. package/UI/Components/Workflow/Utils.ts +93 -1
  81. package/UI/Components/Workflow/Workflow.tsx +77 -4
  82. package/UI/Types/LayeredDismissal.ts +31 -0
  83. package/UI/Types/UseAnchoredFieldPopup.ts +99 -0
  84. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +533 -0
  85. package/Utils/Monitor/NetworkTopologyUtil.ts +45 -7
  86. package/Utils/Telemetry/EntityKey.ts +66 -0
  87. package/build/dist/Models/DatabaseModels/TelemetryEntity.js +47 -0
  88. package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +1 -1
  89. package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js +27 -0
  90. package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +1 -1
  91. package/build/dist/Models/DatabaseModels/WorkflowLog.js +39 -0
  92. package/build/dist/Models/DatabaseModels/WorkflowLog.js.map +1 -1
  93. package/build/dist/Server/API/UserAPI.js +16 -1
  94. package/build/dist/Server/API/UserAPI.js.map +1 -1
  95. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js +20 -0
  96. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js.map +1 -0
  97. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js +12 -0
  98. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js.map +1 -0
  99. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  100. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  101. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +11 -6
  102. package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
  103. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js +3 -0
  104. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +1 -1
  105. package/build/dist/Server/Services/TelemetryEntityService.js +91 -1
  106. package/build/dist/Server/Services/TelemetryEntityService.js.map +1 -1
  107. package/build/dist/Server/Types/Workflow/Components/API/Utils.js +28 -0
  108. package/build/dist/Server/Types/Workflow/Components/API/Utils.js.map +1 -1
  109. package/build/dist/Server/Types/Workflow/TriggerCode.js.map +1 -1
  110. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +507 -0
  111. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -0
  112. package/build/dist/Server/Utils/VM/VMAPI.js +51 -4
  113. package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
  114. package/build/dist/Types/SerializableObjectDictionary.js +133 -39
  115. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  116. package/build/dist/Types/Telemetry/EntitySource.js +39 -0
  117. package/build/dist/Types/Telemetry/EntitySource.js.map +1 -0
  118. package/build/dist/Types/Telemetry/EntityType.js +27 -0
  119. package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
  120. package/build/dist/Types/Telemetry/EntityTypeGroups.js +57 -0
  121. package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -0
  122. package/build/dist/Types/Workflow/Component.js +21 -0
  123. package/build/dist/Types/Workflow/Component.js.map +1 -1
  124. package/build/dist/Types/Workflow/Components/API.js +35 -0
  125. package/build/dist/Types/Workflow/Components/API.js.map +1 -1
  126. package/build/dist/Types/Workflow/Components/BaseModel.js +7 -3
  127. package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
  128. package/build/dist/Types/Workflow/StepTrace.js +104 -0
  129. package/build/dist/Types/Workflow/StepTrace.js.map +1 -0
  130. package/build/dist/Types/Workflow/TemplateSyntax.js +326 -0
  131. package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -0
  132. package/build/dist/Types/Workflow/Templates.js +218 -0
  133. package/build/dist/Types/Workflow/Templates.js.map +1 -0
  134. package/build/dist/UI/Components/Date/CustomTimeRangeModal.js +126 -0
  135. package/build/dist/UI/Components/Date/CustomTimeRangeModal.js.map +1 -0
  136. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +123 -0
  137. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -0
  138. package/build/dist/UI/Components/Dictionary/Dictionary.js +22 -6
  139. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  140. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js +52 -13
  141. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js.map +1 -1
  142. package/build/dist/UI/Components/Forms/Fields/IconPicker.js +28 -12
  143. package/build/dist/UI/Components/Forms/Fields/IconPicker.js.map +1 -1
  144. package/build/dist/UI/Components/Forms/Validation.js +44 -0
  145. package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
  146. package/build/dist/UI/Components/Input/Input.js +12 -4
  147. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  148. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +5 -108
  149. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
  150. package/build/dist/UI/Components/Modal/Modal.js +31 -5
  151. package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
  152. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +5 -105
  153. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
  154. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +140 -11
  155. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  156. package/build/dist/UI/Components/Workflow/Component.js +20 -19
  157. package/build/dist/UI/Components/Workflow/Component.js.map +1 -1
  158. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +31 -3
  159. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
  160. package/build/dist/UI/Components/Workflow/GraphLint.js +396 -0
  161. package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -0
  162. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +225 -0
  163. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -0
  164. package/build/dist/UI/Components/Workflow/ModelSchema.js +118 -0
  165. package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -0
  166. package/build/dist/UI/Components/Workflow/RunForm.js +22 -6
  167. package/build/dist/UI/Components/Workflow/RunForm.js.map +1 -1
  168. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +76 -0
  169. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -0
  170. package/build/dist/UI/Components/Workflow/StepTraceViewer.js +76 -0
  171. package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -0
  172. package/build/dist/UI/Components/Workflow/Utils.js +73 -1
  173. package/build/dist/UI/Components/Workflow/Utils.js.map +1 -1
  174. package/build/dist/UI/Components/Workflow/Workflow.js +51 -6
  175. package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
  176. package/build/dist/UI/Types/LayeredDismissal.js +21 -0
  177. package/build/dist/UI/Types/LayeredDismissal.js.map +1 -0
  178. package/build/dist/UI/Types/UseAnchoredFieldPopup.js +74 -1
  179. package/build/dist/UI/Types/UseAnchoredFieldPopup.js.map +1 -1
  180. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +386 -0
  181. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -0
  182. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +35 -6
  183. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  184. package/build/dist/Utils/Telemetry/EntityKey.js +52 -0
  185. package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
  186. package/package.json +1 -1
@@ -1,7 +1,10 @@
1
1
  import CommonAPI from "../../../Server/API/CommonAPI";
2
2
  import UserAPI from "../../../Server/API/UserAPI";
3
3
  import MasterAdminAuthorization from "../../../Server/Middleware/MasterAdminAuthorization";
4
+ import ProjectMiddleware from "../../../Server/Middleware/ProjectAuthorization";
5
+ import UserMiddleware from "../../../Server/Middleware/UserAuthorization";
4
6
  import TeamMemberService from "../../../Server/Services/TeamMemberService";
7
+ import JSONWebToken from "../../../Server/Utils/JsonWebToken";
5
8
  import {
6
9
  ExpressRequest,
7
10
  ExpressResponse,
@@ -15,7 +18,9 @@ import TeamMember from "../../../Models/DatabaseModels/TeamMember";
15
18
  import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
16
19
  import { DEFAULT_LIMIT } from "../../../Types/Database/LimitMax";
17
20
  import Dictionary from "../../../Types/Dictionary";
21
+ import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
18
22
  import { JSONObject } from "../../../Types/JSON";
23
+ import JSONWebTokenData from "../../../Types/JsonWebTokenData";
19
24
  import ObjectID from "../../../Types/ObjectID";
20
25
  import PositiveNumber from "../../../Types/PositiveNumber";
21
26
  import UserType from "../../../Types/UserType";
@@ -47,10 +52,25 @@ import {
47
52
  * (so the Owners-team guard runs before anything is deleted) with the caller's
48
53
  * own props rather than root.
49
54
  *
50
- * Both are guarded by the master-admin middleware, which is the only thing
55
+ * Both are guarded by a master-admin middleware, which is the only thing
51
56
  * standing between "list one user's projects" and "read across every tenant on
52
57
  * the instance". That the routes carry it is asserted directly, because
53
58
  * nothing else in these tests would notice its absence.
59
+ *
60
+ * The two carry DIFFERENT master-admin middlewares, and which one each carries
61
+ * is the security boundary of this file:
62
+ *
63
+ * - /projects reads, so it takes
64
+ * isAuthorizedMasterAdminOrMasterApiKeyMiddleware — a master-admin session
65
+ * OR the instance-wide master API key, so scripts can call it.
66
+ * - /remove-from-project writes, so it stays on
67
+ * isAuthorizedMasterAdminMiddleware — session only. A leaked static key must
68
+ * not be able to strip somebody's project access headlessly.
69
+ *
70
+ * A find/replace that "helpfully" widened the second one would leave every
71
+ * assertion about grouping, paging and deleting green, so the middlewares are
72
+ * asserted by identity, and the whole UserAPI route surface is swept to prove
73
+ * the read is the ONLY route the key can reach.
54
74
  */
55
75
 
56
76
  jest.mock("../../../Server/Utils/Express", () => {
@@ -78,10 +98,17 @@ jest.mock("../../../Server/Utils/Response", () => {
78
98
  const PROJECTS_ROUTE: string = "/user/:userId/projects";
79
99
  const REMOVE_ROUTE: string = "/user/:userId/remove-from-project";
80
100
 
101
+ const AUTH_STATUS_ROUTE: string = "/user/:userId/authentication-status";
102
+ const SET_PASSWORD_ROUTE: string = "/user/:userId/set-password";
103
+ const RESET_LINK_ROUTE: string = "/user/:userId/send-password-reset-link";
104
+
81
105
  const USER_ID: string = "00000000-0000-4000-8000-000000000001";
82
106
  const PROJECT_ONE_ID: string = "00000000-0000-4000-8000-0000000000a1";
83
107
  const PROJECT_TWO_ID: string = "00000000-0000-4000-8000-0000000000a2";
84
108
 
109
+ const MASTER_API_KEY: string = "8e1a3a52-6d64-4f1f-9a2e-5f0f9c1d2b34";
110
+ const OTHER_API_KEY: string = "1c9d7f40-2b83-4a55-8e70-6d4b9a0c3e12";
111
+
85
112
  type MembershipSpec = {
86
113
  id: string;
87
114
  projectId: string;
@@ -206,9 +233,9 @@ describe("POST /user/:userId/projects", () => {
206
233
  }).not.toThrow();
207
234
  });
208
235
 
209
- test("only a master admin can reach it", () => {
236
+ test("only a master admin — by session or by master API key — can reach it", () => {
210
237
  expect(mockRouter.match("POST", PROJECTS_ROUTE).middleware).toBe(
211
- MasterAdminAuthorization.isAuthorizedMasterAdminMiddleware,
238
+ MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware,
212
239
  );
213
240
  });
214
241
 
@@ -706,10 +733,19 @@ describe("POST /user/:userId/remove-from-project", () => {
706
733
  }).not.toThrow();
707
734
  });
708
735
 
709
- test("only a master admin can reach it", () => {
736
+ /*
737
+ * Session only — deliberately NOT the middleware its read-only sibling
738
+ * carries. This route deletes, and the master API key is a static credential
739
+ * that lives in a config table and in whatever script holds it.
740
+ */
741
+ test("only a master admin SESSION can reach it, never the master API key", () => {
710
742
  expect(mockRouter.match("POST", REMOVE_ROUTE).middleware).toBe(
711
743
  MasterAdminAuthorization.isAuthorizedMasterAdminMiddleware,
712
744
  );
745
+
746
+ expect(mockRouter.match("POST", REMOVE_ROUTE).middleware).not.toBe(
747
+ MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware,
748
+ );
713
749
  });
714
750
 
715
751
  test("removes every membership in ONE delete, scoped to the user and project", async () => {
@@ -850,3 +886,441 @@ describe("POST /user/:userId/remove-from-project", () => {
850
886
  expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
851
887
  });
852
888
  });
889
+
890
+ /*
891
+ * The gate and the handler together, driven the way a real request arrives:
892
+ * through the route's middleware first, and only into the handler if the
893
+ * middleware lets it past.
894
+ *
895
+ * The describes above call handlerFunction directly, which is the right way to
896
+ * test grouping and paging but says nothing about who may call it — and this is
897
+ * a route that reads across EVERY tenant on the instance. Two failure modes
898
+ * live in the gap and neither is visible from either side alone:
899
+ *
900
+ * - a caller who should be refused reaching the read anyway. Asserted as
901
+ * "TeamMemberService.findBy was never called", not merely as "an error was
902
+ * returned": returning 401 after already reading every membership on the
903
+ * instance would satisfy the weaker assertion.
904
+ * - the master-key path authorizing but the handler then failing, because the
905
+ * key branch calls next() WITHOUT setting userAuthorization, userType or
906
+ * tenantId on the request. A handler that read any of them would work under
907
+ * a session and break under a key, and no unit test that hand-builds a
908
+ * request would notice.
909
+ */
910
+ describe("POST /user/:userId/projects — the master API key path", () => {
911
+ let findBySpy: jest.SpyInstance;
912
+
913
+ type MiddlewareCallResult = {
914
+ // Whether the gate let the request through to the read.
915
+ reachedHandler: boolean;
916
+ errorSentToClient: NotAuthorizedException | undefined;
917
+ // The request object as the handler saw it, to inspect what the gate set.
918
+ request: ExpressRequest;
919
+ };
920
+
921
+ async function callThroughMiddleware(data: {
922
+ headers: Dictionary<string | Array<string> | undefined>;
923
+ params?: Dictionary<string> | undefined;
924
+ query?: Dictionary<string> | undefined;
925
+ }): Promise<MiddlewareCallResult> {
926
+ const req: ExpressRequest = {
927
+ params: data.params || { userId: USER_ID },
928
+ query: data.query || {},
929
+ body: {},
930
+ headers: data.headers,
931
+ } as unknown as ExpressRequest;
932
+
933
+ const res: ExpressResponse = {
934
+ send: jest.fn(),
935
+ json: jest.fn(),
936
+ status: jest.fn().mockReturnThis(),
937
+ } as unknown as ExpressResponse;
938
+
939
+ const route: {
940
+ middleware: (
941
+ req: ExpressRequest,
942
+ res: ExpressResponse,
943
+ next: NextFunction,
944
+ ) => void | Promise<void>;
945
+ handlerFunction: (
946
+ req: ExpressRequest,
947
+ res: ExpressResponse,
948
+ next: NextFunction,
949
+ ) => void | Promise<void>;
950
+ } = mockRouter.match("POST", PROJECTS_ROUTE);
951
+
952
+ const middlewareNext: jest.Mock = jest.fn();
953
+
954
+ await route.middleware(req, res, middlewareNext as unknown as NextFunction);
955
+
956
+ const reachedHandler: boolean = middlewareNext.mock.calls.length > 0;
957
+
958
+ if (reachedHandler) {
959
+ await route.handlerFunction(
960
+ req,
961
+ res,
962
+ jest.fn() as unknown as NextFunction,
963
+ );
964
+ }
965
+
966
+ const errorCall: Array<unknown> | undefined = (
967
+ Response.sendErrorResponse as unknown as {
968
+ mock: { calls: Array<Array<unknown>> };
969
+ }
970
+ ).mock.calls[0];
971
+
972
+ return {
973
+ reachedHandler: reachedHandler,
974
+ errorSentToClient: errorCall?.[2] as NotAuthorizedException | undefined,
975
+ request: req,
976
+ };
977
+ }
978
+
979
+ beforeAll(() => {
980
+ mockRouter.routes.length = 0;
981
+ new UserAPI();
982
+ });
983
+
984
+ beforeEach(() => {
985
+ jest.clearAllMocks();
986
+
987
+ findBySpy = jest.spyOn(TeamMemberService, "findBy").mockResolvedValue([
988
+ buildMembership({
989
+ id: "m1",
990
+ projectId: PROJECT_ONE_ID,
991
+ projectName: "Acme Production",
992
+ teamId: "team-owners",
993
+ teamName: "Owners",
994
+ }),
995
+ buildMembership({
996
+ id: "m2",
997
+ projectId: PROJECT_ONE_ID,
998
+ projectName: "Acme Production",
999
+ teamId: "team-eng",
1000
+ teamName: "Engineering",
1001
+ }),
1002
+ buildMembership({
1003
+ id: "m3",
1004
+ projectId: PROJECT_TWO_ID,
1005
+ projectName: "Beta Staging",
1006
+ teamId: "team-ops",
1007
+ teamName: "Ops",
1008
+ }),
1009
+ ]);
1010
+
1011
+ /*
1012
+ * Stand in for the GlobalConfig lookup — only this one key is live. Spying
1013
+ * here rather than on getApiKey keeps the real header parsing and the real
1014
+ * UUID guard in the test.
1015
+ */
1016
+ jest.spyOn(ProjectMiddleware, "isMasterApiKey").mockImplementation(((
1017
+ apiKey: ObjectID,
1018
+ ): Promise<boolean> => {
1019
+ return Promise.resolve(apiKey.toString() === MASTER_API_KEY);
1020
+ }) as never);
1021
+ });
1022
+
1023
+ afterEach(() => {
1024
+ jest.restoreAllMocks();
1025
+ });
1026
+
1027
+ describe("when the master API key is presented", () => {
1028
+ test("a request carrying nothing but the key gets the grouped list", async () => {
1029
+ const result: MiddlewareCallResult = await callThroughMiddleware({
1030
+ headers: { apikey: MASTER_API_KEY },
1031
+ });
1032
+
1033
+ expect(result.reachedHandler).toBe(true);
1034
+ expect(Response.sendErrorResponse).not.toHaveBeenCalled();
1035
+ // Two projects out of three memberships — the grouping still happens.
1036
+ expect(projectNamesSent()).toEqual(["Acme Production", "Beta Staging"]);
1037
+ expect(sentCount()).toBe(2);
1038
+ });
1039
+
1040
+ /*
1041
+ * The handler must not depend on anything the key branch leaves unset. If
1042
+ * this ever fails, the route works for the Admin Dashboard and 500s for
1043
+ * every script — the exact split a route-registration test cannot see.
1044
+ */
1045
+ test("works even though the key branch sets no session state on the request", async () => {
1046
+ const result: MiddlewareCallResult = await callThroughMiddleware({
1047
+ headers: { apikey: MASTER_API_KEY },
1048
+ });
1049
+
1050
+ const request: Dictionary<unknown> =
1051
+ result.request as unknown as Dictionary<unknown>;
1052
+
1053
+ expect(request["userAuthorization"]).toBeUndefined();
1054
+ expect(request["userType"]).toBeUndefined();
1055
+ expect(request["tenantId"]).toBeUndefined();
1056
+
1057
+ // ...and the read happened anyway.
1058
+ expect(findBySpy).toHaveBeenCalledTimes(1);
1059
+ expect(Response.sendJsonArrayResponse).toHaveBeenCalledTimes(1);
1060
+ });
1061
+
1062
+ test("still reads as root, so it can see projects the key's user is not in", async () => {
1063
+ await callThroughMiddleware({ headers: { apikey: MASTER_API_KEY } });
1064
+
1065
+ const props: DatabaseCommonInteractionProps = (
1066
+ findBySpy.mock.calls[0]![0] as { props: DatabaseCommonInteractionProps }
1067
+ ).props;
1068
+
1069
+ expect(props.isRoot).toBe(true);
1070
+ });
1071
+
1072
+ test("pages over projects on the key path too", async () => {
1073
+ await callThroughMiddleware({
1074
+ headers: { apikey: MASTER_API_KEY },
1075
+ query: { skip: "1", limit: "1" },
1076
+ });
1077
+
1078
+ expect(projectNamesSent()).toEqual(["Beta Staging"]);
1079
+ // count stays the size of the whole grouped list, not of the page.
1080
+ expect(sentCount()).toBe(2);
1081
+ });
1082
+
1083
+ /*
1084
+ * A key caller has no tenant, and this endpoint must not acquire one from a
1085
+ * stray header: the handler passes literal `{ isRoot: true }` props, so
1086
+ * there is no props.tenantId for the root tenant-scoping to apply. Worth
1087
+ * pinning because "send projectid with the key" is a natural thing for a
1088
+ * caller to try, and silently getting one project's worth of an answer
1089
+ * would be worse than an error.
1090
+ */
1091
+ test("a projectid header does not narrow the read", async () => {
1092
+ await callThroughMiddleware({
1093
+ headers: { apikey: MASTER_API_KEY, projectid: PROJECT_ONE_ID },
1094
+ });
1095
+
1096
+ const findBy: { query: Dictionary<unknown> } = findBySpy.mock
1097
+ .calls[0]![0] as { query: Dictionary<unknown> };
1098
+
1099
+ expect(Object.keys(findBy.query)).toEqual(["userId"]);
1100
+ expect(projectNamesSent()).toEqual(["Acme Production", "Beta Staging"]);
1101
+ });
1102
+
1103
+ test("validates the user id before reading, key or no key", async () => {
1104
+ const result: MiddlewareCallResult = await callThroughMiddleware({
1105
+ headers: { apikey: MASTER_API_KEY },
1106
+ params: { userId: "not-a-uuid" },
1107
+ });
1108
+
1109
+ expect(result.reachedHandler).toBe(true);
1110
+ expect(findBySpy).not.toHaveBeenCalled();
1111
+ });
1112
+ });
1113
+
1114
+ describe("when the credential cannot authorize the request", () => {
1115
+ /*
1116
+ * Every one of these must stop AT the gate. The assertion that matters is
1117
+ * findBy — a refusal issued after the cross-tenant read has already run is
1118
+ * not a refusal.
1119
+ */
1120
+ const unusableCredentials: Array<[string, string | Array<string>]> = [
1121
+ ["a well-formed key that is not the master key", OTHER_API_KEY],
1122
+ ["an empty apikey header", ""],
1123
+ ["a whitespace-only apikey header", " "],
1124
+ ["a non-UUID apikey header", "not-a-uuid"],
1125
+ ["a truncated UUID", "8e1a3a52-6d64-4f1f-9a2e"],
1126
+ [
1127
+ "a UUID with non-hex characters",
1128
+ "8e1a3a52-6d64-4f1f-9a2e-5f0f9c1d2bZZ",
1129
+ ],
1130
+ ["a SQL injection attempt", "' OR 1=1 --"],
1131
+ ["duplicate apikey headers", [MASTER_API_KEY, OTHER_API_KEY]],
1132
+ ];
1133
+
1134
+ test.each(unusableCredentials)(
1135
+ "refuses %s without reading anything",
1136
+ async (_label: string, apiKeyHeader: string | Array<string>) => {
1137
+ const result: MiddlewareCallResult = await callThroughMiddleware({
1138
+ headers: { apikey: apiKeyHeader },
1139
+ });
1140
+
1141
+ expect(result.reachedHandler).toBe(false);
1142
+ expect(findBySpy).not.toHaveBeenCalled();
1143
+ expect(Response.sendJsonArrayResponse).not.toHaveBeenCalled();
1144
+
1145
+ expect(result.errorSentToClient).toBeInstanceOf(NotAuthorizedException);
1146
+ expect(result.errorSentToClient?.message).toBe(
1147
+ "Unauthorized: Access token is required.",
1148
+ );
1149
+ },
1150
+ );
1151
+
1152
+ test("refuses a request with no credential at all", async () => {
1153
+ const result: MiddlewareCallResult = await callThroughMiddleware({
1154
+ headers: {},
1155
+ });
1156
+
1157
+ expect(result.reachedHandler).toBe(false);
1158
+ expect(findBySpy).not.toHaveBeenCalled();
1159
+ });
1160
+
1161
+ /*
1162
+ * Fail closed. A GlobalConfig lookup that throws must not be read as "the
1163
+ * key checked out"; the middleware swallows it and falls through to the
1164
+ * session check, which has nothing to work with here.
1165
+ */
1166
+ test("refuses when the key lookup itself throws", async () => {
1167
+ jest
1168
+ .spyOn(ProjectMiddleware, "isMasterApiKey")
1169
+ .mockRejectedValue(new Error("database is down") as never);
1170
+
1171
+ const result: MiddlewareCallResult = await callThroughMiddleware({
1172
+ headers: { apikey: MASTER_API_KEY },
1173
+ });
1174
+
1175
+ expect(result.reachedHandler).toBe(false);
1176
+ expect(findBySpy).not.toHaveBeenCalled();
1177
+ });
1178
+
1179
+ /*
1180
+ * The UUID guard has to run BEFORE the lookup: masterApiKey is a Postgres
1181
+ * uuid column, and a non-UUID raises 22P02 as a raw QueryFailedError, which
1182
+ * is not a OneUptime Exception and so escapes the error translator as a 500.
1183
+ */
1184
+ test("never reaches the key lookup with a malformed value", async () => {
1185
+ await callThroughMiddleware({ headers: { apikey: "garbage" } });
1186
+
1187
+ expect(ProjectMiddleware.isMasterApiKey).not.toHaveBeenCalled();
1188
+ });
1189
+ });
1190
+
1191
+ describe("session access is unchanged by the widening", () => {
1192
+ /*
1193
+ * The Admin Dashboard reaches this route with a cookie session and no key.
1194
+ * Accepting the key must not have cost it that.
1195
+ */
1196
+ test("a master admin session still gets the grouped list", async () => {
1197
+ jest
1198
+ .spyOn(UserMiddleware, "getAccessTokenFromExpressRequest")
1199
+ .mockReturnValue("a.master.admin.token" as never);
1200
+
1201
+ jest.spyOn(JSONWebToken, "decode").mockReturnValue({
1202
+ isMasterAdmin: true,
1203
+ } as unknown as JSONWebTokenData as never);
1204
+
1205
+ const result: MiddlewareCallResult = await callThroughMiddleware({
1206
+ headers: {},
1207
+ });
1208
+
1209
+ expect(result.reachedHandler).toBe(true);
1210
+ expect(projectNamesSent()).toEqual(["Acme Production", "Beta Staging"]);
1211
+ });
1212
+
1213
+ test("an ordinary user session is still refused", async () => {
1214
+ jest
1215
+ .spyOn(UserMiddleware, "getAccessTokenFromExpressRequest")
1216
+ .mockReturnValue("an.ordinary.user.token" as never);
1217
+
1218
+ jest.spyOn(JSONWebToken, "decode").mockReturnValue({
1219
+ isMasterAdmin: false,
1220
+ } as unknown as JSONWebTokenData as never);
1221
+
1222
+ const result: MiddlewareCallResult = await callThroughMiddleware({
1223
+ headers: {},
1224
+ });
1225
+
1226
+ expect(result.reachedHandler).toBe(false);
1227
+ expect(findBySpy).not.toHaveBeenCalled();
1228
+ expect(result.errorSentToClient?.message).toBe(
1229
+ "Unauthorized: Only master admins can perform this action.",
1230
+ );
1231
+ });
1232
+
1233
+ /*
1234
+ * An unusable key must not cost a caller their valid session. This
1235
+ * middleware falls through rather than failing fast, unlike the
1236
+ * presence-based routing in UserAuthorization.
1237
+ */
1238
+ test("an unusable key alongside a master admin session still passes", async () => {
1239
+ jest
1240
+ .spyOn(UserMiddleware, "getAccessTokenFromExpressRequest")
1241
+ .mockReturnValue("a.master.admin.token" as never);
1242
+
1243
+ jest.spyOn(JSONWebToken, "decode").mockReturnValue({
1244
+ isMasterAdmin: true,
1245
+ } as unknown as JSONWebTokenData as never);
1246
+
1247
+ const result: MiddlewareCallResult = await callThroughMiddleware({
1248
+ headers: { apikey: "" },
1249
+ });
1250
+
1251
+ expect(result.reachedHandler).toBe(true);
1252
+ });
1253
+ });
1254
+ });
1255
+
1256
+ /*
1257
+ * The rule the widening has to keep: on UserAPI, the master API key reaches
1258
+ * reads and nothing else.
1259
+ *
1260
+ * Asserted as a sweep over the whole registered surface rather than route by
1261
+ * route, so a NEW master-admin route added later has to make a deliberate
1262
+ * choice — adding one that accepts the key fails this test until somebody
1263
+ * writes it into the expected list and, one hopes, thinks about why.
1264
+ */
1265
+ describe("UserAPI master-admin route surface", () => {
1266
+ type RouteSummary = { method: string; uri: string; middleware: unknown };
1267
+
1268
+ function routesGuardedBy(middleware: unknown): Array<string> {
1269
+ return (mockRouter.routes as unknown as Array<RouteSummary>)
1270
+ .filter((route: RouteSummary) => {
1271
+ return route.middleware === middleware;
1272
+ })
1273
+ .map((route: RouteSummary) => {
1274
+ return `${route.method} ${route.uri}`;
1275
+ })
1276
+ .sort();
1277
+ }
1278
+
1279
+ beforeAll(() => {
1280
+ mockRouter.routes.length = 0;
1281
+ new UserAPI();
1282
+ });
1283
+
1284
+ test("the projects read is the ONLY route a static key can reach", () => {
1285
+ expect(
1286
+ routesGuardedBy(
1287
+ MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware,
1288
+ ),
1289
+ ).toEqual([`POST ${PROJECTS_ROUTE}`]);
1290
+ });
1291
+
1292
+ test("every master-admin route that writes stays session-only", () => {
1293
+ expect(
1294
+ routesGuardedBy(
1295
+ MasterAdminAuthorization.isAuthorizedMasterAdminMiddleware,
1296
+ ),
1297
+ ).toEqual(
1298
+ [
1299
+ `GET ${AUTH_STATUS_ROUTE}`,
1300
+ `POST ${REMOVE_ROUTE}`,
1301
+ `POST ${RESET_LINK_ROUTE}`,
1302
+ `POST ${SET_PASSWORD_ROUTE}`,
1303
+ ].sort(),
1304
+ );
1305
+ });
1306
+
1307
+ /*
1308
+ * Spelled out one by one as well as swept, because these are the routes where
1309
+ * a leaked static key would stop being a disclosure and start being an
1310
+ * account takeover: removing somebody's access, setting their password, or
1311
+ * mailing a reset link to their inbox.
1312
+ */
1313
+ test.each([
1314
+ ["POST", REMOVE_ROUTE],
1315
+ ["POST", SET_PASSWORD_ROUTE],
1316
+ ["POST", RESET_LINK_ROUTE],
1317
+ ["GET", AUTH_STATUS_ROUTE],
1318
+ ])(
1319
+ "%s %s does not accept the master API key",
1320
+ (method: string, uri: string) => {
1321
+ expect(mockRouter.match(method, uri).middleware).toBe(
1322
+ MasterAdminAuthorization.isAuthorizedMasterAdminMiddleware,
1323
+ );
1324
+ },
1325
+ );
1326
+ });