@obsunified/collector 2.0.1 → 2.2.0
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/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
- package/dist/chunk-KMEZKGSO.js.map +1 -0
- package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
- package/dist/chunk-ZSF5XP3B.js.map +1 -0
- package/dist/index.d.ts +630 -27
- package/dist/index.js +5819 -853
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
- package/package.json +5 -5
- package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
- package/src/analyses/derive.ts +26 -25
- package/src/analyses/investigations.ts +8 -8
- package/src/analyses/tier0.ts +22 -22
- package/src/auth/dashboard-auth.test.ts +791 -0
- package/src/auth/dashboard-auth.ts +187 -38
- package/src/auth/login-rate-limit.test.ts +129 -0
- package/src/auth/login-rate-limit.ts +143 -0
- package/src/framework/body-limit.test.ts +165 -0
- package/src/framework/collector.ts +355 -115
- package/src/framework/env.ts +9 -0
- package/src/framework/limits.ts +31 -0
- package/src/framework/scheduled-handlers.test.ts +342 -0
- package/src/index.ts +59 -1
- package/src/lib/action-retention.test.ts +472 -0
- package/src/lib/action-retention.ts +420 -0
- package/src/lib/actions-store.ts +6 -12
- package/src/lib/ai-store/calls.ts +2 -2
- package/src/lib/ai-store/sessions.ts +3 -3
- package/src/lib/ai-store/spans.ts +1 -1
- package/src/lib/ai-store/types.ts +2 -1
- package/src/lib/alerts-store.test.ts +153 -0
- package/src/lib/alerts-store.ts +122 -21
- package/src/lib/analyses-runner.test.ts +75 -0
- package/src/lib/analyses-runner.ts +69 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/autosre-change-correlation.ts +780 -0
- package/src/lib/autosre-findings.ts +725 -0
- package/src/lib/autosre-ontology.ts +914 -0
- package/src/lib/autosre-remediation-gate.ts +1663 -0
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/eval-cases-store.ts +63 -1
- package/src/lib/evidence-references.ts +6 -0
- package/src/lib/housekeeping.test.ts +153 -0
- package/src/lib/housekeeping.ts +193 -0
- package/src/lib/identity-index/action-lookups.ts +6 -6
- package/src/lib/identity-index/key-lookups.ts +2 -2
- package/src/lib/identity-index/user-lookup.ts +8 -5
- package/src/lib/identity-index.test.ts +2 -3
- package/src/lib/metrics-store-series.test.ts +99 -0
- package/src/lib/metrics-store.test.ts +23 -0
- package/src/lib/metrics-store.ts +27 -3
- package/src/lib/projects-store.test.ts +25 -0
- package/src/lib/projects-store.ts +4 -3
- package/src/lib/sql-db-dialect.test.ts +12 -3
- package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
- package/src/lib/sql-db-postgres.test.ts +314 -0
- package/src/lib/sql-db-postgres.ts +392 -54
- package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
- package/src/lib/sql-db.ts +40 -6
- package/src/lib/store/ingest.ts +94 -6
- package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
- package/src/lib/test-utils/node-sqlite-db.ts +188 -0
- package/src/lib/webhook-url.test.ts +122 -0
- package/src/lib/webhook-url.ts +155 -0
- package/src/migrations/041_ingest_key_hash_text.sql +5 -0
- package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
- package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
- package/src/migrations/044_actions_started_at_index.sql +10 -0
- package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
- package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
- package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
- package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
- package/src/migrations/049_alert_state_notified.sql +17 -0
- package/src/migrations/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +68 -0
- package/src/migrations/project-scoped-keys.test.ts +432 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
- package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
- package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
- package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
- package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
- package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
- package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
- package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
- package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
- package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
- package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
- package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations-postgres/README.md +58 -5
- package/src/otlp/decode/body.test.ts +76 -0
- package/src/otlp/decode/body.ts +67 -6
- package/src/plugins/_json.ts +21 -0
- package/src/plugins/action-graph-processor.ts +6 -12
- package/src/plugins/ai-receiver.ts +5 -12
- package/src/plugins/ai-span-payloads-processor.ts +1 -2
- package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
- package/src/plugins/alerts-evaluator.test.ts +464 -3
- package/src/plugins/alerts-evaluator.ts +181 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/autosre-routes.ts +1019 -0
- package/src/plugins/connected-routes/manifest.ts +13 -1
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +285 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/default-span-enrichment.ts +13 -1
- package/src/plugins/eval-cases-routes.ts +65 -19
- package/src/plugins/evidence-retrieval-routes.ts +300 -4
- package/src/plugins/identity-receiver.ts +9 -11
- package/src/plugins/json-body.test.ts +91 -0
- package/src/plugins/onboarding-routes.test.ts +105 -0
- package/src/plugins/onboarding-routes.ts +26 -11
- package/src/plugins/projects-routes.ts +2 -1
- package/src/plugins/replay-isolation.test.ts +922 -0
- package/src/plugins/replay-receiver.ts +8 -8
- package/src/plugins/usage-receiver.ts +3 -6
- package/dist/chunk-UGWDOGGH.js.map +0 -1
- package/dist/chunk-XKQTVIRE.js.map +0 -1
- /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import type { CollectorEnv } from "../framework/env";
|
|
4
|
+
import { createDefaultCollectorApp } from "../index";
|
|
5
|
+
import { MemSqlDb } from "../lib/test-utils/mem-sql-db";
|
|
6
|
+
import {
|
|
7
|
+
createDashboardAuth,
|
|
8
|
+
resetDashboardProjectCache,
|
|
9
|
+
} from "./dashboard-auth";
|
|
10
|
+
import { createIngestAuth, resetIngestAuthCache } from "./ingest-auth";
|
|
11
|
+
|
|
12
|
+
const TEST_PASSWORD = "test-dashboard-secret-password-123";
|
|
13
|
+
const TEST_TOKEN = "obs_admin_test_token_abcdef123456";
|
|
14
|
+
const TEST_INGEST_KEY = "obs_ingest_test_key_987654321";
|
|
15
|
+
|
|
16
|
+
async function createValidCookie(
|
|
17
|
+
password: string,
|
|
18
|
+
expOffsetSec = 3600,
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
const expiresAt = Math.floor(Date.now() / 1000) + expOffsetSec;
|
|
21
|
+
const payload = JSON.stringify({ exp: expiresAt });
|
|
22
|
+
const encoder = new TextEncoder();
|
|
23
|
+
const key = await crypto.subtle.importKey(
|
|
24
|
+
"raw",
|
|
25
|
+
encoder.encode(password),
|
|
26
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
27
|
+
false,
|
|
28
|
+
["sign"],
|
|
29
|
+
);
|
|
30
|
+
const signature = await crypto.subtle.sign(
|
|
31
|
+
"HMAC",
|
|
32
|
+
key,
|
|
33
|
+
encoder.encode(payload),
|
|
34
|
+
);
|
|
35
|
+
const sig = btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
36
|
+
return `obs_session=${encodeURIComponent(`${payload}.${sig}`)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createTestDb(
|
|
40
|
+
knownProjects: string[] = ["default", "tenant-a", "tenant-b"],
|
|
41
|
+
) {
|
|
42
|
+
return new MemSqlDb({
|
|
43
|
+
all: (sql) => {
|
|
44
|
+
if (sql.includes("projects")) {
|
|
45
|
+
return knownProjects.map((id) => ({
|
|
46
|
+
id,
|
|
47
|
+
name: id.toUpperCase(),
|
|
48
|
+
slug: id,
|
|
49
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
50
|
+
payload_capture_enabled: 0,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
return [];
|
|
54
|
+
},
|
|
55
|
+
first: () => null,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeEnv(
|
|
60
|
+
db: MemSqlDb,
|
|
61
|
+
overrides: Partial<CollectorEnv> = {},
|
|
62
|
+
): CollectorEnv {
|
|
63
|
+
return {
|
|
64
|
+
DB: db as unknown as D1Database,
|
|
65
|
+
DASHBOARD_PASSWORD: TEST_PASSWORD,
|
|
66
|
+
DASHBOARD_TOKEN: TEST_TOKEN,
|
|
67
|
+
INGEST_KEY: TEST_INGEST_KEY,
|
|
68
|
+
...overrides,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("dashboard-auth middleware & programmatic token contract", () => {
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
resetDashboardProjectCache();
|
|
75
|
+
resetIngestAuthCache();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("Bearer token authentication", () => {
|
|
79
|
+
it("accepts a valid Bearer token configured in config", async () => {
|
|
80
|
+
const db = createTestDb();
|
|
81
|
+
const env = makeEnv(db);
|
|
82
|
+
const auth = createDashboardAuth({
|
|
83
|
+
password: TEST_PASSWORD,
|
|
84
|
+
token: TEST_TOKEN,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const app = new Hono<{
|
|
88
|
+
Bindings: CollectorEnv;
|
|
89
|
+
Variables: { projectId: string };
|
|
90
|
+
}>();
|
|
91
|
+
app.use("/internal/*", auth.middleware);
|
|
92
|
+
app.get("/internal/test", (c) =>
|
|
93
|
+
c.json({ ok: true, project: c.get("projectId") }),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const res = await app.fetch(
|
|
97
|
+
new Request("http://localhost/internal/test", {
|
|
98
|
+
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
|
|
99
|
+
}),
|
|
100
|
+
env,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(res.status).toBe(200);
|
|
104
|
+
const body = await res.json();
|
|
105
|
+
expect(body).toEqual({ ok: true, project: "default" });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("accepts a valid Bearer token from env.DASHBOARD_TOKEN when not in config", async () => {
|
|
109
|
+
const db = createTestDb();
|
|
110
|
+
const env = makeEnv(db);
|
|
111
|
+
const auth = createDashboardAuth({ password: TEST_PASSWORD });
|
|
112
|
+
|
|
113
|
+
const app = new Hono<{
|
|
114
|
+
Bindings: CollectorEnv;
|
|
115
|
+
Variables: { projectId: string };
|
|
116
|
+
}>();
|
|
117
|
+
app.use("/internal/*", auth.middleware);
|
|
118
|
+
app.get("/internal/test", (c) =>
|
|
119
|
+
c.json({ ok: true, project: c.get("projectId") }),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const res = await app.fetch(
|
|
123
|
+
new Request("http://localhost/internal/test", {
|
|
124
|
+
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
|
|
125
|
+
}),
|
|
126
|
+
env,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(res.status).toBe(200);
|
|
130
|
+
const body = await res.json();
|
|
131
|
+
expect(body).toEqual({ ok: true, project: "default" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("rejects an invalid Bearer token with 401 Unauthorized", async () => {
|
|
135
|
+
const db = createTestDb();
|
|
136
|
+
const env = makeEnv(db);
|
|
137
|
+
const auth = createDashboardAuth({
|
|
138
|
+
password: TEST_PASSWORD,
|
|
139
|
+
token: TEST_TOKEN,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const app = new Hono<{
|
|
143
|
+
Bindings: CollectorEnv;
|
|
144
|
+
Variables: { projectId: string };
|
|
145
|
+
}>();
|
|
146
|
+
app.use("/internal/*", auth.middleware);
|
|
147
|
+
app.get("/internal/test", (c) => c.json({ ok: true }));
|
|
148
|
+
|
|
149
|
+
const res = await app.fetch(
|
|
150
|
+
new Request("http://localhost/internal/test", {
|
|
151
|
+
headers: { Authorization: "Bearer wrong-token-xyz" },
|
|
152
|
+
}),
|
|
153
|
+
env,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(res.status).toBe(401);
|
|
157
|
+
const body = await res.json();
|
|
158
|
+
expect(body).toEqual({ error: "Unauthorized" });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("rejects malformed Authorization headers", async () => {
|
|
162
|
+
const db = createTestDb();
|
|
163
|
+
const env = makeEnv(db);
|
|
164
|
+
const auth = createDashboardAuth({
|
|
165
|
+
password: TEST_PASSWORD,
|
|
166
|
+
token: TEST_TOKEN,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const app = new Hono<{
|
|
170
|
+
Bindings: CollectorEnv;
|
|
171
|
+
Variables: { projectId: string };
|
|
172
|
+
}>();
|
|
173
|
+
app.use("/internal/*", auth.middleware);
|
|
174
|
+
app.get("/internal/test", (c) => c.json({ ok: true }));
|
|
175
|
+
|
|
176
|
+
for (const header of [
|
|
177
|
+
"Basic dXNlcjpwYXNz",
|
|
178
|
+
"Bearer",
|
|
179
|
+
"Bearer ",
|
|
180
|
+
"Token xyz",
|
|
181
|
+
]) {
|
|
182
|
+
const res = await app.fetch(
|
|
183
|
+
new Request("http://localhost/internal/test", {
|
|
184
|
+
headers: { Authorization: header },
|
|
185
|
+
}),
|
|
186
|
+
env,
|
|
187
|
+
);
|
|
188
|
+
expect(res.status).toBe(401);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("fails closed when no programmatic token is configured on the collector", async () => {
|
|
193
|
+
const db = createTestDb();
|
|
194
|
+
const env = makeEnv(db, { DASHBOARD_TOKEN: undefined });
|
|
195
|
+
const auth = createDashboardAuth({ password: TEST_PASSWORD });
|
|
196
|
+
|
|
197
|
+
const app = new Hono<{
|
|
198
|
+
Bindings: CollectorEnv;
|
|
199
|
+
Variables: { projectId: string };
|
|
200
|
+
}>();
|
|
201
|
+
app.use("/internal/*", auth.middleware);
|
|
202
|
+
app.get("/internal/test", (c) => c.json({ ok: true }));
|
|
203
|
+
|
|
204
|
+
const res = await app.fetch(
|
|
205
|
+
new Request("http://localhost/internal/test", {
|
|
206
|
+
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
|
|
207
|
+
}),
|
|
208
|
+
env,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(res.status).toBe(401);
|
|
212
|
+
const body = await res.json();
|
|
213
|
+
expect(body).toEqual({ error: "Unauthorized" });
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("Ingest key rejection on /internal/*", () => {
|
|
218
|
+
it("rejects INGEST_KEY sent as Bearer token on /internal/*", async () => {
|
|
219
|
+
const db = createTestDb();
|
|
220
|
+
const env = makeEnv(db);
|
|
221
|
+
const auth = createDashboardAuth({
|
|
222
|
+
password: TEST_PASSWORD,
|
|
223
|
+
token: TEST_TOKEN,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const app = new Hono<{
|
|
227
|
+
Bindings: CollectorEnv;
|
|
228
|
+
Variables: { projectId: string };
|
|
229
|
+
}>();
|
|
230
|
+
app.use("/internal/*", auth.middleware);
|
|
231
|
+
app.get("/internal/test", (c) => c.json({ ok: true }));
|
|
232
|
+
|
|
233
|
+
const res = await app.fetch(
|
|
234
|
+
new Request("http://localhost/internal/test", {
|
|
235
|
+
headers: { Authorization: `Bearer ${TEST_INGEST_KEY}` },
|
|
236
|
+
}),
|
|
237
|
+
env,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
expect(res.status).toBe(401);
|
|
241
|
+
const body = (await res.json()) as { error: string };
|
|
242
|
+
expect(body.error).toMatch(/ingest keys cannot be used/i);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("rejects X-API-Key ingest key on /internal/*", async () => {
|
|
246
|
+
const db = createTestDb();
|
|
247
|
+
const env = makeEnv(db);
|
|
248
|
+
const auth = createDashboardAuth({
|
|
249
|
+
password: TEST_PASSWORD,
|
|
250
|
+
token: TEST_TOKEN,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const app = new Hono<{
|
|
254
|
+
Bindings: CollectorEnv;
|
|
255
|
+
Variables: { projectId: string };
|
|
256
|
+
}>();
|
|
257
|
+
app.use("/internal/*", auth.middleware);
|
|
258
|
+
app.get("/internal/test", (c) => c.json({ ok: true }));
|
|
259
|
+
|
|
260
|
+
const res = await app.fetch(
|
|
261
|
+
new Request("http://localhost/internal/test", {
|
|
262
|
+
headers: { "X-API-Key": TEST_INGEST_KEY },
|
|
263
|
+
}),
|
|
264
|
+
env,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
expect(res.status).toBe(401);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("Cookie login preservation", () => {
|
|
272
|
+
it("accepts a valid HMAC-SHA256 signed obs_session cookie", async () => {
|
|
273
|
+
const db = createTestDb();
|
|
274
|
+
const env = makeEnv(db);
|
|
275
|
+
const auth = createDashboardAuth({
|
|
276
|
+
password: TEST_PASSWORD,
|
|
277
|
+
token: TEST_TOKEN,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const app = new Hono<{
|
|
281
|
+
Bindings: CollectorEnv;
|
|
282
|
+
Variables: { projectId: string };
|
|
283
|
+
}>();
|
|
284
|
+
app.use("/internal/*", auth.middleware);
|
|
285
|
+
app.get("/internal/test", (c) => c.json({ ok: true }));
|
|
286
|
+
|
|
287
|
+
const cookie = await createValidCookie(TEST_PASSWORD);
|
|
288
|
+
const res = await app.fetch(
|
|
289
|
+
new Request("http://localhost/internal/test", {
|
|
290
|
+
headers: { Cookie: cookie },
|
|
291
|
+
}),
|
|
292
|
+
env,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
expect(res.status).toBe(200);
|
|
296
|
+
const body = await res.json();
|
|
297
|
+
expect(body).toEqual({ ok: true });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("rejects a cookie with invalid signature", async () => {
|
|
301
|
+
const db = createTestDb();
|
|
302
|
+
const env = makeEnv(db);
|
|
303
|
+
const auth = createDashboardAuth({
|
|
304
|
+
password: TEST_PASSWORD,
|
|
305
|
+
token: TEST_TOKEN,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const app = new Hono<{
|
|
309
|
+
Bindings: CollectorEnv;
|
|
310
|
+
Variables: { projectId: string };
|
|
311
|
+
}>();
|
|
312
|
+
app.use("/internal/*", auth.middleware);
|
|
313
|
+
app.get("/internal/test", (c) => c.json({ ok: true }));
|
|
314
|
+
|
|
315
|
+
const cookie = await createValidCookie("wrong-signing-password");
|
|
316
|
+
const res = await app.fetch(
|
|
317
|
+
new Request("http://localhost/internal/test", {
|
|
318
|
+
headers: { Cookie: cookie },
|
|
319
|
+
}),
|
|
320
|
+
env,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
expect(res.status).toBe(401);
|
|
324
|
+
const body = await res.json();
|
|
325
|
+
expect(body).toEqual({ error: "Invalid session" });
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("rejects an expired cookie", async () => {
|
|
329
|
+
const db = createTestDb();
|
|
330
|
+
const env = makeEnv(db);
|
|
331
|
+
const auth = createDashboardAuth({
|
|
332
|
+
password: TEST_PASSWORD,
|
|
333
|
+
token: TEST_TOKEN,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const app = new Hono<{
|
|
337
|
+
Bindings: CollectorEnv;
|
|
338
|
+
Variables: { projectId: string };
|
|
339
|
+
}>();
|
|
340
|
+
app.use("/internal/*", auth.middleware);
|
|
341
|
+
app.get("/internal/test", (c) => c.json({ ok: true }));
|
|
342
|
+
|
|
343
|
+
// Expired 100 seconds ago
|
|
344
|
+
const cookie = await createValidCookie(TEST_PASSWORD, -100);
|
|
345
|
+
const res = await app.fetch(
|
|
346
|
+
new Request("http://localhost/internal/test", {
|
|
347
|
+
headers: { Cookie: cookie },
|
|
348
|
+
}),
|
|
349
|
+
env,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
expect(res.status).toBe(401);
|
|
353
|
+
const body = await res.json();
|
|
354
|
+
expect(body).toEqual({ error: "Session expired" });
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("redirects unauthenticated HTML requests to /dashboard/login", async () => {
|
|
358
|
+
const db = createTestDb();
|
|
359
|
+
const env = makeEnv(db);
|
|
360
|
+
const auth = createDashboardAuth({
|
|
361
|
+
password: TEST_PASSWORD,
|
|
362
|
+
token: TEST_TOKEN,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const app = new Hono<{
|
|
366
|
+
Bindings: CollectorEnv;
|
|
367
|
+
Variables: { projectId: string };
|
|
368
|
+
}>();
|
|
369
|
+
app.use("/internal/*", auth.middleware);
|
|
370
|
+
app.get("/internal/page", (c) => c.text("html"));
|
|
371
|
+
|
|
372
|
+
const res = await app.fetch(
|
|
373
|
+
new Request("http://localhost/internal/page", {
|
|
374
|
+
headers: { Accept: "text/html,application/xhtml+xml" },
|
|
375
|
+
}),
|
|
376
|
+
env,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
expect(res.status).toBe(302);
|
|
380
|
+
expect(res.headers.get("Location")).toBe("/dashboard/login");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("allows login via POST /auth/login and validation via GET /auth/check", async () => {
|
|
384
|
+
const db = createTestDb();
|
|
385
|
+
const env = makeEnv(db);
|
|
386
|
+
const auth = createDashboardAuth({
|
|
387
|
+
password: TEST_PASSWORD,
|
|
388
|
+
token: TEST_TOKEN,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
392
|
+
auth.registerRoutes(app);
|
|
393
|
+
|
|
394
|
+
// Valid login
|
|
395
|
+
const loginRes = await app.fetch(
|
|
396
|
+
new Request("http://localhost/auth/login", {
|
|
397
|
+
method: "POST",
|
|
398
|
+
headers: { "Content-Type": "application/json" },
|
|
399
|
+
body: JSON.stringify({ password: TEST_PASSWORD }),
|
|
400
|
+
}),
|
|
401
|
+
env,
|
|
402
|
+
);
|
|
403
|
+
expect(loginRes.status).toBe(200);
|
|
404
|
+
const setCookie = loginRes.headers.get("Set-Cookie");
|
|
405
|
+
expect(setCookie).toBeTruthy();
|
|
406
|
+
expect(setCookie).toContain("obs_session=");
|
|
407
|
+
|
|
408
|
+
// Check auth with session cookie
|
|
409
|
+
const cookieValue = setCookie?.split(";")[0] ?? "";
|
|
410
|
+
const checkRes = await app.fetch(
|
|
411
|
+
new Request("http://localhost/auth/check", {
|
|
412
|
+
headers: { Cookie: cookieValue },
|
|
413
|
+
}),
|
|
414
|
+
env,
|
|
415
|
+
);
|
|
416
|
+
expect(checkRes.status).toBe(200);
|
|
417
|
+
expect(await checkRes.json()).toEqual({ authenticated: true });
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("GET /auth/check validates Bearer token and rejects ingest key", async () => {
|
|
421
|
+
const db = createTestDb();
|
|
422
|
+
const env = makeEnv(db);
|
|
423
|
+
const auth = createDashboardAuth({
|
|
424
|
+
password: TEST_PASSWORD,
|
|
425
|
+
token: TEST_TOKEN,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
429
|
+
auth.registerRoutes(app);
|
|
430
|
+
|
|
431
|
+
// Valid Bearer token
|
|
432
|
+
const validRes = await app.fetch(
|
|
433
|
+
new Request("http://localhost/auth/check", {
|
|
434
|
+
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
|
|
435
|
+
}),
|
|
436
|
+
env,
|
|
437
|
+
);
|
|
438
|
+
expect(await validRes.json()).toEqual({ authenticated: true });
|
|
439
|
+
|
|
440
|
+
// Ingest key as Bearer
|
|
441
|
+
const ingestRes = await app.fetch(
|
|
442
|
+
new Request("http://localhost/auth/check", {
|
|
443
|
+
headers: { Authorization: `Bearer ${TEST_INGEST_KEY}` },
|
|
444
|
+
}),
|
|
445
|
+
env,
|
|
446
|
+
);
|
|
447
|
+
expect(await ingestRes.json()).toEqual({ authenticated: false });
|
|
448
|
+
|
|
449
|
+
// Invalid token
|
|
450
|
+
const invalidRes = await app.fetch(
|
|
451
|
+
new Request("http://localhost/auth/check", {
|
|
452
|
+
headers: { Authorization: "Bearer bad-token" },
|
|
453
|
+
}),
|
|
454
|
+
env,
|
|
455
|
+
);
|
|
456
|
+
expect(await invalidRes.json()).toEqual({ authenticated: false });
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
describe("Project isolation & scoping", () => {
|
|
461
|
+
it("scopes to valid tenant project specified in X-Project-Id", async () => {
|
|
462
|
+
const db = createTestDb(["default", "tenant-a", "tenant-b"]);
|
|
463
|
+
const env = makeEnv(db);
|
|
464
|
+
const auth = createDashboardAuth({
|
|
465
|
+
password: TEST_PASSWORD,
|
|
466
|
+
token: TEST_TOKEN,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const app = new Hono<{
|
|
470
|
+
Bindings: CollectorEnv;
|
|
471
|
+
Variables: { projectId: string };
|
|
472
|
+
}>();
|
|
473
|
+
app.use("/internal/*", auth.middleware);
|
|
474
|
+
app.get("/internal/data", (c) => c.json({ project: c.get("projectId") }));
|
|
475
|
+
|
|
476
|
+
const res = await app.fetch(
|
|
477
|
+
new Request("http://localhost/internal/data", {
|
|
478
|
+
headers: {
|
|
479
|
+
Authorization: `Bearer ${TEST_TOKEN}`,
|
|
480
|
+
"X-Project-Id": "tenant-a",
|
|
481
|
+
},
|
|
482
|
+
}),
|
|
483
|
+
env,
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
expect(res.status).toBe(200);
|
|
487
|
+
expect(await res.json()).toEqual({ project: "tenant-a" });
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("rejects unknown project in X-Project-Id with 400 Bad Request", async () => {
|
|
491
|
+
const db = createTestDb(["default", "tenant-a"]);
|
|
492
|
+
const env = makeEnv(db);
|
|
493
|
+
const auth = createDashboardAuth({
|
|
494
|
+
password: TEST_PASSWORD,
|
|
495
|
+
token: TEST_TOKEN,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const app = new Hono<{
|
|
499
|
+
Bindings: CollectorEnv;
|
|
500
|
+
Variables: { projectId: string };
|
|
501
|
+
}>();
|
|
502
|
+
app.use("/internal/*", auth.middleware);
|
|
503
|
+
app.get("/internal/data", (c) => c.json({ project: c.get("projectId") }));
|
|
504
|
+
|
|
505
|
+
const res = await app.fetch(
|
|
506
|
+
new Request("http://localhost/internal/data", {
|
|
507
|
+
headers: {
|
|
508
|
+
Authorization: `Bearer ${TEST_TOKEN}`,
|
|
509
|
+
"X-Project-Id": "unknown-corp",
|
|
510
|
+
},
|
|
511
|
+
}),
|
|
512
|
+
env,
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
expect(res.status).toBe(400);
|
|
516
|
+
expect(await res.json()).toEqual({
|
|
517
|
+
error: "Unknown project: unknown-corp",
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("exempts /internal/projects/* from requiring a pre-existing project", async () => {
|
|
522
|
+
const db = createTestDb(["default"]);
|
|
523
|
+
const env = makeEnv(db);
|
|
524
|
+
const auth = createDashboardAuth({
|
|
525
|
+
password: TEST_PASSWORD,
|
|
526
|
+
token: TEST_TOKEN,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const app = new Hono<{
|
|
530
|
+
Bindings: CollectorEnv;
|
|
531
|
+
Variables: { projectId: string };
|
|
532
|
+
}>();
|
|
533
|
+
app.use("/internal/*", auth.middleware);
|
|
534
|
+
app.get("/internal/projects", (c) => c.json({ ok: true }));
|
|
535
|
+
|
|
536
|
+
const res = await app.fetch(
|
|
537
|
+
new Request("http://localhost/internal/projects", {
|
|
538
|
+
headers: {
|
|
539
|
+
Authorization: `Bearer ${TEST_TOKEN}`,
|
|
540
|
+
"X-Project-Id": "not-seeded-yet",
|
|
541
|
+
},
|
|
542
|
+
}),
|
|
543
|
+
env,
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
expect(res.status).toBe(200);
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
describe("Fail closed when unconfigured", () => {
|
|
551
|
+
it("returns 500 when neither password nor token is configured", async () => {
|
|
552
|
+
const db = createTestDb();
|
|
553
|
+
const env = makeEnv(db, {
|
|
554
|
+
DASHBOARD_PASSWORD: undefined,
|
|
555
|
+
DASHBOARD_TOKEN: undefined,
|
|
556
|
+
});
|
|
557
|
+
const auth = createDashboardAuth();
|
|
558
|
+
|
|
559
|
+
const app = new Hono<{
|
|
560
|
+
Bindings: CollectorEnv;
|
|
561
|
+
Variables: { projectId: string };
|
|
562
|
+
}>();
|
|
563
|
+
app.use("/internal/*", auth.middleware);
|
|
564
|
+
app.get("/internal/test", (c) => c.json({ ok: true }));
|
|
565
|
+
|
|
566
|
+
const res = await app.fetch(
|
|
567
|
+
new Request("http://localhost/internal/test", {
|
|
568
|
+
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
|
|
569
|
+
}),
|
|
570
|
+
env,
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
expect(res.status).toBe(500);
|
|
574
|
+
const body = (await res.json()) as { error: string };
|
|
575
|
+
expect(body.error).toContain(
|
|
576
|
+
"Neither DASHBOARD_PASSWORD nor DASHBOARD_TOKEN",
|
|
577
|
+
);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
describe("Full collector app wiring", () => {
|
|
582
|
+
it("verifies end-to-end /internal/* and /v1/* route protection", async () => {
|
|
583
|
+
const db = createTestDb();
|
|
584
|
+
const env = makeEnv(db);
|
|
585
|
+
|
|
586
|
+
const ingestAuth = createIngestAuth();
|
|
587
|
+
const dashboardAuth = createDashboardAuth({
|
|
588
|
+
password: TEST_PASSWORD,
|
|
589
|
+
token: TEST_TOKEN,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const app = createDefaultCollectorApp({
|
|
593
|
+
auth: { middleware: ingestAuth },
|
|
594
|
+
dashboardAuth,
|
|
595
|
+
sqlDb: () => db,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// 1. Valid Bearer token to /internal/* succeeds
|
|
599
|
+
const internalValidRes = await app.fetch(
|
|
600
|
+
new Request("http://localhost/internal/telemetry/overview", {
|
|
601
|
+
headers: {
|
|
602
|
+
Authorization: `Bearer ${TEST_TOKEN}`,
|
|
603
|
+
"X-Project-Id": "default",
|
|
604
|
+
},
|
|
605
|
+
}),
|
|
606
|
+
env,
|
|
607
|
+
);
|
|
608
|
+
expect(internalValidRes.status).toBe(200);
|
|
609
|
+
|
|
610
|
+
// 2. Ingest key to /internal/* is rejected with 401
|
|
611
|
+
const internalIngestRes = await app.fetch(
|
|
612
|
+
new Request("http://localhost/internal/telemetry/overview", {
|
|
613
|
+
headers: {
|
|
614
|
+
Authorization: `Bearer ${TEST_INGEST_KEY}`,
|
|
615
|
+
},
|
|
616
|
+
}),
|
|
617
|
+
env,
|
|
618
|
+
);
|
|
619
|
+
expect(internalIngestRes.status).toBe(401);
|
|
620
|
+
|
|
621
|
+
// 3. Invalid token to /internal/* is rejected with 401
|
|
622
|
+
const internalInvalidRes = await app.fetch(
|
|
623
|
+
new Request("http://localhost/internal/telemetry/overview", {
|
|
624
|
+
headers: {
|
|
625
|
+
Authorization: "Bearer invalid-token",
|
|
626
|
+
},
|
|
627
|
+
}),
|
|
628
|
+
env,
|
|
629
|
+
);
|
|
630
|
+
expect(internalInvalidRes.status).toBe(401);
|
|
631
|
+
|
|
632
|
+
// 4. Valid session cookie to /internal/* succeeds
|
|
633
|
+
const cookie = await createValidCookie(TEST_PASSWORD);
|
|
634
|
+
const internalCookieRes = await app.fetch(
|
|
635
|
+
new Request("http://localhost/internal/telemetry/overview", {
|
|
636
|
+
headers: {
|
|
637
|
+
Cookie: cookie,
|
|
638
|
+
},
|
|
639
|
+
}),
|
|
640
|
+
env,
|
|
641
|
+
);
|
|
642
|
+
expect(internalCookieRes.status).toBe(200);
|
|
643
|
+
|
|
644
|
+
// 5. Unauthenticated request to /internal/* is rejected with 401
|
|
645
|
+
const internalUnauthRes = await app.fetch(
|
|
646
|
+
new Request("http://localhost/internal/telemetry/overview"),
|
|
647
|
+
env,
|
|
648
|
+
);
|
|
649
|
+
expect(internalUnauthRes.status).toBe(401);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("fails closed on /internal/* if dashboardAuth was not configured on app", async () => {
|
|
653
|
+
const db = createTestDb();
|
|
654
|
+
const env = makeEnv(db);
|
|
655
|
+
|
|
656
|
+
const ingestAuth = createIngestAuth();
|
|
657
|
+
// No dashboardAuth provided!
|
|
658
|
+
const app = createDefaultCollectorApp({
|
|
659
|
+
auth: { middleware: ingestAuth },
|
|
660
|
+
sqlDb: () => db,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// Even with valid ingest key, /internal/* must fail closed
|
|
664
|
+
const res = await app.fetch(
|
|
665
|
+
new Request("http://localhost/internal/telemetry/overview", {
|
|
666
|
+
headers: {
|
|
667
|
+
Authorization: `Bearer ${TEST_INGEST_KEY}`,
|
|
668
|
+
},
|
|
669
|
+
}),
|
|
670
|
+
env,
|
|
671
|
+
);
|
|
672
|
+
expect(res.status).toBe(401);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
describe("Login rate limiting", () => {
|
|
677
|
+
it("answers 429 after repeated failures and lets the client back in later", async () => {
|
|
678
|
+
const db = createTestDb();
|
|
679
|
+
const env = makeEnv(db);
|
|
680
|
+
let now = 1_700_000_000_000;
|
|
681
|
+
const auth = createDashboardAuth({
|
|
682
|
+
password: TEST_PASSWORD,
|
|
683
|
+
loginRateLimit: {
|
|
684
|
+
maxFailures: 2,
|
|
685
|
+
windowMs: 60_000,
|
|
686
|
+
now: () => now,
|
|
687
|
+
// This test models a deployment behind a trusted proxy.
|
|
688
|
+
trustProxyHeaders: true,
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
692
|
+
auth.registerRoutes(app);
|
|
693
|
+
const login = (password: string, ip = "203.0.113.5") =>
|
|
694
|
+
app.fetch(
|
|
695
|
+
new Request("http://localhost/auth/login", {
|
|
696
|
+
method: "POST",
|
|
697
|
+
headers: {
|
|
698
|
+
"content-type": "application/json",
|
|
699
|
+
"cf-connecting-ip": ip,
|
|
700
|
+
},
|
|
701
|
+
body: JSON.stringify({ password }),
|
|
702
|
+
}),
|
|
703
|
+
env,
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
expect((await login("wrong")).status).toBe(401);
|
|
707
|
+
expect((await login("wrong")).status).toBe(401);
|
|
708
|
+
const blocked = await login(TEST_PASSWORD);
|
|
709
|
+
expect(blocked.status).toBe(429);
|
|
710
|
+
expect(blocked.headers.get("Retry-After")).toBe("60");
|
|
711
|
+
// A different client is not affected.
|
|
712
|
+
expect((await login(TEST_PASSWORD, "198.51.100.9")).status).toBe(200);
|
|
713
|
+
now += 60_000;
|
|
714
|
+
expect((await login(TEST_PASSWORD)).status).toBe(200);
|
|
715
|
+
// Success resets the budget.
|
|
716
|
+
expect((await login("wrong")).status).toBe(401);
|
|
717
|
+
expect((await login("wrong")).status).toBe(401);
|
|
718
|
+
expect((await login(TEST_PASSWORD)).status).toBe(429);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("never shares one bucket across clients it cannot tell apart, and warns once", async () => {
|
|
722
|
+
const db = createTestDb();
|
|
723
|
+
const env = makeEnv(db);
|
|
724
|
+
const warnings: string[] = [];
|
|
725
|
+
const auth = createDashboardAuth({
|
|
726
|
+
password: TEST_PASSWORD,
|
|
727
|
+
loginRateLimit: {
|
|
728
|
+
maxFailures: 2,
|
|
729
|
+
windowMs: 60_000,
|
|
730
|
+
warn: (m) => warnings.push(m),
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
734
|
+
auth.registerRoutes(app);
|
|
735
|
+
const login = (password: string, forwardedFor: string) =>
|
|
736
|
+
app.fetch(
|
|
737
|
+
new Request("http://localhost/auth/login", {
|
|
738
|
+
method: "POST",
|
|
739
|
+
headers: {
|
|
740
|
+
"content-type": "application/json",
|
|
741
|
+
"x-forwarded-for": forwardedFor,
|
|
742
|
+
"cf-connecting-ip": forwardedFor,
|
|
743
|
+
},
|
|
744
|
+
body: JSON.stringify({ password }),
|
|
745
|
+
}),
|
|
746
|
+
env,
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
// Attacker-chosen headers are ignored, and without a peer address
|
|
750
|
+
// there is no bucket at all: wrong passwords from "anyone" must not
|
|
751
|
+
// lock the real operator out (a shared bucket would 429 here).
|
|
752
|
+
expect((await login("wrong", "1.1.1.1")).status).toBe(401);
|
|
753
|
+
expect((await login("wrong", "2.2.2.2")).status).toBe(401);
|
|
754
|
+
expect((await login("wrong", "3.3.3.3")).status).toBe(401);
|
|
755
|
+
expect((await login(TEST_PASSWORD, "4.4.4.4")).status).toBe(200);
|
|
756
|
+
expect(warnings).toHaveLength(1);
|
|
757
|
+
expect(warnings[0]).toContain("login rate limiting is inactive");
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("keys on the host-resolved peer address when one is provided", async () => {
|
|
761
|
+
const db = createTestDb();
|
|
762
|
+
const env = makeEnv(db);
|
|
763
|
+
const auth = createDashboardAuth({
|
|
764
|
+
password: TEST_PASSWORD,
|
|
765
|
+
loginRateLimit: {
|
|
766
|
+
maxFailures: 1,
|
|
767
|
+
windowMs: 60_000,
|
|
768
|
+
clientAddress: (c) => c.req.header("x-test-peer") ?? undefined,
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
772
|
+
auth.registerRoutes(app);
|
|
773
|
+
const login = (password: string, peer: string) =>
|
|
774
|
+
app.fetch(
|
|
775
|
+
new Request("http://localhost/auth/login", {
|
|
776
|
+
method: "POST",
|
|
777
|
+
headers: {
|
|
778
|
+
"content-type": "application/json",
|
|
779
|
+
"x-test-peer": peer,
|
|
780
|
+
"x-forwarded-for": "9.9.9.9",
|
|
781
|
+
},
|
|
782
|
+
body: JSON.stringify({ password }),
|
|
783
|
+
}),
|
|
784
|
+
env,
|
|
785
|
+
);
|
|
786
|
+
expect((await login("wrong", "10.0.0.5")).status).toBe(401);
|
|
787
|
+
expect((await login(TEST_PASSWORD, "10.0.0.5")).status).toBe(429);
|
|
788
|
+
expect((await login(TEST_PASSWORD, "10.0.0.6")).status).toBe(200);
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
});
|