@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,306 @@
1
+ import { EncryptionSecret } from "../EnvironmentConfig";
2
+ import BadDataException from "../../Types/Exception/BadDataException";
3
+ import HashedString from "../../Types/HashedString";
4
+ import crypto from "crypto";
5
+
6
+ /*
7
+ * Password hashing for user credentials.
8
+ *
9
+ * WHY THIS IS NOT A PLAIN HASH
10
+ *
11
+ * SHA-256 is designed to be fast, and that is exactly wrong for a password.
12
+ * A commodity GPU computes billions of SHA-256 digests per second, so a
13
+ * leaked User table is a dictionary attack that finishes over lunch — a
14
+ * per-user salt stops the attacker amortising that work across accounts, but
15
+ * it does nothing to slow down any single guess.
16
+ *
17
+ * scrypt is deliberately expensive in BOTH time and memory. The memory cost
18
+ * is the important half: it is what stops an attacker buying a thousandfold
19
+ * speedup with GPUs or custom silicon, because memory bandwidth is the one
20
+ * thing that hardware cannot cheaply parallelise.
21
+ *
22
+ * THE CONSTRUCTION
23
+ *
24
+ * input = HMAC-SHA256(key = EncryptionSecret, message = password)
25
+ * derived = scrypt(input, salt, 32 bytes, { N, r, p })
26
+ *
27
+ * The HMAC step carries the instance-wide EncryptionSecret as a "pepper".
28
+ * The pepper lives in configuration, not in the database, so a dump of
29
+ * Postgres alone is not enough to start guessing — the attacker also needs
30
+ * the deployment's secret. It also normalises the password to a fixed 32
31
+ * bytes before scrypt sees it, which sidesteps every question about very
32
+ * long or oddly-encoded inputs.
33
+ *
34
+ * THE STORED FORMAT
35
+ *
36
+ * scrypt$N=16384,r=8,p=1$<64 hex characters>
37
+ *
38
+ * The hash carries the algorithm and the cost parameters it was produced
39
+ * with. That is what makes raising the cost later a non-event: old hashes
40
+ * keep verifying against the parameters recorded in them, and each user's
41
+ * hash is re-derived at their next successful login. Nothing about a cost
42
+ * bump requires a migration or a forced password reset.
43
+ *
44
+ * The salt is NOT in this string — it lives in the row's own salt column
45
+ * (see TableColumn.hashSaltColumn), which is where the previous scheme put
46
+ * it and where the write path keeps it in step with the hash.
47
+ */
48
+
49
+ /*
50
+ * N is the CPU/memory cost. Memory used is 128 * N * r bytes, so the values
51
+ * below cost 16 MiB and roughly 50ms per hash on current server hardware.
52
+ *
53
+ * That is a deliberate middle: enough to make offline guessing expensive,
54
+ * cheap enough that a burst of logins does not exhaust the libuv thread pool
55
+ * or the box's memory. Raising N is a one-line change — bump it here, and
56
+ * every user is migrated to the new cost as they log in.
57
+ */
58
+ const CURRENT_PARAMS: ScryptParams = {
59
+ N: 16384,
60
+ r: 8,
61
+ p: 1,
62
+ };
63
+
64
+ /** Bytes of derived key. 32 bytes = the 64 hex characters stored. */
65
+ const KEY_LENGTH_IN_BYTES: number = 32;
66
+
67
+ const SCHEME_NAME: string = "scrypt";
68
+
69
+ /*
70
+ * Refuse absurd parameters read back out of the database. Stored values are
71
+ * ours, not an attacker's, but a corrupted row must fail the login rather
72
+ * than ask Node to allocate gigabytes.
73
+ */
74
+ const MAX_N: number = 1048576; // 2^20
75
+ const MAX_R: number = 32;
76
+ const MAX_P: number = 16;
77
+
78
+ export interface ScryptParams {
79
+ N: number;
80
+ r: number;
81
+ p: number;
82
+ }
83
+
84
+ interface ParsedPasswordHash {
85
+ params: ScryptParams;
86
+ derivedKey: string;
87
+ }
88
+
89
+ export default class PasswordHash {
90
+ /**
91
+ * A fresh, cryptographically random salt: 32 bytes as 64 hex characters.
92
+ *
93
+ * A salt is not a secret — its one job is to be UNIQUE per user, so that no
94
+ * precomputed table and no cracked password is ever worth anything against
95
+ * a second account.
96
+ */
97
+ public static generateSalt(): string {
98
+ return crypto.randomBytes(32).toString("hex");
99
+ }
100
+
101
+ /** The `algorithm$params$` prefix that hashes written today carry. */
102
+ public static getCurrentPrefix(): string {
103
+ return `${SCHEME_NAME}$${PasswordHash.encodeParams(CURRENT_PARAMS)}$`;
104
+ }
105
+
106
+ public static getCurrentParams(): ScryptParams {
107
+ return { ...CURRENT_PARAMS };
108
+ }
109
+
110
+ private static encodeParams(params: ScryptParams): string {
111
+ return `N=${params.N},r=${params.r},p=${params.p}`;
112
+ }
113
+
114
+ /**
115
+ * Pull the parameters and derived key back out of a stored value.
116
+ *
117
+ * Returns null for anything that is not one of our scrypt hashes — which
118
+ * includes every password written before this scheme existed. Callers use
119
+ * null to mean "fall back to the legacy comparison", so this must never
120
+ * throw on unrecognised input.
121
+ */
122
+ private static parse(storedValue: string): ParsedPasswordHash | null {
123
+ const parts: Array<string> = storedValue.split("$");
124
+
125
+ if (parts.length !== 3 || parts[0] !== SCHEME_NAME) {
126
+ return null;
127
+ }
128
+
129
+ const match: RegExpMatchArray | null = (parts[1] as string).match(
130
+ /^N=(\d+),r=(\d+),p=(\d+)$/,
131
+ );
132
+
133
+ if (!match) {
134
+ return null;
135
+ }
136
+
137
+ const params: ScryptParams = {
138
+ N: Number(match[1]),
139
+ r: Number(match[2]),
140
+ p: Number(match[3]),
141
+ };
142
+
143
+ const isSane: boolean =
144
+ params.N > 1 &&
145
+ params.N <= MAX_N &&
146
+ (params.N & (params.N - 1)) === 0 && // scrypt requires a power of two
147
+ params.r > 0 &&
148
+ params.r <= MAX_R &&
149
+ params.p > 0 &&
150
+ params.p <= MAX_P;
151
+
152
+ if (!isSane || !parts[2]) {
153
+ return null;
154
+ }
155
+
156
+ return { params: params, derivedKey: parts[2] };
157
+ }
158
+
159
+ /**
160
+ * The pepper step. Keyed with the instance's EncryptionSecret, so the
161
+ * database alone cannot be attacked offline.
162
+ */
163
+ private static applyPepper(plainValue: string): Buffer {
164
+ return crypto
165
+ .createHmac("sha256", EncryptionSecret.toString())
166
+ .update(plainValue, "utf8")
167
+ .digest();
168
+ }
169
+
170
+ private static derive(data: {
171
+ plainValue: string;
172
+ salt: string;
173
+ params: ScryptParams;
174
+ }): Promise<string> {
175
+ const { N, r, p } = data.params;
176
+
177
+ return new Promise<string>(
178
+ (resolve: (value: string) => void, reject: (error: Error) => void) => {
179
+ crypto.scrypt(
180
+ PasswordHash.applyPepper(data.plainValue),
181
+ data.salt,
182
+ KEY_LENGTH_IN_BYTES,
183
+ {
184
+ N: N,
185
+ r: r,
186
+ p: p,
187
+ /*
188
+ * Node rejects the call outright when 128 * N * r exceeds
189
+ * maxmem, and its default is a flat 32 MiB. Scale the ceiling
190
+ * with the parameters instead, so raising N does not silently
191
+ * become "Invalid scrypt params" at runtime.
192
+ */
193
+ maxmem: 128 * N * r * 2,
194
+ },
195
+ (error: Error | null, derivedKey: Buffer): void => {
196
+ if (error) {
197
+ return reject(error);
198
+ }
199
+
200
+ return resolve(derivedKey.toString("hex"));
201
+ },
202
+ );
203
+ },
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Hash a password for storage, at today's cost parameters.
209
+ *
210
+ * The returned string records the algorithm and the parameters used, so it
211
+ * stays verifiable after those parameters change.
212
+ */
213
+ public static async hash(data: {
214
+ plainValue: string;
215
+ salt: string;
216
+ }): Promise<string> {
217
+ if (!data.plainValue) {
218
+ return "";
219
+ }
220
+
221
+ if (!data.salt) {
222
+ throw new BadDataException("A salt is required to hash a password.");
223
+ }
224
+
225
+ const derivedKey: string = await PasswordHash.derive({
226
+ plainValue: data.plainValue,
227
+ salt: data.salt,
228
+ params: CURRENT_PARAMS,
229
+ });
230
+
231
+ return `${PasswordHash.getCurrentPrefix()}${derivedKey}`;
232
+ }
233
+
234
+ /**
235
+ * Check a password against a stored value, whichever scheme wrote it.
236
+ *
237
+ * Three schemes are in circulation and all three have to keep working, or
238
+ * the change that introduced the next one locks people out:
239
+ *
240
+ * scrypt$... this one. Verified at the parameters recorded in the hash.
241
+ * salted SHA `SHA256("v2:" + salt + ":" + secret + ":" + password)`.
242
+ * bare SHA `SHA256(secret + password)`, from before salts existed.
243
+ *
244
+ * The last two are told apart by whether the row has a salt, which is the
245
+ * same rule they were written under. Anything not carrying a recognised
246
+ * scheme prefix is one of them.
247
+ */
248
+ public static async verify(data: {
249
+ plainValue: string;
250
+ storedValue: string;
251
+ salt?: string | null | undefined;
252
+ }): Promise<boolean> {
253
+ if (!data.plainValue || !data.storedValue) {
254
+ return false;
255
+ }
256
+
257
+ const parsed: ParsedPasswordHash | null = PasswordHash.parse(
258
+ data.storedValue,
259
+ );
260
+
261
+ if (!parsed) {
262
+ return await HashedString.verifyValue({
263
+ plainValue: data.plainValue,
264
+ hashedValue: data.storedValue,
265
+ encryptionSecret: EncryptionSecret,
266
+ salt: data.salt || null,
267
+ });
268
+ }
269
+
270
+ /*
271
+ * A scrypt hash is meaningless without the salt it was derived with.
272
+ * Reaching here without one means the caller did not select the salt
273
+ * column, which would otherwise look exactly like a wrong password.
274
+ */
275
+ if (!data.salt) {
276
+ throw new BadDataException(
277
+ "Cannot verify a salted password hash without its salt. Select the salt column alongside the hash.",
278
+ );
279
+ }
280
+
281
+ const derivedKey: string = await PasswordHash.derive({
282
+ plainValue: data.plainValue,
283
+ salt: data.salt,
284
+ params: parsed.params,
285
+ });
286
+
287
+ return HashedString.isEqual(derivedKey, parsed.derivedKey);
288
+ }
289
+
290
+ /**
291
+ * Should this stored value be re-hashed the next time the password is
292
+ * known to be correct?
293
+ *
294
+ * True for every hash that is not exactly what `hash()` writes today: the
295
+ * two SHA-256 schemes that came before, and any scrypt hash carrying
296
+ * parameters that are no longer current. This is the whole upgrade
297
+ * mechanism — raising the cost above is enough to start migrating users.
298
+ */
299
+ public static needsUpgrade(storedValue: string): boolean {
300
+ if (!storedValue) {
301
+ return false;
302
+ }
303
+
304
+ return !storedValue.startsWith(PasswordHash.getCurrentPrefix());
305
+ }
306
+ }
@@ -0,0 +1,333 @@
1
+ import { afterEach, describe, expect, jest, test } from "@jest/globals";
2
+ import "@testing-library/jest-dom";
3
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
4
+ import * as React from "react";
5
+ import getJestMockFunction, { MockFunction } from "../../MockType";
6
+
7
+ /*
8
+ * The card that puts a project's custom fields on a resource's OVERVIEW page.
9
+ *
10
+ * Every resource already had a dedicated "Custom Fields" page behind a side
11
+ * menu item, which is where the values went to be ignored. What this wrapper
12
+ * owns is the two things that make the same card safe to mount on a page
13
+ * nobody opened for custom fields: it disappears entirely for the projects
14
+ * that never defined one, and it names itself the same way on every resource.
15
+ */
16
+
17
+ const getListMock: MockFunction = getJestMockFunction();
18
+ const getItemMock: MockFunction = getJestMockFunction();
19
+ const getCurrentProjectIdMock: MockFunction = getJestMockFunction();
20
+
21
+ /*
22
+ * The arrow wrappers are load bearing: jest.mock is hoisted above the compiled
23
+ * requires, so naming the mocks directly would capture them before their
24
+ * initializers have run.
25
+ */
26
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
27
+ return {
28
+ __esModule: true,
29
+ default: {
30
+ getList: (...args: Array<any>) => {
31
+ return getListMock(...args);
32
+ },
33
+ getItem: (...args: Array<any>) => {
34
+ return getItemMock(...args);
35
+ },
36
+ updateById: () => {
37
+ return Promise.resolve();
38
+ },
39
+ },
40
+ };
41
+ });
42
+
43
+ jest.mock("../../../UI/Utils/Project", () => {
44
+ return {
45
+ __esModule: true,
46
+ default: {
47
+ getCurrentProjectId: (...args: Array<any>) => {
48
+ return getCurrentProjectIdMock(...args);
49
+ },
50
+ },
51
+ };
52
+ });
53
+
54
+ import OverviewCustomFields from "../../../../App/FeatureSet/Dashboard/src/Components/CustomFields/OverviewCustomFields";
55
+ import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
56
+ import Incident from "../../../Models/DatabaseModels/Incident";
57
+ import IncidentCustomField from "../../../Models/DatabaseModels/IncidentCustomField";
58
+ import StatusPage from "../../../Models/DatabaseModels/StatusPage";
59
+ import StatusPageCustomField from "../../../Models/DatabaseModels/StatusPageCustomField";
60
+ import CustomFieldType from "../../../Types/CustomField/CustomFieldType";
61
+ import { JSONObject } from "../../../Types/JSON";
62
+ import ObjectID from "../../../Types/ObjectID";
63
+
64
+ const PROJECT_ID: ObjectID = new ObjectID(
65
+ "11111111-1111-4111-8111-111111111111",
66
+ );
67
+ const INCIDENT_ID: ObjectID = new ObjectID(
68
+ "22222222-2222-4222-8222-222222222222",
69
+ );
70
+
71
+ type BuildSchemaFunction = (names: Array<string>) => Array<IncidentCustomField>;
72
+
73
+ const buildSchema: BuildSchemaFunction = (
74
+ names: Array<string>,
75
+ ): Array<IncidentCustomField> => {
76
+ return names.map((name: string) => {
77
+ const schemaItem: IncidentCustomField = new IncidentCustomField();
78
+ schemaItem.name = name;
79
+ schemaItem.customFieldType = CustomFieldType.Text;
80
+ return schemaItem;
81
+ });
82
+ };
83
+
84
+ type ResolveListFunction = (data: Array<BaseModel>) => void;
85
+
86
+ const resolveListWith: ResolveListFunction = (data: Array<BaseModel>): void => {
87
+ getListMock.mockResolvedValue({
88
+ data: data,
89
+ count: data.length,
90
+ skip: 0,
91
+ limit: data.length,
92
+ } as never);
93
+ };
94
+
95
+ type BuildIncidentFunction = (customFields: JSONObject) => Incident;
96
+
97
+ const buildIncident: BuildIncidentFunction = (
98
+ customFields: JSONObject,
99
+ ): Incident => {
100
+ const incident: Incident = new Incident();
101
+ incident.id = INCIDENT_ID;
102
+ incident.customFields = customFields;
103
+ return incident;
104
+ };
105
+
106
+ afterEach(() => {
107
+ cleanup();
108
+ getListMock.mockReset();
109
+ getItemMock.mockReset();
110
+ getCurrentProjectIdMock.mockReset();
111
+ });
112
+
113
+ describe("OverviewCustomFields", () => {
114
+ test("shows the project's fields and this record's values", async () => {
115
+ getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
116
+ resolveListWith(buildSchema(["Customer", "Escalated To"]));
117
+ getItemMock.mockResolvedValue(
118
+ buildIncident({
119
+ Customer: "Acme Corp",
120
+ "Escalated To": "Tier 2",
121
+ }) as never,
122
+ );
123
+
124
+ render(
125
+ <OverviewCustomFields
126
+ modelId={INCIDENT_ID}
127
+ modelType={Incident}
128
+ customFieldType={IncidentCustomField}
129
+ resourceName="Incident"
130
+ />,
131
+ );
132
+
133
+ expect(await screen.findByText("Acme Corp")).toBeInTheDocument();
134
+ expect(screen.getByText("Tier 2")).toBeInTheDocument();
135
+ expect(screen.getByText("Customer")).toBeInTheDocument();
136
+ expect(screen.getByText("Escalated To")).toBeInTheDocument();
137
+ });
138
+
139
+ test("titles the card the same way regardless of resource", async () => {
140
+ getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
141
+ resolveListWith(buildSchema(["Customer"]));
142
+ getItemMock.mockResolvedValue(
143
+ buildIncident({ Customer: "Acme Corp" }) as never,
144
+ );
145
+
146
+ render(
147
+ <OverviewCustomFields
148
+ modelId={INCIDENT_ID}
149
+ modelType={Incident}
150
+ customFieldType={IncidentCustomField}
151
+ resourceName="Incident"
152
+ />,
153
+ );
154
+
155
+ await screen.findByText("Acme Corp");
156
+
157
+ expect(screen.getByText("Custom Fields")).toBeInTheDocument();
158
+ expect(
159
+ screen.getByText("Custom fields for this incident."),
160
+ ).toBeInTheDocument();
161
+ });
162
+
163
+ test("lowercases a multi-word resource name in the description", async () => {
164
+ getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
165
+ resolveListWith(buildSchema(["Owner"]));
166
+ getItemMock.mockResolvedValue(
167
+ buildIncident({ Owner: "Platform" }) as never,
168
+ );
169
+
170
+ render(
171
+ <OverviewCustomFields
172
+ modelId={INCIDENT_ID}
173
+ modelType={StatusPage}
174
+ customFieldType={StatusPageCustomField}
175
+ resourceName="Status Page"
176
+ />,
177
+ );
178
+
179
+ await screen.findByText("Platform");
180
+
181
+ expect(
182
+ screen.getByText("Custom fields for this status page."),
183
+ ).toBeInTheDocument();
184
+ });
185
+
186
+ test("gives the Detail block an id derived from the resource", async () => {
187
+ getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
188
+ resolveListWith(buildSchema(["Customer"]));
189
+ getItemMock.mockResolvedValue(
190
+ buildIncident({ Customer: "Acme Corp" }) as never,
191
+ );
192
+
193
+ const { container } = render(
194
+ <OverviewCustomFields
195
+ modelId={INCIDENT_ID}
196
+ modelType={Incident}
197
+ customFieldType={IncidentCustomField}
198
+ resourceName="Incident"
199
+ />,
200
+ );
201
+
202
+ await screen.findByText("Acme Corp");
203
+
204
+ expect(
205
+ container.querySelector('[id="Incident Custom Fields"]'),
206
+ ).not.toBeNull();
207
+ });
208
+
209
+ /*
210
+ * The whole reason the overview can mount this unconditionally. Most
211
+ * projects never define a custom field, and they must not be shown an empty
212
+ * card explaining that.
213
+ */
214
+ test("renders nothing when the project defined no fields", async () => {
215
+ getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
216
+ resolveListWith([]);
217
+ getItemMock.mockResolvedValue(buildIncident({}) as never);
218
+
219
+ const { container } = render(
220
+ <OverviewCustomFields
221
+ modelId={INCIDENT_ID}
222
+ modelType={Incident}
223
+ customFieldType={IncidentCustomField}
224
+ resourceName="Incident"
225
+ />,
226
+ );
227
+
228
+ await waitFor(() => {
229
+ expect(getItemMock).toHaveBeenCalled();
230
+ });
231
+
232
+ expect(container).toBeEmptyDOMElement();
233
+ });
234
+
235
+ test("renders nothing when reading the fields fails", async () => {
236
+ getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
237
+ getListMock.mockRejectedValue(new Error("Upgrade your plan"));
238
+ getItemMock.mockResolvedValue(buildIncident({}) as never);
239
+
240
+ const { container } = render(
241
+ <OverviewCustomFields
242
+ modelId={INCIDENT_ID}
243
+ modelType={Incident}
244
+ customFieldType={IncidentCustomField}
245
+ resourceName="Incident"
246
+ />,
247
+ );
248
+
249
+ await waitFor(() => {
250
+ expect(getListMock).toHaveBeenCalled();
251
+ });
252
+
253
+ expect(screen.queryByText(/Upgrade your plan/i)).not.toBeInTheDocument();
254
+ expect(container).toBeEmptyDOMElement();
255
+ });
256
+
257
+ test("scopes the field lookup to the current project and the given record", async () => {
258
+ getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
259
+ resolveListWith(buildSchema(["Customer"]));
260
+ getItemMock.mockResolvedValue(
261
+ buildIncident({ Customer: "Acme Corp" }) as never,
262
+ );
263
+
264
+ render(
265
+ <OverviewCustomFields
266
+ modelId={INCIDENT_ID}
267
+ modelType={Incident}
268
+ customFieldType={IncidentCustomField}
269
+ resourceName="Incident"
270
+ />,
271
+ );
272
+
273
+ await screen.findByText("Acme Corp");
274
+
275
+ const listArgs: JSONObject = getListMock.mock.calls[0]![0] as JSONObject;
276
+ expect(listArgs["modelType"]).toBe(IncidentCustomField);
277
+ expect((listArgs["query"] as JSONObject)["projectId"]).toBe(PROJECT_ID);
278
+
279
+ const itemArgs: JSONObject = getItemMock.mock.calls[0]![0] as JSONObject;
280
+ expect(itemArgs["modelType"]).toBe(Incident);
281
+ expect(itemArgs["id"]).toBe(INCIDENT_ID);
282
+ });
283
+
284
+ test("renders nothing, and asks for nothing, without a current project", () => {
285
+ getCurrentProjectIdMock.mockReturnValue(null);
286
+
287
+ const { container } = render(
288
+ <OverviewCustomFields
289
+ modelId={INCIDENT_ID}
290
+ modelType={Incident}
291
+ customFieldType={IncidentCustomField}
292
+ resourceName="Incident"
293
+ />,
294
+ );
295
+
296
+ expect(container).toBeEmptyDOMElement();
297
+ expect(getListMock).not.toHaveBeenCalled();
298
+ expect(getItemMock).not.toHaveBeenCalled();
299
+ });
300
+
301
+ test("is editable by default and read only when asked", async () => {
302
+ getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
303
+ resolveListWith(buildSchema(["Customer"]));
304
+ getItemMock.mockResolvedValue(
305
+ buildIncident({ Customer: "Acme Corp" }) as never,
306
+ );
307
+
308
+ const { unmount } = render(
309
+ <OverviewCustomFields
310
+ modelId={INCIDENT_ID}
311
+ modelType={Incident}
312
+ customFieldType={IncidentCustomField}
313
+ resourceName="Incident"
314
+ />,
315
+ );
316
+
317
+ expect(await screen.findByText("Edit Fields")).toBeInTheDocument();
318
+ unmount();
319
+
320
+ render(
321
+ <OverviewCustomFields
322
+ modelId={INCIDENT_ID}
323
+ modelType={Incident}
324
+ customFieldType={IncidentCustomField}
325
+ resourceName="Incident"
326
+ isEditable={false}
327
+ />,
328
+ );
329
+
330
+ expect(await screen.findByText("Acme Corp")).toBeInTheDocument();
331
+ expect(screen.queryByText("Edit Fields")).not.toBeInTheDocument();
332
+ });
333
+ });