@oneuptime/common 12.0.0 → 12.0.2

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 (237) hide show
  1. package/Models/DatabaseModels/Host.ts +12 -4
  2. package/Models/DatabaseModels/KubernetesContainer.ts +5 -4
  3. package/Models/DatabaseModels/KubernetesResource.ts +8 -7
  4. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +12 -2
  5. package/Models/DatabaseModels/PodmanResource.ts +6 -6
  6. package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
  7. package/Models/DatabaseModels/StatusPage.ts +95 -0
  8. package/Server/API/TeamMemberAPI.ts +85 -0
  9. package/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.ts +12 -2
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  15. package/Server/Middleware/ProjectAuthorization.ts +66 -4
  16. package/Server/Services/BillingService.ts +600 -51
  17. package/Server/Services/CephClusterService.ts +47 -5
  18. package/Server/Services/CephResourceService.ts +43 -4
  19. package/Server/Services/CloudResourceService.ts +47 -5
  20. package/Server/Services/DatabaseService.ts +87 -6
  21. package/Server/Services/DockerHostService.ts +47 -5
  22. package/Server/Services/DockerResourceService.ts +62 -8
  23. package/Server/Services/DockerSwarmClusterService.ts +48 -5
  24. package/Server/Services/DockerSwarmResourceService.ts +53 -4
  25. package/Server/Services/HostService.ts +48 -5
  26. package/Server/Services/IoTDeviceService.ts +1 -17
  27. package/Server/Services/IoTFleetService.ts +47 -5
  28. package/Server/Services/KubernetesClusterService.ts +48 -5
  29. package/Server/Services/KubernetesContainerService.ts +50 -9
  30. package/Server/Services/KubernetesResourceService.ts +53 -7
  31. package/Server/Services/LabelService.ts +106 -1
  32. package/Server/Services/LlmProviderService.ts +69 -9
  33. package/Server/Services/MonitorService.ts +57 -0
  34. package/Server/Services/PodmanHostService.ts +47 -5
  35. package/Server/Services/PodmanResourceService.ts +62 -8
  36. package/Server/Services/ProjectService.ts +165 -35
  37. package/Server/Services/ProxmoxClusterService.ts +47 -5
  38. package/Server/Services/ProxmoxResourceService.ts +43 -4
  39. package/Server/Services/RumApplicationService.ts +48 -5
  40. package/Server/Services/RunnerService.ts +1 -7
  41. package/Server/Services/ServerlessFunctionService.ts +48 -5
  42. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
  43. package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
  44. package/Server/Services/StatusPageService.ts +95 -36
  45. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
  46. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  47. package/Server/Types/Database/JSONColumnQuery.ts +621 -0
  48. package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
  49. package/Server/Types/Database/QueryHelper.ts +17 -32
  50. package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
  51. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +12 -0
  52. package/Tests/App/Dashboard/RunnerInstallInstructions.test.tsx +119 -0
  53. package/Tests/App/Dashboard/RunnerStatus.test.tsx +274 -0
  54. package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
  55. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
  56. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
  57. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
  58. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
  59. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
  60. package/Tests/Server/Services/BillingService.test.ts +78 -3
  61. package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
  62. package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
  63. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
  64. package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
  65. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
  66. package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
  67. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
  68. package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
  69. package/Tests/Server/Services/LlmProviderUsableByProject.test.ts +293 -0
  70. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
  71. package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
  72. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
  73. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
  74. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
  75. package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
  76. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
  77. package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
  78. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
  79. package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
  80. package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
  81. package/Tests/Server/Utils/AI/ToolArgsExtractors.test.ts +141 -0
  82. package/Tests/Server/Utils/AI/Toolbox/WidgetBuilder.test.ts +205 -0
  83. package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
  84. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +897 -0
  85. package/Tests/Types/Events/Recurring.test.ts +157 -0
  86. package/Tests/Types/Runner/RunnerLiveStatus.test.ts +320 -0
  87. package/Tests/UI/Components/AiInvestigationSettingsCard.test.tsx +285 -0
  88. package/Tests/UI/Components/Detail/EntityFields.test.tsx +166 -0
  89. package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
  90. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
  91. package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
  92. package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
  93. package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
  94. package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
  95. package/Types/CustomField/CustomFieldDefinition.ts +24 -0
  96. package/Types/Date.ts +112 -0
  97. package/Types/Events/Recurring.ts +99 -1
  98. package/Types/Runbook/RunbookStep.ts +15 -0
  99. package/Types/Runner/RunnerLiveStatus.ts +114 -0
  100. package/Types/StatusPage/StatusPageReport.ts +11 -0
  101. package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
  102. package/UI/Components/Detail/Detail.tsx +155 -0
  103. package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
  104. package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
  105. package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
  106. package/UI/Utils/TeamMembersByUser.ts +302 -0
  107. package/Utils/StatusPage/ReportPeriod.ts +236 -0
  108. package/Utils/Telemetry/HostIpAddresses.ts +98 -0
  109. package/build/dist/Models/DatabaseModels/Host.js +12 -4
  110. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  111. package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
  112. package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
  114. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +16 -4
  116. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
  118. package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
  120. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
  122. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  123. package/build/dist/Server/API/TeamMemberAPI.js +57 -2
  124. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  125. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js +11 -1
  126. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js.map +1 -1
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  136. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  137. package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
  138. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  139. package/build/dist/Server/Services/BillingService.js +466 -36
  140. package/build/dist/Server/Services/BillingService.js.map +1 -1
  141. package/build/dist/Server/Services/CephClusterService.js +42 -5
  142. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  143. package/build/dist/Server/Services/CephResourceService.js +28 -3
  144. package/build/dist/Server/Services/CephResourceService.js.map +1 -1
  145. package/build/dist/Server/Services/CloudResourceService.js +42 -5
  146. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  147. package/build/dist/Server/Services/DatabaseService.js +69 -2
  148. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  149. package/build/dist/Server/Services/DockerHostService.js +42 -5
  150. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  151. package/build/dist/Server/Services/DockerResourceService.js +34 -4
  152. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  153. package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
  154. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  155. package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
  156. package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
  157. package/build/dist/Server/Services/HostService.js +43 -5
  158. package/build/dist/Server/Services/HostService.js.map +1 -1
  159. package/build/dist/Server/Services/IoTDeviceService.js +1 -8
  160. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  161. package/build/dist/Server/Services/IoTFleetService.js +42 -5
  162. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  163. package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
  164. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  165. package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
  166. package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
  167. package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
  168. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  169. package/build/dist/Server/Services/LabelService.js +92 -0
  170. package/build/dist/Server/Services/LabelService.js.map +1 -1
  171. package/build/dist/Server/Services/LlmProviderService.js +61 -8
  172. package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
  173. package/build/dist/Server/Services/MonitorService.js +47 -0
  174. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  175. package/build/dist/Server/Services/PodmanHostService.js +42 -5
  176. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  177. package/build/dist/Server/Services/PodmanResourceService.js +34 -4
  178. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  179. package/build/dist/Server/Services/ProjectService.js +135 -21
  180. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  181. package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
  182. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  183. package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
  184. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  185. package/build/dist/Server/Services/RumApplicationService.js +43 -5
  186. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  187. package/build/dist/Server/Services/RunnerService.js +1 -6
  188. package/build/dist/Server/Services/RunnerService.js.map +1 -1
  189. package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
  190. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  191. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
  192. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
  193. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
  194. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  195. package/build/dist/Server/Services/StatusPageService.js +69 -46
  196. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  197. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
  198. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  199. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  200. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  201. package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
  202. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
  203. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
  204. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
  205. package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
  206. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  207. package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
  208. package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
  209. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +11 -0
  210. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  211. package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
  212. package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
  213. package/build/dist/Types/Date.js +68 -0
  214. package/build/dist/Types/Date.js.map +1 -1
  215. package/build/dist/Types/Events/Recurring.js +63 -0
  216. package/build/dist/Types/Events/Recurring.js.map +1 -1
  217. package/build/dist/Types/Runbook/RunbookStep.js.map +1 -1
  218. package/build/dist/Types/Runner/RunnerLiveStatus.js +69 -0
  219. package/build/dist/Types/Runner/RunnerLiveStatus.js.map +1 -0
  220. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
  221. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
  222. package/build/dist/UI/Components/Detail/Detail.js +97 -0
  223. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  224. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  225. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
  226. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  227. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
  228. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
  229. package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
  230. package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
  231. package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
  232. package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
  233. package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
  234. package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
  235. package/jest.config.json +2 -0
  236. package/package.json +1 -1
  237. package/tsconfig.json +12 -1
