@oneuptime/common 12.0.24 → 12.0.25
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.
- package/Models/AnalyticsModels/Span.ts +101 -0
- package/Server/API/BaseAPI.ts +0 -24
- package/Server/API/SlackAPI.ts +0 -2
- package/Server/Middleware/SlackAuthorization.ts +96 -18
- package/Server/Utils/Telemetry/LlmMetricSpend.ts +56 -5
- package/Server/Utils/Telemetry/LlmSpan.ts +46 -0
- package/Server/Utils/Workspace/Slack/Actions/Auth.ts +0 -12
- package/Tests/App/Dashboard/LlmCallsTableIdentity.test.tsx +322 -0
- package/Tests/App/Dashboard/LlmOverview.test.tsx +335 -0
- package/Tests/App/Dashboard/LlmSpanDisplay.test.ts +391 -0
- package/Tests/App/Dashboard/LlmUsageBreakdown.test.tsx +1007 -0
- package/Tests/Server/API/BaseAPI.test.ts +41 -0
- package/Tests/Server/API/BaseAPIUpdatePayloadValidation.test.ts +9 -16
- package/Tests/Server/Middleware/SlackAuthorization.test.ts +262 -5
- package/Tests/Server/Utils/Telemetry/LlmCostBudgetEvaluator.test.ts +37 -18
- package/Tests/Server/Utils/Telemetry/LlmMetricSpend.test.ts +143 -2
- package/Tests/Server/Utils/Telemetry/LlmSpan.test.ts +804 -0
- package/Tests/Types/Telemetry/LlmMetricConventions.test.ts +391 -0
- package/Tests/Utils/Telemetry/LlmMetricQuery.test.ts +298 -0
- package/Types/Telemetry/LlmConventions.ts +255 -0
- package/Types/Telemetry/LlmMetricConventions.ts +212 -7
- package/Utils/Telemetry/LlmMetricQuery.ts +83 -0
- package/build/dist/Models/AnalyticsModels/Span.js +89 -0
- package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
- package/build/dist/Server/API/BaseAPI.js +3 -19
- package/build/dist/Server/API/BaseAPI.js.map +1 -1
- package/build/dist/Server/API/SlackAPI.js +0 -2
- package/build/dist/Server/API/SlackAPI.js.map +1 -1
- package/build/dist/Server/Middleware/SlackAuthorization.js +58 -7
- package/build/dist/Server/Middleware/SlackAuthorization.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LlmMetricSpend.js +52 -3
- package/build/dist/Server/Utils/Telemetry/LlmMetricSpend.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LlmSpan.js +24 -1
- package/build/dist/Server/Utils/Telemetry/LlmSpan.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/Slack/Actions/Auth.js +0 -10
- package/build/dist/Server/Utils/Workspace/Slack/Actions/Auth.js.map +1 -1
- package/build/dist/Types/Telemetry/LlmConventions.js +236 -0
- package/build/dist/Types/Telemetry/LlmConventions.js.map +1 -1
- package/build/dist/Types/Telemetry/LlmMetricConventions.js +197 -5
- package/build/dist/Types/Telemetry/LlmMetricConventions.js.map +1 -1
- package/build/dist/Utils/Telemetry/LlmMetricQuery.js +57 -1
- package/build/dist/Utils/Telemetry/LlmMetricQuery.js.map +1 -1
- package/package.json +1 -1
|
@@ -151,6 +151,7 @@ describe("BaseAPI", () => {
|
|
|
151
151
|
} as OneUptimeRequest;
|
|
152
152
|
|
|
153
153
|
beforeAll(async () => {
|
|
154
|
+
mockRouter.routes.length = 0;
|
|
154
155
|
mockRouter.post.mockClear();
|
|
155
156
|
mockRouter.get.mockClear();
|
|
156
157
|
mockRouter.put.mockClear();
|
|
@@ -208,11 +209,49 @@ describe("BaseAPI", () => {
|
|
|
208
209
|
expect.any(Function),
|
|
209
210
|
);
|
|
210
211
|
|
|
212
|
+
expect(Express.getRouter().post).toHaveBeenCalledWith(
|
|
213
|
+
"/mock/:id/update-item",
|
|
214
|
+
UserMiddleware.getUserMiddleware,
|
|
215
|
+
expect.any(Function),
|
|
216
|
+
);
|
|
217
|
+
|
|
211
218
|
expect(Express.getRouter().delete).toHaveBeenCalledWith(
|
|
212
219
|
"/mock/:id",
|
|
213
220
|
UserMiddleware.getUserMiddleware,
|
|
214
221
|
expect.any(Function),
|
|
215
222
|
);
|
|
223
|
+
|
|
224
|
+
expect(Express.getRouter().post).toHaveBeenCalledWith(
|
|
225
|
+
"/mock/:id/delete-item",
|
|
226
|
+
UserMiddleware.getUserMiddleware,
|
|
227
|
+
expect.any(Function),
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("does not register state-changing GET aliases", () => {
|
|
232
|
+
const registeredGetPaths: Array<string> = mockRouter.routes
|
|
233
|
+
.filter((route: { method: string }) => {
|
|
234
|
+
return route.method === "GET";
|
|
235
|
+
})
|
|
236
|
+
.map((route: { uri: string }) => {
|
|
237
|
+
return route.uri;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(registeredGetPaths).not.toContain("/mock/:id/update-item");
|
|
241
|
+
expect(registeredGetPaths).not.toContain("/mock/:id/delete-item");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("keeps read-only GET compatibility routes", () => {
|
|
245
|
+
const registeredGetPaths: Array<string> = mockRouter.routes
|
|
246
|
+
.filter((route: { method: string }) => {
|
|
247
|
+
return route.method === "GET";
|
|
248
|
+
})
|
|
249
|
+
.map((route: { uri: string }) => {
|
|
250
|
+
return route.uri;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(registeredGetPaths).toContain("/mock/get-list");
|
|
254
|
+
expect(registeredGetPaths).toContain("/mock/:id/get-item");
|
|
216
255
|
});
|
|
217
256
|
});
|
|
218
257
|
|
|
@@ -226,7 +265,9 @@ describe("BaseAPI", () => {
|
|
|
226
265
|
["POST", "/mock/:id/get-item", "getItem"],
|
|
227
266
|
["GET", "/mock/:id/get-item", "getItem"],
|
|
228
267
|
["PUT", "/mock/:id", "updateItem"],
|
|
268
|
+
["POST", "/mock/:id/update-item", "updateItem"],
|
|
229
269
|
["DELETE", "/mock/:id", "deleteItem"],
|
|
270
|
+
["POST", "/mock/:id/delete-item", "deleteItem"],
|
|
230
271
|
] as [string, string, string][];
|
|
231
272
|
|
|
232
273
|
for (const [method, uri, shouldBeCalled] of checkRoutes) {
|
|
@@ -261,11 +261,7 @@ describe("BaseAPI.updateItem payload validation", () => {
|
|
|
261
261
|
});
|
|
262
262
|
});
|
|
263
263
|
|
|
264
|
-
/*
|
|
265
|
-
* PUT /:id, POST /:id/update-item and GET /:id/update-item all land on this
|
|
266
|
-
* one handler, so none of them can be left answering 200 to a payload that
|
|
267
|
-
* writes nothing. GET carries no body at all.
|
|
268
|
-
*/
|
|
264
|
+
/* PUT /:id and POST /:id/update-item both land on this one handler. */
|
|
269
265
|
describe("across every route that reaches updateItem", () => {
|
|
270
266
|
it("refuses the flat body on the POST update-item route", async () => {
|
|
271
267
|
const request: OneUptimeRequest = requestWithBody({
|
|
@@ -278,15 +274,7 @@ describe("BaseAPI.updateItem payload validation", () => {
|
|
|
278
274
|
);
|
|
279
275
|
});
|
|
280
276
|
|
|
281
|
-
it("
|
|
282
|
-
await expect(
|
|
283
|
-
api.updateItem(requestWithBody({}), response),
|
|
284
|
-
).rejects.toThrow(BadDataException);
|
|
285
|
-
|
|
286
|
-
expect(service.updateOneById).not.toHaveBeenCalled();
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("registers all three update routes", () => {
|
|
277
|
+
it("registers only non-GET update routes", () => {
|
|
290
278
|
const crudPath: string = new Probe().getCrudApiPath()!.toString();
|
|
291
279
|
|
|
292
280
|
expect(mockRouter.match("PUT", `${crudPath}/:id`)).toBeTruthy();
|
|
@@ -294,8 +282,13 @@ describe("BaseAPI.updateItem payload validation", () => {
|
|
|
294
282
|
mockRouter.match("POST", `${crudPath}/:id/update-item`),
|
|
295
283
|
).toBeTruthy();
|
|
296
284
|
expect(
|
|
297
|
-
mockRouter.
|
|
298
|
-
|
|
285
|
+
mockRouter.routes.some((route: { method: string; uri: string }) => {
|
|
286
|
+
return (
|
|
287
|
+
route.method === "GET" &&
|
|
288
|
+
route.uri === `${crudPath}/:id/update-item`
|
|
289
|
+
);
|
|
290
|
+
}),
|
|
291
|
+
).toBe(false);
|
|
299
292
|
});
|
|
300
293
|
});
|
|
301
294
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { describe, expect, test
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
|
|
2
2
|
import crypto from "crypto";
|
|
3
|
+
import GlobalCache from "../../../Server/Infrastructure/GlobalCache";
|
|
3
4
|
import SlackAuthorization from "../../../Server/Middleware/SlackAuthorization";
|
|
4
5
|
import Response from "../../../Server/Utils/Response";
|
|
5
6
|
import {
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
OneUptimeRequest,
|
|
9
10
|
} from "../../../Server/Utils/Express";
|
|
10
11
|
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
12
|
+
import ServiceUnavailableException from "../../../Types/Exception/ServiceUnavailableException";
|
|
11
13
|
|
|
12
14
|
const SIGNING_SECRET: string = "slack-app-signing-secret";
|
|
13
15
|
|
|
@@ -28,6 +30,16 @@ jest.mock("../../../Server/Utils/Response", () => {
|
|
|
28
30
|
__esModule: true,
|
|
29
31
|
default: {
|
|
30
32
|
sendErrorResponse: jest.fn(),
|
|
33
|
+
sendTextResponse: jest.fn(),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
jest.mock("../../../Server/Infrastructure/GlobalCache", () => {
|
|
39
|
+
return {
|
|
40
|
+
__esModule: true,
|
|
41
|
+
default: {
|
|
42
|
+
setStringIfNotExists: jest.fn(),
|
|
31
43
|
},
|
|
32
44
|
};
|
|
33
45
|
});
|
|
@@ -41,11 +53,16 @@ jest.mock("../../../Server/Utils/Response", () => {
|
|
|
41
53
|
* have to come back as the intended 400.
|
|
42
54
|
*/
|
|
43
55
|
describe("SlackAuthorization.isAuthorizedSlackRequest", () => {
|
|
44
|
-
const
|
|
56
|
+
const NOW_IN_SECONDS: number = 1_800_000_000;
|
|
57
|
+
const TIMESTAMP: string = NOW_IN_SECONDS.toString();
|
|
45
58
|
const RAW_BODY: string = "token=abc&team_id=T123&command=%2Foneuptime";
|
|
46
59
|
|
|
47
60
|
const sendErrorResponse: jest.Mock =
|
|
48
61
|
Response.sendErrorResponse as unknown as jest.Mock;
|
|
62
|
+
const sendTextResponse: jest.Mock =
|
|
63
|
+
Response.sendTextResponse as unknown as jest.Mock;
|
|
64
|
+
const setStringIfNotExists: jest.Mock =
|
|
65
|
+
GlobalCache.setStringIfNotExists as unknown as jest.Mock;
|
|
49
66
|
|
|
50
67
|
const res: ExpressResponse = {} as ExpressResponse;
|
|
51
68
|
let next: NextFunction;
|
|
@@ -62,15 +79,19 @@ describe("SlackAuthorization.isAuthorizedSlackRequest", () => {
|
|
|
62
79
|
type BuildRequestFunction = (
|
|
63
80
|
signature: string | undefined,
|
|
64
81
|
body?: string,
|
|
82
|
+
timestamp?: string | null,
|
|
65
83
|
) => OneUptimeRequest;
|
|
66
84
|
|
|
67
85
|
const buildRequest: BuildRequestFunction = (
|
|
68
86
|
signature: string | undefined,
|
|
69
87
|
body: string = RAW_BODY,
|
|
88
|
+
timestamp: string | null = TIMESTAMP,
|
|
70
89
|
): OneUptimeRequest => {
|
|
71
|
-
const headers: Record<string, string> = {
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
const headers: Record<string, string> = {};
|
|
91
|
+
|
|
92
|
+
if (timestamp !== null) {
|
|
93
|
+
headers["x-slack-request-timestamp"] = timestamp;
|
|
94
|
+
}
|
|
74
95
|
|
|
75
96
|
if (signature !== undefined) {
|
|
76
97
|
headers["x-slack-signature"] = signature;
|
|
@@ -81,9 +102,15 @@ describe("SlackAuthorization.isAuthorizedSlackRequest", () => {
|
|
|
81
102
|
|
|
82
103
|
beforeEach(() => {
|
|
83
104
|
jest.clearAllMocks();
|
|
105
|
+
jest.spyOn(Date, "now").mockReturnValue(NOW_IN_SECONDS * 1000);
|
|
106
|
+
setStringIfNotExists.mockResolvedValue(true);
|
|
84
107
|
next = jest.fn();
|
|
85
108
|
});
|
|
86
109
|
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
jest.restoreAllMocks();
|
|
112
|
+
});
|
|
113
|
+
|
|
87
114
|
test("calls next when the signature matches the timestamp and body", async () => {
|
|
88
115
|
const req: OneUptimeRequest = buildRequest(sign(TIMESTAMP, RAW_BODY));
|
|
89
116
|
|
|
@@ -91,6 +118,12 @@ describe("SlackAuthorization.isAuthorizedSlackRequest", () => {
|
|
|
91
118
|
|
|
92
119
|
expect(next).toHaveBeenCalled();
|
|
93
120
|
expect(sendErrorResponse).not.toHaveBeenCalled();
|
|
121
|
+
expect(setStringIfNotExists).toHaveBeenCalledWith(
|
|
122
|
+
"slack-request-replay",
|
|
123
|
+
sign(TIMESTAMP, RAW_BODY).slice("v0=".length),
|
|
124
|
+
TIMESTAMP,
|
|
125
|
+
{ expiresInSeconds: 5 * 60 + 1 },
|
|
126
|
+
);
|
|
94
127
|
});
|
|
95
128
|
|
|
96
129
|
test("rejects a well-formed signature computed over a different body", async () => {
|
|
@@ -107,6 +140,212 @@ describe("SlackAuthorization.isAuthorizedSlackRequest", () => {
|
|
|
107
140
|
res,
|
|
108
141
|
new BadDataException("Slack Signature Verification Failed."),
|
|
109
142
|
);
|
|
143
|
+
expect(setStringIfNotExists).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("accepts a request exactly five minutes old", async () => {
|
|
147
|
+
const timestamp: string = (NOW_IN_SECONDS - 5 * 60).toString();
|
|
148
|
+
const req: OneUptimeRequest = buildRequest(
|
|
149
|
+
sign(timestamp, RAW_BODY),
|
|
150
|
+
RAW_BODY,
|
|
151
|
+
timestamp,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
|
|
155
|
+
|
|
156
|
+
expect(next).toHaveBeenCalled();
|
|
157
|
+
expect(sendErrorResponse).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("accepts a request exactly five minutes in the future and covers the full replay window", async () => {
|
|
161
|
+
const timestamp: string = (NOW_IN_SECONDS + 5 * 60).toString();
|
|
162
|
+
const req: OneUptimeRequest = buildRequest(
|
|
163
|
+
sign(timestamp, RAW_BODY),
|
|
164
|
+
RAW_BODY,
|
|
165
|
+
timestamp,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
|
|
169
|
+
|
|
170
|
+
expect(next).toHaveBeenCalled();
|
|
171
|
+
expect(setStringIfNotExists).toHaveBeenCalledWith(
|
|
172
|
+
"slack-request-replay",
|
|
173
|
+
sign(timestamp, RAW_BODY).slice("v0=".length),
|
|
174
|
+
timestamp,
|
|
175
|
+
{ expiresInSeconds: 10 * 60 + 1 },
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("rejects a correctly signed request older than five minutes", async () => {
|
|
180
|
+
const timestamp: string = (NOW_IN_SECONDS - 5 * 60 - 1).toString();
|
|
181
|
+
const req: OneUptimeRequest = buildRequest(
|
|
182
|
+
sign(timestamp, RAW_BODY),
|
|
183
|
+
RAW_BODY,
|
|
184
|
+
timestamp,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
|
|
188
|
+
|
|
189
|
+
expect(next).not.toHaveBeenCalled();
|
|
190
|
+
expect(sendErrorResponse).toHaveBeenCalledWith(
|
|
191
|
+
req,
|
|
192
|
+
res,
|
|
193
|
+
new BadDataException("Slack Signature Verification Failed."),
|
|
194
|
+
);
|
|
195
|
+
expect(setStringIfNotExists).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("rejects a correctly signed request more than five minutes in the future", async () => {
|
|
199
|
+
const timestamp: string = (NOW_IN_SECONDS + 5 * 60 + 1).toString();
|
|
200
|
+
const req: OneUptimeRequest = buildRequest(
|
|
201
|
+
sign(timestamp, RAW_BODY),
|
|
202
|
+
RAW_BODY,
|
|
203
|
+
timestamp,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
|
|
207
|
+
|
|
208
|
+
expect(next).not.toHaveBeenCalled();
|
|
209
|
+
expect(sendErrorResponse).toHaveBeenCalledWith(
|
|
210
|
+
req,
|
|
211
|
+
res,
|
|
212
|
+
new BadDataException("Slack Signature Verification Failed."),
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("acknowledges a duplicate without invoking the Slack handler", async () => {
|
|
217
|
+
setStringIfNotExists.mockResolvedValue(false);
|
|
218
|
+
const req: OneUptimeRequest = buildRequest(sign(TIMESTAMP, RAW_BODY));
|
|
219
|
+
|
|
220
|
+
await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
|
|
221
|
+
|
|
222
|
+
expect(next).not.toHaveBeenCalled();
|
|
223
|
+
expect(sendErrorResponse).not.toHaveBeenCalled();
|
|
224
|
+
expect(sendTextResponse).toHaveBeenCalledWith(req, res, "");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("uses the canonical digest so signature casing cannot bypass replay detection", async () => {
|
|
228
|
+
const lowercaseSignature: string = sign(TIMESTAMP, RAW_BODY);
|
|
229
|
+
const uppercaseSignature: string = `v0=${lowercaseSignature
|
|
230
|
+
.slice("v0=".length)
|
|
231
|
+
.toUpperCase()}`;
|
|
232
|
+
const firstRequest: OneUptimeRequest = buildRequest(lowercaseSignature);
|
|
233
|
+
const replayRequest: OneUptimeRequest = buildRequest(uppercaseSignature);
|
|
234
|
+
|
|
235
|
+
setStringIfNotExists
|
|
236
|
+
.mockResolvedValueOnce(true)
|
|
237
|
+
.mockResolvedValueOnce(false);
|
|
238
|
+
|
|
239
|
+
await SlackAuthorization.isAuthorizedSlackRequest(firstRequest, res, next);
|
|
240
|
+
await SlackAuthorization.isAuthorizedSlackRequest(replayRequest, res, next);
|
|
241
|
+
|
|
242
|
+
const canonicalDigest: string = lowercaseSignature.slice("v0=".length);
|
|
243
|
+
expect(setStringIfNotExists).toHaveBeenNthCalledWith(
|
|
244
|
+
1,
|
|
245
|
+
"slack-request-replay",
|
|
246
|
+
canonicalDigest,
|
|
247
|
+
TIMESTAMP,
|
|
248
|
+
{ expiresInSeconds: 5 * 60 + 1 },
|
|
249
|
+
);
|
|
250
|
+
expect(setStringIfNotExists).toHaveBeenNthCalledWith(
|
|
251
|
+
2,
|
|
252
|
+
"slack-request-replay",
|
|
253
|
+
canonicalDigest,
|
|
254
|
+
TIMESTAMP,
|
|
255
|
+
{ expiresInSeconds: 5 * 60 + 1 },
|
|
256
|
+
);
|
|
257
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(sendTextResponse).toHaveBeenCalledWith(replayRequest, res, "");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("allows repeated URL verification requests to reach their idempotent handler", async () => {
|
|
262
|
+
const signature: string = sign(TIMESTAMP, RAW_BODY);
|
|
263
|
+
const firstRequest: OneUptimeRequest = buildRequest(signature);
|
|
264
|
+
const secondRequest: OneUptimeRequest = buildRequest(signature);
|
|
265
|
+
|
|
266
|
+
Object.assign(firstRequest, {
|
|
267
|
+
route: { path: "/slack/events" },
|
|
268
|
+
body: { type: "url_verification" },
|
|
269
|
+
});
|
|
270
|
+
Object.assign(secondRequest, {
|
|
271
|
+
route: { path: "/slack/events" },
|
|
272
|
+
body: { type: "url_verification" },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await SlackAuthorization.isAuthorizedSlackRequest(firstRequest, res, next);
|
|
276
|
+
await SlackAuthorization.isAuthorizedSlackRequest(secondRequest, res, next);
|
|
277
|
+
|
|
278
|
+
expect(next).toHaveBeenCalledTimes(2);
|
|
279
|
+
expect(setStringIfNotExists).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("allows repeated options loads to reach their idempotent handler", async () => {
|
|
283
|
+
const signature: string = sign(TIMESTAMP, RAW_BODY);
|
|
284
|
+
const firstRequest: OneUptimeRequest = buildRequest(signature);
|
|
285
|
+
const secondRequest: OneUptimeRequest = buildRequest(signature);
|
|
286
|
+
|
|
287
|
+
Object.assign(firstRequest, {
|
|
288
|
+
route: { path: "/slack/options-load" },
|
|
289
|
+
});
|
|
290
|
+
Object.assign(secondRequest, {
|
|
291
|
+
route: { path: "/slack/options-load" },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await SlackAuthorization.isAuthorizedSlackRequest(firstRequest, res, next);
|
|
295
|
+
await SlackAuthorization.isAuthorizedSlackRequest(secondRequest, res, next);
|
|
296
|
+
|
|
297
|
+
expect(next).toHaveBeenCalledTimes(2);
|
|
298
|
+
expect(setStringIfNotExists).not.toHaveBeenCalled();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("fails closed when distributed replay protection is unavailable", async () => {
|
|
302
|
+
setStringIfNotExists.mockRejectedValue(new Error("cache unavailable"));
|
|
303
|
+
const req: OneUptimeRequest = buildRequest(sign(TIMESTAMP, RAW_BODY));
|
|
304
|
+
|
|
305
|
+
await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
|
|
306
|
+
|
|
307
|
+
expect(next).not.toHaveBeenCalled();
|
|
308
|
+
expect(sendErrorResponse).toHaveBeenCalledWith(
|
|
309
|
+
req,
|
|
310
|
+
res,
|
|
311
|
+
new ServiceUnavailableException(
|
|
312
|
+
"Slack request verification is temporarily unavailable.",
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const malformedTimestamps: Array<[string, string | null]> = [
|
|
318
|
+
["absent", null],
|
|
319
|
+
["empty", ""],
|
|
320
|
+
["non-numeric", "not-a-timestamp"],
|
|
321
|
+
["partially numeric", `${TIMESTAMP}abc`],
|
|
322
|
+
["fractional", `${TIMESTAMP}.5`],
|
|
323
|
+
["negative", "-1"],
|
|
324
|
+
["outside the safe integer range", "999999999999999999999"],
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
describe("rejects a malformed timestamp", () => {
|
|
328
|
+
test.each(malformedTimestamps)(
|
|
329
|
+
"%s",
|
|
330
|
+
async (_label: string, timestamp: string | null) => {
|
|
331
|
+
const signedTimestamp: string = timestamp || TIMESTAMP;
|
|
332
|
+
const req: OneUptimeRequest = buildRequest(
|
|
333
|
+
sign(signedTimestamp, RAW_BODY),
|
|
334
|
+
RAW_BODY,
|
|
335
|
+
timestamp,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
|
|
339
|
+
|
|
340
|
+
expect(next).not.toHaveBeenCalled();
|
|
341
|
+
expect(sendErrorResponse).toHaveBeenCalledWith(
|
|
342
|
+
req,
|
|
343
|
+
res,
|
|
344
|
+
new BadDataException("Slack Signature Verification Failed."),
|
|
345
|
+
);
|
|
346
|
+
expect(setStringIfNotExists).not.toHaveBeenCalled();
|
|
347
|
+
},
|
|
348
|
+
);
|
|
110
349
|
});
|
|
111
350
|
|
|
112
351
|
/*
|
|
@@ -143,7 +382,25 @@ describe("SlackAuthorization.isAuthorizedSlackRequest", () => {
|
|
|
143
382
|
res,
|
|
144
383
|
new BadDataException("Slack Signature Verification Failed."),
|
|
145
384
|
);
|
|
385
|
+
expect(setStringIfNotExists).not.toHaveBeenCalled();
|
|
146
386
|
},
|
|
147
387
|
);
|
|
148
388
|
});
|
|
389
|
+
|
|
390
|
+
test("normalizes an array signature header without throwing", async () => {
|
|
391
|
+
const req: OneUptimeRequest = buildRequest(undefined);
|
|
392
|
+
req.headers["x-slack-signature"] = ["not-a-signature"];
|
|
393
|
+
|
|
394
|
+
await expect(
|
|
395
|
+
SlackAuthorization.isAuthorizedSlackRequest(req, res, next),
|
|
396
|
+
).resolves.toBeUndefined();
|
|
397
|
+
|
|
398
|
+
expect(next).not.toHaveBeenCalled();
|
|
399
|
+
expect(sendErrorResponse).toHaveBeenCalledWith(
|
|
400
|
+
req,
|
|
401
|
+
res,
|
|
402
|
+
new BadDataException("Slack Signature Verification Failed."),
|
|
403
|
+
);
|
|
404
|
+
expect(setStringIfNotExists).not.toHaveBeenCalled();
|
|
405
|
+
});
|
|
149
406
|
});
|
|
@@ -19,6 +19,7 @@ import LlmCostBudgetService from "../../../../Server/Services/LlmCostBudgetServi
|
|
|
19
19
|
import MetricService from "../../../../Server/Services/MetricService";
|
|
20
20
|
import SpanService from "../../../../Server/Services/SpanService";
|
|
21
21
|
import TelemetryUtil from "../../../../Server/Utils/Telemetry/Telemetry";
|
|
22
|
+
import { LlmMicroUsdCostMetricNames } from "../../../../Types/Telemetry/LlmMetricConventions";
|
|
22
23
|
import { LlmMetricScope } from "../../../../Utils/Telemetry/LlmMetricQuery";
|
|
23
24
|
/*
|
|
24
25
|
* `jest` deliberately comes from the global scope, not @jest/globals — the
|
|
@@ -84,6 +85,34 @@ const mockedMetricService: {
|
|
|
84
85
|
aggregateBy: MockedFn;
|
|
85
86
|
};
|
|
86
87
|
|
|
88
|
+
/*
|
|
89
|
+
* Metric-sourced cost is TWO aggregates, not one: the USD-denominated metric
|
|
90
|
+
* names and the micro-USD ones (the Codex CLI reports millionths of a dollar)
|
|
91
|
+
* are queried separately and scaled before being added, because a single Sum
|
|
92
|
+
* over both units cannot be un-mixed afterwards. A plain mockResolvedValue
|
|
93
|
+
* answers BOTH queries with the same payload, which would feed the same
|
|
94
|
+
* figure in twice — once at 1e-6 — and quietly skew every expectation here.
|
|
95
|
+
*
|
|
96
|
+
* So the mock dispatches on the metric names the query actually selects: the
|
|
97
|
+
* rows are the project's USD spend, and the micro-USD query correctly finds
|
|
98
|
+
* nothing.
|
|
99
|
+
*/
|
|
100
|
+
function mockUsdMetricRows(rows: Array<JSONObject>): void {
|
|
101
|
+
mockedMetricService.aggregateBy.mockImplementation(
|
|
102
|
+
(aggregate: { query: Record<string, unknown> }): Promise<JSONObject> => {
|
|
103
|
+
const names: Array<string> = ((
|
|
104
|
+
aggregate?.query?.["name"] as { values?: Array<string> } | undefined
|
|
105
|
+
)?.values || []) as Array<string>;
|
|
106
|
+
|
|
107
|
+
const isMicroUsd: boolean = names.some((name: string) => {
|
|
108
|
+
return LlmMicroUsdCostMetricNames.includes(name);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return Promise.resolve({ data: isMicroUsd ? [] : rows });
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
87
116
|
const mockedBudgetService: {
|
|
88
117
|
findBy: MockedFn;
|
|
89
118
|
updateOneById: MockedFn;
|
|
@@ -596,9 +625,7 @@ describe("LlmCostBudgetEvaluator.resolveSpend — source selection", () => {
|
|
|
596
625
|
}
|
|
597
626
|
|
|
598
627
|
function mockMetricSpend(value: number): void {
|
|
599
|
-
|
|
600
|
-
data: [{ timestamp: NOW, value: value }],
|
|
601
|
-
} as never);
|
|
628
|
+
mockUsdMetricRows([{ timestamp: NOW, value: value }]);
|
|
602
629
|
}
|
|
603
630
|
|
|
604
631
|
test("uses spans when the span stream reported spend", async () => {
|
|
@@ -730,12 +757,10 @@ describe("LlmCostBudgetEvaluator.resolveSpend — source selection", () => {
|
|
|
730
757
|
});
|
|
731
758
|
|
|
732
759
|
test("sums multiple metric buckets", async () => {
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
],
|
|
738
|
-
} as never);
|
|
760
|
+
mockUsdMetricRows([
|
|
761
|
+
{ timestamp: NOW, value: 1.5 },
|
|
762
|
+
{ timestamp: NOW, value: 2.5 },
|
|
763
|
+
]);
|
|
739
764
|
|
|
740
765
|
await expect(
|
|
741
766
|
LlmCostBudgetEvaluator.resolveSpend({
|
|
@@ -781,9 +806,7 @@ describe("LlmCostBudgetEvaluator.evaluateBudget — metric-sourced spend", () =>
|
|
|
781
806
|
});
|
|
782
807
|
|
|
783
808
|
test("a metrics-only project gets its spend stamped on the budget row", async () => {
|
|
784
|
-
|
|
785
|
-
data: [{ timestamp: NOW, value: 60 }],
|
|
786
|
-
} as never);
|
|
809
|
+
mockUsdMetricRows([{ timestamp: NOW, value: 60 }]);
|
|
787
810
|
|
|
788
811
|
const budget: LlmCostBudget = makeBudget({ dailyBudgetInUSD: 100 });
|
|
789
812
|
|
|
@@ -798,9 +821,7 @@ describe("LlmCostBudgetEvaluator.evaluateBudget — metric-sourced spend", () =>
|
|
|
798
821
|
});
|
|
799
822
|
|
|
800
823
|
test("a metrics-only project publishes the same budget series as a span-backed one", async () => {
|
|
801
|
-
|
|
802
|
-
data: [{ timestamp: NOW, value: 80 }],
|
|
803
|
-
} as never);
|
|
824
|
+
mockUsdMetricRows([{ timestamp: NOW, value: 80 }]);
|
|
804
825
|
|
|
805
826
|
await LlmCostBudgetEvaluator.evaluateBudget({
|
|
806
827
|
budget: makeBudget({ dailyBudgetInUSD: 100 }),
|
|
@@ -823,9 +844,7 @@ describe("LlmCostBudgetEvaluator.evaluateBudget — metric-sourced spend", () =>
|
|
|
823
844
|
* have to learn about the source.
|
|
824
845
|
*/
|
|
825
846
|
test("the published series carries no source-specific attribute", async () => {
|
|
826
|
-
|
|
827
|
-
data: [{ timestamp: NOW, value: 5 }],
|
|
828
|
-
} as never);
|
|
847
|
+
mockUsdMetricRows([{ timestamp: NOW, value: 5 }]);
|
|
829
848
|
|
|
830
849
|
const budget: LlmCostBudget = makeBudget();
|
|
831
850
|
|