@oneuptime/common 12.0.3 → 12.0.4

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 (173) hide show
  1. package/Models/DatabaseModels/CephCluster.ts +14 -0
  2. package/Models/DatabaseModels/CodeRepository.ts +14 -6
  3. package/Models/DatabaseModels/DockerHost.ts +14 -0
  4. package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
  5. package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
  6. package/Models/DatabaseModels/IoTFleet.ts +14 -0
  7. package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
  8. package/Models/DatabaseModels/NetworkDevice.ts +14 -0
  9. package/Models/DatabaseModels/NetworkInterface.ts +18 -0
  10. package/Models/DatabaseModels/Project.ts +16 -2
  11. package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
  12. package/Models/DatabaseModels/Service.ts +30 -0
  13. package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
  14. package/Models/DatabaseModels/User.ts +30 -0
  15. package/Server/API/AIAgentDataAPI.ts +31 -0
  16. package/Server/API/DashboardAPI.ts +419 -12
  17. package/Server/API/GitHubAPI.ts +119 -283
  18. package/Server/API/UserAPI.ts +263 -1
  19. package/Server/EnvironmentConfig.ts +7 -3
  20. package/Server/Infrastructure/GlobalCache.ts +37 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
  27. package/Server/Services/AnalyticsDatabaseService.ts +72 -1
  28. package/Server/Services/CodeRepositoryService.ts +105 -2
  29. package/Server/Services/DatabaseService.ts +185 -2
  30. package/Server/Services/OpenTelemetryIngestService.ts +230 -26
  31. package/Server/Services/UserService.ts +184 -0
  32. package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
  33. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
  34. package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
  35. package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
  36. package/Server/Utils/PasswordHash.ts +306 -0
  37. package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
  38. package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
  39. package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
  40. package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
  41. package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
  42. package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
  43. package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
  44. package/Tests/Server/API/Helpers.ts +6 -1
  45. package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
  46. package/Tests/Server/Infrastructure/GlobalCache.test.ts +128 -0
  47. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
  48. package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
  49. package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
  50. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
  51. package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
  52. package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
  53. package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
  54. package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
  55. package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
  56. package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
  57. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
  58. package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
  59. package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
  60. package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
  61. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
  62. package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
  63. package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
  64. package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
  65. package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
  66. package/Tests/Types/Database/ColumnLength.test.ts +7 -1
  67. package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
  68. package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
  69. package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
  70. package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
  71. package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
  72. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
  73. package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
  74. package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
  75. package/Types/Database/ColumnLength.ts +9 -1
  76. package/Types/Database/TableColumn.ts +11 -0
  77. package/Types/Database/UnsynchronizedIndex.ts +48 -0
  78. package/Types/HashedString.ts +139 -4
  79. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
  80. package/UI/Components/Navbar/NavBar.tsx +12 -2
  81. package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
  82. package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
  83. package/Utils/TeamMembersByProject.ts +237 -0
  84. package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
  85. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  86. package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
  87. package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
  88. package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
  89. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  90. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
  91. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
  92. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
  93. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
  95. package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
  97. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
  99. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
  101. package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/Project.js +16 -2
  103. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
  105. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/Service.js +28 -1
  107. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
  109. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  110. package/build/dist/Models/DatabaseModels/User.js +32 -0
  111. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  112. package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
  113. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  114. package/build/dist/Server/API/DashboardAPI.js +304 -10
  115. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  116. package/build/dist/Server/API/GitHubAPI.js +94 -167
  117. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  118. package/build/dist/Server/API/UserAPI.js +187 -2
  119. package/build/dist/Server/API/UserAPI.js.map +1 -1
  120. package/build/dist/Server/EnvironmentConfig.js +7 -3
  121. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  122. package/build/dist/Server/Infrastructure/GlobalCache.js +29 -0
  123. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  136. package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
  137. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  138. package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
  139. package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
  140. package/build/dist/Server/Services/DatabaseService.js +160 -2
  141. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  142. package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
  143. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  144. package/build/dist/Server/Services/UserService.js +165 -0
  145. package/build/dist/Server/Services/UserService.js.map +1 -1
  146. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
  147. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
  148. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
  149. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  150. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
  151. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
  152. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
  153. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
  154. package/build/dist/Server/Utils/PasswordHash.js +237 -0
  155. package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
  156. package/build/dist/Types/Database/ColumnLength.js +9 -1
  157. package/build/dist/Types/Database/ColumnLength.js.map +1 -1
  158. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  159. package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
  160. package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
  161. package/build/dist/Types/HashedString.js +95 -5
  162. package/build/dist/Types/HashedString.js.map +1 -1
  163. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
  164. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  165. package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
  166. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  167. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
  168. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  169. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
  170. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
  171. package/build/dist/Utils/TeamMembersByProject.js +145 -0
  172. package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
  173. package/package.json +1 -1