@@ -0,0 +1,303 @@
1
+ import CommonAPI from "../../../Server/API/CommonAPI";
2
+ import TeamMemberAPI from "../../../Server/API/TeamMemberAPI";
3
+ import TeamMemberService from "../../../Server/Services/TeamMemberService";
4
+ import {
5
+ ExpressRequest,
6
+ ExpressResponse,
7
+ NextFunction,
8
+ } from "../../../Server/Utils/Express";
9
+ import Response from "../../../Server/Utils/Response";
10
+ import { mockRouter } from "./Helpers";
11
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
12
+ import Dictionary from "../../../Types/Dictionary";
13
+ import ObjectID from "../../../Types/ObjectID";
14
+ import Permission, {
15
+ UserPermission,
16
+ UserTenantAccessPermission,
17
+ } from "../../../Types/Permission";
18
+ import {
19
+ afterEach,
20
+ beforeAll,
21
+ beforeEach,
22
+ describe,
23
+ expect,
24
+ test,
25
+ } from "@jest/globals";
26
+
27
+ /*
28
+ * POST /team-member/remove-user-from-project exists so that removing a person
29
+ * from a project is ONE server-side delete rather than a DELETE per membership
30
+ * issued from the browser.
31
+ *
32
+ * That distinction is the whole point, and it is invisible from the outside:
33
+ * TeamMemberService.onBeforeDelete refuses to remove the last accepted member
34
+ * of a team that shouldHaveAtLeastOneMember (the Owners team), and it refuses
35
+ * per request. A client-side loop therefore deletes every other membership
36
+ * first and only then reports the failure, leaving the person stripped of the
37
+ * teams the admin was just told they had not lost. A single deleteBy runs that
38
+ * guard across the whole set before deleting anything.
39
+ *
40
+ * So these tests pin: one deleteBy, over exactly {userId, projectId}, with the
41
+ * caller's own permissions (not root), and with the project taken from the
42
+ * request's tenant rather than from anything the caller put in the body.
43
+ */
44
+
45
+ jest.mock("../../../Server/Utils/Express", () => {
46
+ return {
47
+ getRouter: () => {
48
+ return mockRouter;
49
+ },
50
+ };
51
+ });
52
+
53
+ jest.mock("../../../Server/Utils/Response", () => {
54
+ return {
55
+ sendEntityArrayResponse: jest.fn(),
56
+ sendJsonObjectResponse: jest.fn(),
57
+ sendEmptySuccessResponse: jest.fn(),
58
+ sendEntityResponse: jest.fn(),
59
+ sendErrorResponse: jest.fn(),
60
+ };
61
+ });
62
+
63
+ const ROUTE: string = "/team-member/remove-user-from-project";
64
+
65
+ type RouteCallResult = {
66
+ thrownToNext: unknown;
67
+ nextCallCount: number;
68
+ };
69
+
70
+ function buildProps(data: {
71
+ projectId: ObjectID | undefined;
72
+ userId: ObjectID;
73
+ }): DatabaseCommonInteractionProps {
74
+ const memberPermission: UserPermission = {
75
+ _type: "UserPermission",
76
+ permission: Permission.ProjectAdmin,
77
+ labelIds: [],
78
+ };
79
+
80
+ const permissionMap: Dictionary<UserTenantAccessPermission> = {};
81
+
82
+ if (data.projectId) {
83
+ permissionMap[data.projectId.toString()] = {
84
+ _type: "UserTenantAccessPermission",
85
+ projectId: data.projectId,
86
+ permissions: [memberPermission],
87
+ };
88
+ }
89
+
90
+ return {
91
+ tenantId: data.projectId,
92
+ userId: data.userId,
93
+ userTenantAccessPermission: permissionMap,
94
+ };
95
+ }
96
+
97
+ async function callRoute(data: {
98
+ body: Dictionary<unknown>;
99
+ authenticatedUserId?: ObjectID | undefined;
100
+ }): Promise<RouteCallResult> {
101
+ const req: ExpressRequest = {
102
+ params: {},
103
+ query: {},
104
+ body: data.body,
105
+ headers: {},
106
+ userAuthorization: data.authenticatedUserId
107
+ ? { userId: data.authenticatedUserId }
108
+ : undefined,
109
+ } as unknown as ExpressRequest;
110
+
111
+ const res: ExpressResponse = {
112
+ send: jest.fn(),
113
+ json: jest.fn(),
114
+ status: jest.fn().mockReturnThis(),
115
+ } as unknown as ExpressResponse;
116
+
117
+ const next: jest.Mock = jest.fn();
118
+
119
+ await mockRouter
120
+ .match("POST", ROUTE)
121
+ .handlerFunction(req, res, next as unknown as NextFunction);
122
+
123
+ return {
124
+ thrownToNext: next.mock.calls[0] ? next.mock.calls[0][0] : undefined,
125
+ nextCallCount: next.mock.calls.length,
126
+ };
127
+ }
128
+
129
+ describe("POST /team-member/remove-user-from-project", () => {
130
+ let projectId: ObjectID;
131
+ let callerUserId: ObjectID;
132
+ let targetUserId: ObjectID;
133
+ let deleteBySpy: jest.SpyInstance;
134
+ let propsSpy: jest.SpyInstance;
135
+
136
+ beforeAll(() => {
137
+ mockRouter.routes.length = 0;
138
+ new TeamMemberAPI();
139
+ });
140
+
141
+ beforeEach(() => {
142
+ jest.clearAllMocks();
143
+ projectId = ObjectID.generate();
144
+ callerUserId = ObjectID.generate();
145
+ targetUserId = ObjectID.generate();
146
+
147
+ deleteBySpy = jest
148
+ .spyOn(TeamMemberService, "deleteBy")
149
+ .mockResolvedValue(3);
150
+
151
+ propsSpy = jest
152
+ .spyOn(CommonAPI, "getDatabaseCommonInteractionProps")
153
+ .mockResolvedValue(
154
+ buildProps({ projectId: projectId, userId: callerUserId }),
155
+ );
156
+ });
157
+
158
+ afterEach(() => {
159
+ jest.restoreAllMocks();
160
+ });
161
+
162
+ test("the route is registered", () => {
163
+ expect(() => {
164
+ return mockRouter.match("POST", ROUTE);
165
+ }).not.toThrow();
166
+ });
167
+
168
+ test("removes every membership in ONE delete, scoped to the user and project", async () => {
169
+ await callRoute({
170
+ body: { userId: targetUserId.toString() },
171
+ authenticatedUserId: callerUserId,
172
+ });
173
+
174
+ expect(deleteBySpy).toHaveBeenCalledTimes(1);
175
+
176
+ const deleteBy: {
177
+ query: Dictionary<unknown>;
178
+ props: DatabaseCommonInteractionProps;
179
+ } = deleteBySpy.mock.calls[0]![0] as {
180
+ query: Dictionary<unknown>;
181
+ props: DatabaseCommonInteractionProps;
182
+ };
183
+
184
+ expect((deleteBy.query["userId"] as ObjectID).toString()).toBe(
185
+ targetUserId.toString(),
186
+ );
187
+ expect((deleteBy.query["projectId"] as ObjectID).toString()).toBe(
188
+ projectId.toString(),
189
+ );
190
+ // Nothing else narrows it — every team the user is on is in scope.
191
+ expect(Object.keys(deleteBy.query).sort()).toEqual(["projectId", "userId"]);
192
+ });
193
+
194
+ test("runs the delete with the caller's own permissions, never as root", async () => {
195
+ await callRoute({
196
+ body: { userId: targetUserId.toString() },
197
+ authenticatedUserId: callerUserId,
198
+ });
199
+
200
+ const props: DatabaseCommonInteractionProps = (
201
+ deleteBySpy.mock.calls[0]![0] as { props: DatabaseCommonInteractionProps }
202
+ ).props;
203
+
204
+ expect(props.isRoot).toBeFalsy();
205
+ expect(props.userId?.toString()).toBe(callerUserId.toString());
206
+ expect(props.tenantId?.toString()).toBe(projectId.toString());
207
+ });
208
+
209
+ test("reports how many memberships were removed", async () => {
210
+ await callRoute({
211
+ body: { userId: targetUserId.toString() },
212
+ authenticatedUserId: callerUserId,
213
+ });
214
+
215
+ expect(Response.sendJsonObjectResponse).toHaveBeenCalledWith(
216
+ expect.anything(),
217
+ expect.anything(),
218
+ { numberOfMembershipsDeleted: 3 },
219
+ );
220
+ });
221
+
222
+ test("takes the project from the request's tenant, not from the body", async () => {
223
+ const attackerSuppliedProjectId: ObjectID = ObjectID.generate();
224
+
225
+ await callRoute({
226
+ body: {
227
+ userId: targetUserId.toString(),
228
+ projectId: attackerSuppliedProjectId.toString(),
229
+ },
230
+ authenticatedUserId: callerUserId,
231
+ });
232
+
233
+ const query: Dictionary<unknown> = (
234
+ deleteBySpy.mock.calls[0]![0] as { query: Dictionary<unknown> }
235
+ ).query;
236
+
237
+ expect((query["projectId"] as ObjectID).toString()).toBe(
238
+ projectId.toString(),
239
+ );
240
+ expect((query["projectId"] as ObjectID).toString()).not.toBe(
241
+ attackerSuppliedProjectId.toString(),
242
+ );
243
+ });
244
+
245
+ test("rejects an unauthenticated caller without deleting anything", async () => {
246
+ await callRoute({ body: { userId: targetUserId.toString() } });
247
+
248
+ expect(deleteBySpy).not.toHaveBeenCalled();
249
+ expect(Response.sendErrorResponse).toHaveBeenCalled();
250
+ });
251
+
252
+ test("rejects a request with no tenant without deleting anything", async () => {
253
+ propsSpy.mockResolvedValue(
254
+ buildProps({ projectId: undefined, userId: callerUserId }),
255
+ );
256
+
257
+ await callRoute({
258
+ body: { userId: targetUserId.toString() },
259
+ authenticatedUserId: callerUserId,
260
+ });
261
+
262
+ expect(deleteBySpy).not.toHaveBeenCalled();
263
+ expect(Response.sendErrorResponse).toHaveBeenCalled();
264
+ });
265
+
266
+ test("rejects a missing user id without deleting anything", async () => {
267
+ await callRoute({ body: {}, authenticatedUserId: callerUserId });
268
+
269
+ expect(deleteBySpy).not.toHaveBeenCalled();
270
+ expect(Response.sendErrorResponse).toHaveBeenCalled();
271
+ });
272
+
273
+ test("rejects a user id that is not a uuid without deleting anything", async () => {
274
+ const result: RouteCallResult = await callRoute({
275
+ body: { userId: "'; DROP TABLE TeamMember; --" },
276
+ authenticatedUserId: callerUserId,
277
+ });
278
+
279
+ expect(deleteBySpy).not.toHaveBeenCalled();
280
+ expect(result.nextCallCount).toBe(1);
281
+ });
282
+
283
+ test("passes a server rejection on rather than reporting success", async () => {
284
+ /*
285
+ * This is the last-accepted-Owner guard. It has to reach the caller as a
286
+ * failure with nothing deleted — which is exactly what the single deleteBy
287
+ * buys over a per-membership loop.
288
+ */
289
+ const guardError: Error = new Error(
290
+ "This team should have at least 1 member who has accepted the invitation.",
291
+ );
292
+
293
+ deleteBySpy.mockRejectedValue(guardError);
294
+
295
+ const result: RouteCallResult = await callRoute({
296
+ body: { userId: targetUserId.toString() },
297
+ authenticatedUserId: callerUserId,
298
+ });
299
+
300
+ expect(result.thrownToNext).toBe(guardError);
301
+ expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
302
+ });
303
+ });
@@ -0,0 +1,350 @@
1
+ import MasterAdminAuthorization from "../../../Server/Middleware/MasterAdminAuthorization";
2
+ import ProjectMiddleware from "../../../Server/Middleware/ProjectAuthorization";
3
+ import UserMiddleware from "../../../Server/Middleware/UserAuthorization";
4
+ import JSONWebToken from "../../../Server/Utils/JsonWebToken";
5
+ import Response from "../../../Server/Utils/Response";
6
+ import {
7
+ ExpressRequest,
8
+ ExpressResponse,
9
+ NextFunction,
10
+ } from "../../../Server/Utils/Express";
11
+ import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
12
+ import JSONWebTokenData from "../../../Types/JsonWebTokenData";
13
+ import ObjectID from "../../../Types/ObjectID";
14
+ import { getJestSpyOn } from "../../Spy";
15
+ import { beforeEach, describe, expect, jest, test } from "@jest/globals";
16
+
17
+ /*
18
+ * isAuthorizedMasterAdminOrMasterApiKeyMiddleware is the SECOND production
19
+ * consumer of ProjectMiddleware.getApiKey (UserAuthorization is the first), and
20
+ * until now it had no test anywhere in the repo.
21
+ *
22
+ * That mattered when issue #3004 was fixed. getApiKey gained a `.trim()` and a
23
+ * UUID-shape guard, and this middleware inherited both without anyone looking
24
+ * at it:
25
+ *
26
+ * before: a padded header " <master-key> " became ObjectID(" <master-key> "),
27
+ * which isMasterApiKey's own UUID guard rejected, so the request fell
28
+ * through to the JWT check and was refused.
29
+ * after: getApiKey trims first, so the same header now authenticates.
30
+ *
31
+ * That is a widening of a master-admin path. It is a safe one — it admits only
32
+ * a caller who already holds the correct master key, and HTTP strips optional
33
+ * whitespace around field values anyway — but it is exactly the kind of change
34
+ * that should be pinned rather than left implicit. These tests pin both the
35
+ * widening and the boundary that did NOT move: a wrong key, a malformed key, or
36
+ * no key at all still gets nothing without a master-admin session.
37
+ */
38
+
39
+ const MASTER_API_KEY: string = "8e1a3a52-6d64-4f1f-9a2e-5f0f9c1d2b34";
40
+ const OTHER_API_KEY: string = "1c9d7f40-2b83-4a55-8e70-6d4b9a0c3e12";
41
+
42
+ describe("MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware", () => {
43
+ let request: ExpressRequest;
44
+ let response: ExpressResponse;
45
+ let next: NextFunction;
46
+
47
+ type RequestWithHeadersFunction = (
48
+ headers: Record<string, string | Array<string> | undefined>,
49
+ ) => ExpressRequest;
50
+
51
+ const requestWithHeaders: RequestWithHeadersFunction = (
52
+ headers: Record<string, string | Array<string> | undefined>,
53
+ ): ExpressRequest => {
54
+ return { headers: headers } as unknown as ExpressRequest;
55
+ };
56
+
57
+ beforeEach(() => {
58
+ jest.restoreAllMocks();
59
+ jest.clearAllMocks();
60
+
61
+ response = {
62
+ status: jest.fn().mockReturnThis(),
63
+ json: jest.fn().mockReturnThis(),
64
+ send: jest.fn().mockReturnThis(),
65
+ } as unknown as ExpressResponse;
66
+
67
+ next = jest.fn() as unknown as NextFunction;
68
+
69
+ getJestSpyOn(Response, "sendErrorResponse").mockImplementation(() => {
70
+ return undefined as never;
71
+ });
72
+
73
+ /*
74
+ * Stand in for the GlobalConfig lookup: only the one master key value is
75
+ * live. Spying here rather than on isMasterApiKey keeps the real UUID guard
76
+ * and the real "is it actually the master key" comparison in the test.
77
+ */
78
+ getJestSpyOn(ProjectMiddleware, "isMasterApiKey").mockImplementation(((
79
+ apiKey: ObjectID,
80
+ ): Promise<boolean> => {
81
+ return Promise.resolve(apiKey.toString() === MASTER_API_KEY);
82
+ }) as never);
83
+ });
84
+
85
+ describe("when the master API key is presented", () => {
86
+ test("authorizes the request and never looks at the session", async () => {
87
+ const accessTokenSpy: jest.SpyInstance<any, any> = getJestSpyOn(
88
+ UserMiddleware,
89
+ "getAccessTokenFromExpressRequest",
90
+ );
91
+
92
+ request = requestWithHeaders({ apikey: MASTER_API_KEY });
93
+
94
+ await MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware(
95
+ request,
96
+ response,
97
+ next,
98
+ );
99
+
100
+ expect(next).toHaveBeenCalledTimes(1);
101
+ expect(next).toHaveBeenCalledWith();
102
+ expect(Response.sendErrorResponse).not.toHaveBeenCalled();
103
+ // The key short-circuits before the JWT branch is ever entered.
104
+ expect(accessTokenSpy).not.toHaveBeenCalled();
105
+ });
106
+
107
+ /*
108
+ * The behaviour change introduced by the #3004 fix. Before getApiKey
109
+ * trimmed, this request fell through to the JWT check and was refused.
110
+ */
111
+ test("accepts a whitespace-padded master key now that getApiKey trims", async () => {
112
+ request = requestWithHeaders({ apikey: ` ${MASTER_API_KEY}\t` });
113
+
114
+ await MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware(
115
+ request,
116
+ response,
117
+ next,
118
+ );
119
+
120
+ expect(next).toHaveBeenCalledTimes(1);
121
+ expect(Response.sendErrorResponse).not.toHaveBeenCalled();
122
+ });
123
+
124
+ /*
125
+ * getApiKey normalizes whitespace but deliberately does NOT normalize case,
126
+ * so the value reaches the lookup exactly as the caller wrote it. Postgres
127
+ * compares `uuid` values by value rather than by the text they were written
128
+ * in, so a real instance still matches an upper-cased key — this asserts
129
+ * only the part that is ours: what we hand to the lookup.
130
+ */
131
+ test("passes the key to the lookup verbatim, without case normalization", async () => {
132
+ request = requestWithHeaders({
133
+ apikey: ` ${MASTER_API_KEY.toUpperCase()} `,
134
+ });
135
+
136
+ await MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware(
137
+ request,
138
+ response,
139
+ next,
140
+ );
141
+
142
+ expect(ProjectMiddleware.isMasterApiKey).toHaveBeenCalledTimes(1);
143
+
144
+ const lookedUpKey: ObjectID = (
145
+ ProjectMiddleware.isMasterApiKey as unknown as jest.Mock
146
+ ).mock.calls[0]![0] as ObjectID;
147
+
148
+ expect(lookedUpKey.toString()).toBe(MASTER_API_KEY.toUpperCase());
149
+ });
150
+ });
151
+
152
+ describe("when the key cannot authorize the request", () => {
153
+ /*
154
+ * The boundary that did NOT move with the #3004 fix. Each of these must
155
+ * still be refused, because none of them is the master key.
156
+ */
157
+ const unusableKeys: Array<[string, string]> = [
158
+ ["a well-formed key that is not the master key", OTHER_API_KEY],
159
+ ["an empty header", ""],
160
+ ["a whitespace-only header", " "],
161
+ ["a non-UUID string", "not-a-uuid"],
162
+ ["a UUID with a segment missing", "8e1a3a52-6d64-4f1f-9a2e"],
163
+ [
164
+ "a UUID with a non-hex character",
165
+ "8e1a3a52-6d64-4f1f-9a2e-5f0f9c1d2bZZ",
166
+ ],
167
+ ["a SQL injection attempt", "' OR 1=1 --"],
168
+ ];
169
+
170
+ test.each(unusableKeys)(
171
+ "refuses %s and falls through to the session check",
172
+ async (_label: string, apiKeyHeader: string) => {
173
+ request = requestWithHeaders({ apikey: apiKeyHeader });
174
+
175
+ await MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware(
176
+ request,
177
+ response,
178
+ next,
179
+ );
180
+
181
+ expect(next).not.toHaveBeenCalled();
182
+ expect(Response.sendErrorResponse).toHaveBeenCalledTimes(1);
183
+
184
+ const error: NotAuthorizedException = (
185
+ Response.sendErrorResponse as unknown as jest.Mock
186
+ ).mock.calls[0]![2] as NotAuthorizedException;
187
+
188
+ expect(error).toBeInstanceOf(NotAuthorizedException);
189
+ expect(error.message).toBe("Unauthorized: Access token is required.");
190
+ },
191
+ );
192
+
193
+ /*
194
+ * The UUID-shape guard added in the #3004 fix has to run BEFORE the key
195
+ * reaches the lookup: ApiKey.apiKey and GlobalConfig.masterApiKey are both
196
+ * Postgres uuid columns, and a non-UUID raises 22P02 as a raw
197
+ * QueryFailedError, which is not a OneUptime Exception and so escapes the
198
+ * error translator as a 500.
199
+ */
200
+ test("never reaches the master key lookup with a malformed value", async () => {
201
+ request = requestWithHeaders({ apikey: "garbage" });
202
+
203
+ await MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware(
204
+ request,
205
+ response,
206
+ next,
207
+ );
208
+
209
+ expect(ProjectMiddleware.isMasterApiKey).not.toHaveBeenCalled();
210
+ });
211
+
212
+ /*
213
+ * Duplicate headers arrive from Node joined with a comma. Two keys is never
214
+ * one key, and it must not authenticate as whichever happened to be first.
215
+ */
216
+ test("refuses duplicate apikey headers rather than using the first", async () => {
217
+ request = requestWithHeaders({
218
+ apikey: [MASTER_API_KEY, OTHER_API_KEY],
219
+ });
220
+
221
+ await MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware(
222
+ request,
223
+ response,
224
+ next,
225
+ );
226
+
227
+ expect(ProjectMiddleware.isMasterApiKey).not.toHaveBeenCalled();
228
+ expect(next).not.toHaveBeenCalled();
229
+ expect(Response.sendErrorResponse).toHaveBeenCalledTimes(1);
230
+ });
231
+
232
+ test("refuses a request with no apikey header and no session", async () => {
233
+ request = requestWithHeaders({});
234
+
235
+ await MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware(
236
+ request,
237
+ response,
238
+ next,
239
+ );
240
+
241
+ expect(ProjectMiddleware.isMasterApiKey).not.toHaveBeenCalled();
242
+ expect(next).not.toHaveBeenCalled();
243
+ expect(Response.sendErrorResponse).toHaveBeenCalledTimes(1);
244
+ });
245
+
246
+ /*
247
+ * A lookup that blows up must not become an authorization. The catch in the
248
+ * middleware swallows it and falls through to the session check.
249
+ */
250
+ test("falls through to the session check when the key lookup throws", async () => {
251
+ getJestSpyOn(ProjectMiddleware, "isMasterApiKey").mockImplementation(
252
+ (() => {
253
+ return Promise.reject(new Error("database is down"));
254
+ }) as never,
255
+ );
256
+
257
+ request = requestWithHeaders({ apikey: MASTER_API_KEY });
258
+
259
+ await MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware(
260
+ request,
261
+ response,
262
+ next,
263
+ );
264
+
265
+ expect(next).not.toHaveBeenCalled();
266
+ expect(Response.sendErrorResponse).toHaveBeenCalledTimes(1);
267
+ });
268
+ });
269
+
270
+ describe("session fallback", () => {
271
+ test("authorizes a master admin session when no key is presented", async () => {
272
+ getJestSpyOn(
273
+ UserMiddleware,
274
+ "getAccessTokenFromExpressRequest",
275
+ ).mockReturnValue("a.master.admin.token" as never);
276
+
277
+ getJestSpyOn(JSONWebToken, "decode").mockReturnValue({
278
+ isMasterAdmin: true,
279
+ } as unknown as JSONWebTokenData as never);
280
+
281
+ request = requestWithHeaders({});
282
+
283
+ await MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware(
284
+ request,
285
+ response,
286
+ next,
287
+ );
288
+
289
+ expect(next).toHaveBeenCalledTimes(1);
290
+ expect(Response.sendErrorResponse).not.toHaveBeenCalled();
291
+ });
292
+
293
+ test("refuses a session that is not a master admin", async () => {
294
+ getJestSpyOn(
295
+ UserMiddleware,
296
+ "getAccessTokenFromExpressRequest",
297
+ ).mockReturnValue("an.ordinary.user.token" as never);
298
+
299
+ getJestSpyOn(JSONWebToken, "decode").mockReturnValue({
300
+ isMasterAdmin: false,
301
+ } as unknown as JSONWebTokenData as never);
302
+
303
+ request = requestWithHeaders({});
304
+
305
+ await MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware(
306
+ request,
307
+ response,
308
+ next,
309
+ );
310
+
311
+ expect(next).not.toHaveBeenCalled();
312
+
313
+ const error: NotAuthorizedException = (
314
+ Response.sendErrorResponse as unknown as jest.Mock
315
+ ).mock.calls[0]![2] as NotAuthorizedException;
316
+
317
+ expect(error.message).toBe(
318
+ "Unauthorized: Only master admins can perform this action.",
319
+ );
320
+ });
321
+
322
+ /*
323
+ * An unusable apikey header must not cost a caller their valid session on
324
+ * these routes. This middleware is the one place where the presence-based
325
+ * routing in UserAuthorization does NOT apply: it falls through rather than
326
+ * failing fast.
327
+ */
328
+ test("still honours a master admin session when an unusable key is also sent", async () => {
329
+ getJestSpyOn(
330
+ UserMiddleware,
331
+ "getAccessTokenFromExpressRequest",
332
+ ).mockReturnValue("a.master.admin.token" as never);
333
+
334
+ getJestSpyOn(JSONWebToken, "decode").mockReturnValue({
335
+ isMasterAdmin: true,
336
+ } as unknown as JSONWebTokenData as never);
337
+
338
+ request = requestWithHeaders({ apikey: "" });
339
+
340
+ await MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware(
341
+ request,
342
+ response,
343
+ next,
344
+ );
345
+
346
+ expect(next).toHaveBeenCalledTimes(1);
347
+ expect(Response.sendErrorResponse).not.toHaveBeenCalled();
348
+ });
349
+ });
350
+ });