@@ -0,0 +1,655 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import TeamMembersByProject, {
3
+ UserProjectMembership,
4
+ } from "../../Utils/TeamMembersByProject";
5
+ import Project from "../../Models/DatabaseModels/Project";
6
+ import Team from "../../Models/DatabaseModels/Team";
7
+ import TeamMember from "../../Models/DatabaseModels/TeamMember";
8
+ import ObjectID from "../../Types/ObjectID";
9
+
10
+ /*
11
+ * The Admin Dashboard's User > Projects page asks "which projects is this
12
+ * person in?" and the only data that answers it is a list of TeamMember - a
13
+ * (user, team) pair. Someone on three teams of one project has three
14
+ * memberships and is in one project, so every row on that page is a fold of
15
+ * several memberships.
16
+ *
17
+ * Everything below pins that fold, because every way it can go wrong is
18
+ * silent: a group key read off the wrong field merges nothing (one project
19
+ * renders as three), a shared key merges unrelated projects onto one line, a
20
+ * team dropped or double-counted just shows a slightly wrong Teams column, and
21
+ * an unstable row id breaks the delete button on exactly the multi-team rows
22
+ * this exists for.
23
+ */
24
+
25
+ type MembershipSpec = {
26
+ id?: string | undefined;
27
+ projectId?: string | undefined;
28
+ projectIdOnProjectObject?: string | undefined;
29
+ projectName?: string | undefined;
30
+ teamId?: string | undefined;
31
+ teamName?: string | undefined;
32
+ hasAcceptedInvitation?: boolean | undefined;
33
+ createdAt?: Date | undefined;
34
+ };
35
+
36
+ const buildMembership: (spec: MembershipSpec) => TeamMember = (
37
+ spec: MembershipSpec,
38
+ ): TeamMember => {
39
+ const teamMember: TeamMember = new TeamMember();
40
+
41
+ if (spec.id) {
42
+ teamMember._id = spec.id;
43
+ }
44
+
45
+ if (spec.projectId) {
46
+ teamMember.projectId = new ObjectID(spec.projectId);
47
+ }
48
+
49
+ if (spec.projectId || spec.projectIdOnProjectObject || spec.projectName) {
50
+ const project: Project = new Project();
51
+
52
+ const projectId: string | undefined =
53
+ spec.projectIdOnProjectObject || spec.projectId;
54
+
55
+ if (projectId) {
56
+ project._id = projectId;
57
+ }
58
+
59
+ if (spec.projectName) {
60
+ project.name = spec.projectName;
61
+ }
62
+
63
+ teamMember.project = project;
64
+ }
65
+
66
+ if (spec.teamId) {
67
+ teamMember.teamId = new ObjectID(spec.teamId);
68
+ }
69
+
70
+ if (spec.teamId || spec.teamName) {
71
+ const team: Team = new Team();
72
+
73
+ if (spec.teamId) {
74
+ team._id = spec.teamId;
75
+ }
76
+
77
+ if (spec.teamName) {
78
+ team.name = spec.teamName;
79
+ }
80
+
81
+ teamMember.team = team;
82
+ }
83
+
84
+ if (spec.createdAt) {
85
+ teamMember.createdAt = spec.createdAt;
86
+ }
87
+
88
+ teamMember.hasAcceptedInvitation = spec.hasAcceptedInvitation ?? true;
89
+
90
+ return teamMember;
91
+ };
92
+
93
+ const teamNamesOf: (row: UserProjectMembership) => Array<string> = (
94
+ row: UserProjectMembership,
95
+ ): Array<string> => {
96
+ return row.teams.map((team: Team) => {
97
+ return team.name?.toString() || "";
98
+ });
99
+ };
100
+
101
+ const projectNamesOf: (rows: Array<UserProjectMembership>) => Array<string> = (
102
+ rows: Array<UserProjectMembership>,
103
+ ): Array<string> => {
104
+ return rows.map((row: UserProjectMembership) => {
105
+ return TeamMembersByProject.getProjectName(row);
106
+ });
107
+ };
108
+
109
+ describe("TeamMembersByProject.getGroupKey", () => {
110
+ it("groups on the membership's projectId", () => {
111
+ expect(
112
+ TeamMembersByProject.getGroupKey(
113
+ buildMembership({ projectId: "project-1", teamId: "team-1" }),
114
+ ),
115
+ ).toBe("project-1");
116
+ });
117
+
118
+ it("falls back to the id on the joined project when projectId was not selected", () => {
119
+ const teamMember: TeamMember = buildMembership({
120
+ projectIdOnProjectObject: "project-1",
121
+ teamId: "team-1",
122
+ });
123
+
124
+ expect(teamMember.projectId).toBeUndefined();
125
+ expect(TeamMembersByProject.getGroupKey(teamMember)).toBe("project-1");
126
+ });
127
+
128
+ it("returns null when the membership carries no project at all", () => {
129
+ expect(TeamMembersByProject.getGroupKey(new TeamMember())).toBeNull();
130
+ });
131
+ });
132
+
133
+ describe("TeamMembersByProject.groupByProject", () => {
134
+ it("returns nothing for no memberships", () => {
135
+ expect(TeamMembersByProject.groupByProject([])).toEqual([]);
136
+ });
137
+
138
+ it("returns one row for a user on one team of one project", () => {
139
+ const rows: Array<UserProjectMembership> =
140
+ TeamMembersByProject.groupByProject([
141
+ buildMembership({
142
+ projectId: "project-1",
143
+ projectName: "Acme Production",
144
+ teamId: "team-1",
145
+ teamName: "Owners",
146
+ }),
147
+ ]);
148
+
149
+ expect(rows).toHaveLength(1);
150
+ expect(teamNamesOf(rows[0]!)).toEqual(["Owners"]);
151
+ });
152
+
153
+ it("collapses several teams of one project into a single row carrying every team", () => {
154
+ const rows: Array<UserProjectMembership> =
155
+ TeamMembersByProject.groupByProject([
156
+ buildMembership({
157
+ projectId: "project-1",
158
+ projectName: "Acme Production",
159
+ teamId: "team-1",
160
+ teamName: "Owners",
161
+ }),
162
+ buildMembership({
163
+ projectId: "project-1",
164
+ projectName: "Acme Production",
165
+ teamId: "team-2",
166
+ teamName: "Engineering",
167
+ }),
168
+ ]);
169
+
170
+ expect(rows).toHaveLength(1);
171
+ expect(teamNamesOf(rows[0]!)).toEqual(["Engineering", "Owners"]);
172
+ });
173
+
174
+ it("keeps different projects apart", () => {
175
+ const rows: Array<UserProjectMembership> =
176
+ TeamMembersByProject.groupByProject([
177
+ buildMembership({
178
+ projectId: "project-1",
179
+ projectName: "Acme Production",
180
+ teamId: "team-1",
181
+ teamName: "Owners",
182
+ }),
183
+ buildMembership({
184
+ projectId: "project-2",
185
+ projectName: "Acme Staging",
186
+ teamId: "team-3",
187
+ teamName: "Owners",
188
+ }),
189
+ buildMembership({
190
+ projectId: "project-1",
191
+ projectName: "Acme Production",
192
+ teamId: "team-2",
193
+ teamName: "Engineering",
194
+ }),
195
+ ]);
196
+
197
+ expect(rows).toHaveLength(2);
198
+ expect(projectNamesOf(rows)).toEqual(["Acme Production", "Acme Staging"]);
199
+ expect(teamNamesOf(rows[0]!)).toEqual(["Engineering", "Owners"]);
200
+ expect(teamNamesOf(rows[1]!)).toEqual(["Owners"]);
201
+ });
202
+
203
+ it("keeps the order the caller passed, by first appearance of each project", () => {
204
+ const rows: Array<UserProjectMembership> =
205
+ TeamMembersByProject.groupByProject([
206
+ buildMembership({ projectId: "project-3", teamId: "team-1" }),
207
+ buildMembership({ projectId: "project-1", teamId: "team-1" }),
208
+ buildMembership({ projectId: "project-3", teamId: "team-2" }),
209
+ buildMembership({ projectId: "project-2", teamId: "team-1" }),
210
+ ]);
211
+
212
+ expect(
213
+ rows.map((row: UserProjectMembership) => {
214
+ return row.projectId?.toString();
215
+ }),
216
+ ).toEqual(["project-3", "project-1", "project-2"]);
217
+ });
218
+
219
+ it("groups by project even when the same project's memberships are not adjacent", () => {
220
+ const rows: Array<UserProjectMembership> =
221
+ TeamMembersByProject.groupByProject([
222
+ buildMembership({ projectId: "p1", teamId: "t1", teamName: "A" }),
223
+ buildMembership({ projectId: "p2", teamId: "t1", teamName: "A" }),
224
+ buildMembership({ projectId: "p3", teamId: "t1", teamName: "A" }),
225
+ buildMembership({ projectId: "p1", teamId: "t2", teamName: "B" }),
226
+ ]);
227
+
228
+ expect(rows).toHaveLength(3);
229
+ expect(teamNamesOf(rows[0]!)).toEqual(["A", "B"]);
230
+ });
231
+
232
+ it("sorts the teams on a row by name, whatever order the memberships arrived in", () => {
233
+ const rows: Array<UserProjectMembership> =
234
+ TeamMembersByProject.groupByProject([
235
+ buildMembership({ projectId: "p1", teamId: "t3", teamName: "Zulu" }),
236
+ buildMembership({ projectId: "p1", teamId: "t1", teamName: "alpha" }),
237
+ buildMembership({ projectId: "p1", teamId: "t2", teamName: "Bravo" }),
238
+ ]);
239
+
240
+ expect(teamNamesOf(rows[0]!)).toEqual(["alpha", "Bravo", "Zulu"]);
241
+ });
242
+
243
+ it("lists a team once even if the same membership pair comes back twice", () => {
244
+ const rows: Array<UserProjectMembership> =
245
+ TeamMembersByProject.groupByProject([
246
+ buildMembership({ projectId: "p1", teamId: "t1", teamName: "Ops" }),
247
+ buildMembership({ projectId: "p1", teamId: "t1", teamName: "Ops" }),
248
+ ]);
249
+
250
+ expect(teamNamesOf(rows[0]!)).toEqual(["Ops"]);
251
+ });
252
+
253
+ it("de-dupes on the team's own id when the membership has no teamId", () => {
254
+ const first: TeamMember = buildMembership({
255
+ projectId: "p1",
256
+ teamId: "t1",
257
+ teamName: "Ops",
258
+ });
259
+ const second: TeamMember = buildMembership({
260
+ projectId: "p1",
261
+ teamId: "t1",
262
+ teamName: "Ops",
263
+ });
264
+
265
+ // The select asked for the joined team but not the foreign key.
266
+ delete first.teamId;
267
+ delete second.teamId;
268
+
269
+ const rows: Array<UserProjectMembership> =
270
+ TeamMembersByProject.groupByProject([first, second]);
271
+
272
+ expect(teamNamesOf(rows[0]!)).toEqual(["Ops"]);
273
+ });
274
+
275
+ it("keeps a row whose membership has no team, with an empty team list", () => {
276
+ const rows: Array<UserProjectMembership> =
277
+ TeamMembersByProject.groupByProject([
278
+ buildMembership({ projectId: "p1", projectName: "Acme" }),
279
+ ]);
280
+
281
+ expect(rows).toHaveLength(1);
282
+ expect(rows[0]!.teams).toEqual([]);
283
+ });
284
+
285
+ it("never merges memberships that carry no project, however many there are", () => {
286
+ const rows: Array<UserProjectMembership> =
287
+ TeamMembersByProject.groupByProject([new TeamMember(), new TeamMember()]);
288
+
289
+ expect(rows).toHaveLength(2);
290
+ });
291
+
292
+ it("fills in the project from a later membership when the first one lacked it", () => {
293
+ /*
294
+ * The relation can be missing on one membership and present on the next
295
+ * (nullified relation, or a partial select). Taking the first membership's
296
+ * project verbatim would leave the row nameless while the data to name it
297
+ * was right there.
298
+ */
299
+ const withoutProject: TeamMember = buildMembership({
300
+ projectId: "p1",
301
+ teamId: "t1",
302
+ });
303
+ delete withoutProject.project;
304
+
305
+ const rows: Array<UserProjectMembership> =
306
+ TeamMembersByProject.groupByProject([
307
+ withoutProject,
308
+ buildMembership({
309
+ projectId: "p1",
310
+ projectName: "Acme Production",
311
+ teamId: "t2",
312
+ }),
313
+ ]);
314
+
315
+ expect(rows).toHaveLength(1);
316
+ expect(TeamMembersByProject.getProjectName(rows[0]!)).toBe(
317
+ "Acme Production",
318
+ );
319
+ });
320
+
321
+ it("fills in the projectId from a later membership when it was grouped on the joined project", () => {
322
+ const rows: Array<UserProjectMembership> =
323
+ TeamMembersByProject.groupByProject([
324
+ buildMembership({ projectIdOnProjectObject: "p1", teamId: "t1" }),
325
+ buildMembership({ projectId: "p1", teamId: "t2" }),
326
+ ]);
327
+
328
+ expect(rows).toHaveLength(1);
329
+ expect(rows[0]!.projectId?.toString()).toBe("p1");
330
+ });
331
+ });
332
+
333
+ describe("TeamMembersByProject.groupByProject — membership status", () => {
334
+ it("counts the user as a member of the project as soon as one invitation is accepted", () => {
335
+ const rows: Array<UserProjectMembership> =
336
+ TeamMembersByProject.groupByProject([
337
+ buildMembership({
338
+ projectId: "p1",
339
+ teamId: "t1",
340
+ hasAcceptedInvitation: false,
341
+ }),
342
+ buildMembership({
343
+ projectId: "p1",
344
+ teamId: "t2",
345
+ hasAcceptedInvitation: true,
346
+ }),
347
+ ]);
348
+
349
+ expect(rows[0]!.hasAcceptedInvitation).toBe(true);
350
+ expect(rows[0]!.pendingTeamCount).toBe(1);
351
+ });
352
+
353
+ it("leaves a user with no accepted invitation as an invitee", () => {
354
+ const rows: Array<UserProjectMembership> =
355
+ TeamMembersByProject.groupByProject([
356
+ buildMembership({
357
+ projectId: "p1",
358
+ teamId: "t1",
359
+ hasAcceptedInvitation: false,
360
+ }),
361
+ buildMembership({
362
+ projectId: "p1",
363
+ teamId: "t2",
364
+ hasAcceptedInvitation: false,
365
+ }),
366
+ ]);
367
+
368
+ expect(rows[0]!.hasAcceptedInvitation).toBe(false);
369
+ expect(rows[0]!.pendingTeamCount).toBe(2);
370
+ });
371
+
372
+ it("reports no pending invitations when every membership is accepted", () => {
373
+ const rows: Array<UserProjectMembership> =
374
+ TeamMembersByProject.groupByProject([
375
+ buildMembership({ projectId: "p1", teamId: "t1" }),
376
+ buildMembership({ projectId: "p1", teamId: "t2" }),
377
+ ]);
378
+
379
+ expect(rows[0]!.hasAcceptedInvitation).toBe(true);
380
+ expect(rows[0]!.pendingTeamCount).toBe(0);
381
+ });
382
+
383
+ it("keeps the status of one project out of another", () => {
384
+ const rows: Array<UserProjectMembership> =
385
+ TeamMembersByProject.groupByProject([
386
+ buildMembership({
387
+ projectId: "p1",
388
+ teamId: "t1",
389
+ hasAcceptedInvitation: true,
390
+ }),
391
+ buildMembership({
392
+ projectId: "p2",
393
+ teamId: "t2",
394
+ hasAcceptedInvitation: false,
395
+ }),
396
+ ]);
397
+
398
+ expect(rows[0]!.hasAcceptedInvitation).toBe(true);
399
+ expect(rows[1]!.hasAcceptedInvitation).toBe(false);
400
+ expect(rows[1]!.pendingTeamCount).toBe(1);
401
+ });
402
+ });
403
+
404
+ describe("TeamMembersByProject.groupByProject — joinedAt", () => {
405
+ it("reports the earliest membership as when the user joined the project", () => {
406
+ const rows: Array<UserProjectMembership> =
407
+ TeamMembersByProject.groupByProject([
408
+ buildMembership({
409
+ projectId: "p1",
410
+ teamId: "t1",
411
+ createdAt: new Date("2024-06-01T00:00:00.000Z"),
412
+ }),
413
+ buildMembership({
414
+ projectId: "p1",
415
+ teamId: "t2",
416
+ createdAt: new Date("2024-01-15T00:00:00.000Z"),
417
+ }),
418
+ ]);
419
+
420
+ expect(rows[0]!.joinedAt?.toISOString()).toBe("2024-01-15T00:00:00.000Z");
421
+ });
422
+
423
+ it("leaves joinedAt unset when no membership carries a date", () => {
424
+ const rows: Array<UserProjectMembership> =
425
+ TeamMembersByProject.groupByProject([
426
+ buildMembership({ projectId: "p1", teamId: "t1" }),
427
+ ]);
428
+
429
+ expect(rows[0]!.joinedAt).toBeUndefined();
430
+ });
431
+
432
+ it("takes the only date there is when some memberships have none", () => {
433
+ const rows: Array<UserProjectMembership> =
434
+ TeamMembersByProject.groupByProject([
435
+ buildMembership({ projectId: "p1", teamId: "t1" }),
436
+ buildMembership({
437
+ projectId: "p1",
438
+ teamId: "t2",
439
+ createdAt: new Date("2024-03-03T00:00:00.000Z"),
440
+ }),
441
+ ]);
442
+
443
+ expect(rows[0]!.joinedAt?.toISOString()).toBe("2024-03-03T00:00:00.000Z");
444
+ });
445
+ });
446
+
447
+ describe("TeamMembersByProject.groupByProject — row identity", () => {
448
+ /*
449
+ * The row's id identifies the PROJECT the user is in, and the table matches
450
+ * a selected row to an on-screen row by that id alone. A first-seen id would
451
+ * change whenever the server returned the memberships in a different order,
452
+ * so the same row would carry different ids between two reads and its
453
+ * checkbox — and its delete — would silently target nothing. Hence: lowest
454
+ * membership id, independent of arrival order.
455
+ */
456
+ it("gives a row the same id whatever order the memberships arrive in", () => {
457
+ const oneOrder: Array<UserProjectMembership> =
458
+ TeamMembersByProject.groupByProject([
459
+ buildMembership({ id: "member-3", projectId: "p1", teamId: "t3" }),
460
+ buildMembership({ id: "member-1", projectId: "p1", teamId: "t1" }),
461
+ buildMembership({ id: "member-2", projectId: "p1", teamId: "t2" }),
462
+ ]);
463
+
464
+ const otherOrder: Array<UserProjectMembership> =
465
+ TeamMembersByProject.groupByProject([
466
+ buildMembership({ id: "member-2", projectId: "p1", teamId: "t2" }),
467
+ buildMembership({ id: "member-3", projectId: "p1", teamId: "t3" }),
468
+ buildMembership({ id: "member-1", projectId: "p1", teamId: "t1" }),
469
+ ]);
470
+
471
+ expect(oneOrder[0]!.id).toBe("member-1");
472
+ expect(otherOrder[0]!.id).toBe(oneOrder[0]!.id);
473
+ });
474
+
475
+ it("keeps the row id pointing at a real membership of that project", () => {
476
+ const rows: Array<UserProjectMembership> =
477
+ TeamMembersByProject.groupByProject([
478
+ buildMembership({ id: "member-9", projectId: "p1", teamId: "t1" }),
479
+ buildMembership({ id: "member-4", projectId: "p1", teamId: "t2" }),
480
+ ]);
481
+
482
+ expect(["member-9", "member-4"]).toContain(rows[0]!.id);
483
+ });
484
+
485
+ it("leaves a single-membership row on its own id", () => {
486
+ const rows: Array<UserProjectMembership> =
487
+ TeamMembersByProject.groupByProject([
488
+ buildMembership({ id: "member-7", projectId: "p1", teamId: "t1" }),
489
+ ]);
490
+
491
+ expect(rows[0]!.id).toBe("member-7");
492
+ });
493
+
494
+ it("gives different projects different row ids", () => {
495
+ const rows: Array<UserProjectMembership> =
496
+ TeamMembersByProject.groupByProject([
497
+ buildMembership({ id: "member-1", projectId: "p1", teamId: "t1" }),
498
+ buildMembership({ id: "member-2", projectId: "p2", teamId: "t1" }),
499
+ buildMembership({ id: "member-3", projectId: "p1", teamId: "t2" }),
500
+ ]);
501
+
502
+ expect(rows[0]!.id).not.toBe(rows[1]!.id);
503
+ });
504
+
505
+ it("does not invent an id for a row whose memberships have none", () => {
506
+ const rows: Array<UserProjectMembership> =
507
+ TeamMembersByProject.groupByProject([
508
+ buildMembership({ projectId: "p1", teamId: "t1" }),
509
+ buildMembership({ projectId: "p1", teamId: "t2" }),
510
+ ]);
511
+
512
+ expect(rows[0]!.id).toBeUndefined();
513
+ });
514
+
515
+ /*
516
+ * Removing the user from the project deletes every one of these, so a row
517
+ * that forgot one would leave the person half-removed.
518
+ */
519
+ it("carries every membership id folded into the row", () => {
520
+ const rows: Array<UserProjectMembership> =
521
+ TeamMembersByProject.groupByProject([
522
+ buildMembership({ id: "member-1", projectId: "p1", teamId: "t1" }),
523
+ buildMembership({ id: "member-2", projectId: "p1", teamId: "t2" }),
524
+ buildMembership({ id: "member-3", projectId: "p2", teamId: "t3" }),
525
+ ]);
526
+
527
+ expect(rows[0]!.teamMemberIds).toEqual(["member-1", "member-2"]);
528
+ expect(rows[1]!.teamMemberIds).toEqual(["member-3"]);
529
+ });
530
+ });
531
+
532
+ describe("TeamMembersByProject.sortByProjectName", () => {
533
+ it("sorts by project name, ignoring case", () => {
534
+ const rows: Array<UserProjectMembership> =
535
+ TeamMembersByProject.groupByProject([
536
+ buildMembership({ projectId: "p1", projectName: "zebra corp" }),
537
+ buildMembership({ projectId: "p2", projectName: "Acme Production" }),
538
+ buildMembership({ projectId: "p3", projectName: "middle ground" }),
539
+ ]);
540
+
541
+ expect(
542
+ projectNamesOf(TeamMembersByProject.sortByProjectName(rows)),
543
+ ).toEqual(["Acme Production", "middle ground", "zebra corp"]);
544
+ });
545
+
546
+ it("falls back to the project id for a project with no name", () => {
547
+ const rows: Array<UserProjectMembership> =
548
+ TeamMembersByProject.groupByProject([
549
+ buildMembership({ projectId: "p1" }),
550
+ ]);
551
+
552
+ expect(TeamMembersByProject.getProjectName(rows[0]!)).toBe("p1");
553
+ });
554
+
555
+ it("puts rows with nothing to sort on last rather than at the top", () => {
556
+ const rows: Array<UserProjectMembership> =
557
+ TeamMembersByProject.groupByProject([
558
+ new TeamMember(),
559
+ buildMembership({ projectId: "p1", projectName: "Acme Production" }),
560
+ ]);
561
+
562
+ const sorted: Array<UserProjectMembership> =
563
+ TeamMembersByProject.sortByProjectName(rows);
564
+
565
+ expect(TeamMembersByProject.getProjectName(sorted[0]!)).toBe(
566
+ "Acme Production",
567
+ );
568
+ expect(TeamMembersByProject.getProjectName(sorted[1]!)).toBe("");
569
+ });
570
+
571
+ it("does not reorder the array it was given", () => {
572
+ const rows: Array<UserProjectMembership> =
573
+ TeamMembersByProject.groupByProject([
574
+ buildMembership({ projectId: "p1", projectName: "Zebra" }),
575
+ buildMembership({ projectId: "p2", projectName: "Acme" }),
576
+ ]);
577
+
578
+ TeamMembersByProject.sortByProjectName(rows);
579
+
580
+ expect(TeamMembersByProject.getProjectName(rows[0]!)).toBe("Zebra");
581
+ });
582
+ });
583
+
584
+ describe("what the User > Projects page renders", () => {
585
+ it("shows a user on six teams across three projects as three rows", () => {
586
+ const rows: Array<UserProjectMembership> =
587
+ TeamMembersByProject.sortByProjectName(
588
+ TeamMembersByProject.groupByProject([
589
+ buildMembership({
590
+ projectId: "prod",
591
+ projectName: "Acme Production",
592
+ teamId: "owners",
593
+ teamName: "Owners",
594
+ }),
595
+ buildMembership({
596
+ projectId: "staging",
597
+ projectName: "Acme Staging",
598
+ teamId: "eng",
599
+ teamName: "Engineering",
600
+ }),
601
+ buildMembership({
602
+ projectId: "prod",
603
+ projectName: "Acme Production",
604
+ teamId: "eng",
605
+ teamName: "Engineering",
606
+ }),
607
+ buildMembership({
608
+ projectId: "sandbox",
609
+ projectName: "Sandbox",
610
+ teamId: "invited",
611
+ teamName: "Support",
612
+ hasAcceptedInvitation: false,
613
+ }),
614
+ buildMembership({
615
+ projectId: "prod",
616
+ projectName: "Acme Production",
617
+ teamId: "support",
618
+ teamName: "Support",
619
+ }),
620
+ buildMembership({
621
+ projectId: "staging",
622
+ projectName: "Acme Staging",
623
+ teamId: "owners-staging",
624
+ teamName: "Owners",
625
+ }),
626
+ ]),
627
+ );
628
+
629
+ expect(
630
+ rows.map((row: UserProjectMembership) => {
631
+ return {
632
+ project: TeamMembersByProject.getProjectName(row),
633
+ teams: teamNamesOf(row),
634
+ isMember: row.hasAcceptedInvitation,
635
+ };
636
+ }),
637
+ ).toEqual([
638
+ {
639
+ project: "Acme Production",
640
+ teams: ["Engineering", "Owners", "Support"],
641
+ isMember: true,
642
+ },
643
+ {
644
+ project: "Acme Staging",
645
+ teams: ["Engineering", "Owners"],
646
+ isMember: true,
647
+ },
648
+ {
649
+ project: "Sandbox",
650
+ teams: ["Support"],
651
+ isMember: false,
652
+ },
653
+ ]);
654
+ });
655
+ });
@@ -12,7 +12,15 @@ enum ColumnLength {
12
12
  Password = 500,
13
13
  ShortURL = 100,
14
14
  ShortText = 100,
15
- HashedString = 64,
15
+ /*
16
+ * Wide enough for a self-describing password hash, not just a bare digest.
17
+ * User passwords are stored as `scrypt$N=...,r=...,p=...$<64 hex>` (~90
18
+ * characters) so the cost parameters travel with the hash and can be
19
+ * raised later without a migration. The columns that still store a plain
20
+ * SHA-256 digest — session refresh tokens, master passwords — simply do
21
+ * not use the extra room.
22
+ */
23
+ HashedString = 255,
16
24
  Phone = 30,
17
25
  OTP = 8,
18
26
  }
@@ -16,6 +16,17 @@ export interface TableColumnMetadata {
16
16
  unique?: boolean;
17
17
  computed?: boolean;
18
18
  hashed?: boolean;
19
+ /*
20
+ * Name of the sibling column that holds this column's per-record salt.
21
+ *
22
+ * When set, the write path generates a fresh random salt on every write of
23
+ * this column, stores it in the named column, and mixes it into the hash.
24
+ * Two records with the same plaintext then hash to different values, so one
25
+ * precomputed table (or one cracked value) buys an attacker nothing
26
+ * anywhere else. The salt column must be nullable — rows written before the
27
+ * salt existed have none and verify against the legacy unsalted scheme.
28
+ */
29
+ hashSaltColumn?: string;
19
30
  encrypted?: boolean;
20
31
  manyToOneRelationColumn?: string;
21
32
  type: TableColumnType;