Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

prod-signals.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

prod-signals.test.tsBlame239 lines · 1 contributor
a6d8fd5Claude1/**
2 * Block K9 — Production-signal ingestion tests.
3 *
4 * Pure helpers cover the security-critical bits (sha sanity, hash
5 * stability, frame extraction, source allow-listing). Route smokes
6 * only assert auth behaviour — DB-backed CRUD is an integration
7 * concern and lives in the live-DB suite.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12import {
13 SIGNAL_SOURCES,
14 extractTopFrame,
15 hashError,
16 isValidSha,
17 sanitiseKind,
18 sanitiseSeverity,
19 sanitiseSource,
20 __internal,
21} from "../lib/prod-signals";
22
23describe("prod-signals — isValidSha", () => {
24 it("accepts 7–64 hex chars", () => {
25 expect(isValidSha("abcdef1")).toBe(true); // 7
26 expect(isValidSha("a".repeat(40))).toBe(true); // full git sha
27 expect(isValidSha("a".repeat(64))).toBe(true); // sha-256 bound
28 expect(isValidSha("DEADBEEF1234")).toBe(true); // case insensitive
29 });
30
31 it("rejects too short / too long / bad chars / empty / null", () => {
32 expect(isValidSha("abc123")).toBe(false); // 6 < 7
33 expect(isValidSha("a".repeat(65))).toBe(false);
34 expect(isValidSha("xyz12345")).toBe(false);
35 expect(isValidSha("")).toBe(false);
36 expect(isValidSha(null)).toBe(false);
37 expect(isValidSha(undefined)).toBe(false);
38 expect(isValidSha(1234567 as any)).toBe(false);
39 });
40});
41
42describe("prod-signals — hashError", () => {
43 it("is deterministic for the same inputs", () => {
44 const a = hashError("TypeError: x is null", "at foo (bar.ts:1:1)");
45 const b = hashError("TypeError: x is null", "at foo (bar.ts:1:1)");
46 expect(a).toBe(b);
47 });
48
49 it("returns a 16-hex-char string", () => {
50 const h = hashError("boom", "at foo (bar.ts)");
51 expect(h.length).toBe(__internal.HASH_LEN);
52 expect(/^[a-f0-9]+$/.test(h)).toBe(true);
53 });
54
55 it("differs when top frame differs", () => {
56 const a = hashError("boom", "at foo (a.ts:1:1)");
57 const b = hashError("boom", "at bar (c.ts:1:1)");
58 expect(a).not.toBe(b);
59 });
60
61 it("collapses volatile details in messages (whitespace, hex pointers, line:col)", () => {
62 // Two forms of the same error that differ only by whitespace / addr / line:col
63 // should collide after normalisation.
64 const a = hashError(
65 "Cannot read property 'x' of null at 0xdeadbeef:12:34",
66 "at foo (a.ts:10:5)"
67 );
68 const b = hashError(
69 "Cannot read property 'x' of null at 0xfeedface:99:1",
70 "at foo (a.ts:10:5)"
71 );
72 expect(a).toBe(b);
73 });
74
75 it("tolerates null / undefined inputs", () => {
76 const h = hashError(null as any, undefined as any);
77 expect(h.length).toBe(__internal.HASH_LEN);
78 });
79});
80
81describe("prod-signals — extractTopFrame", () => {
82 it("returns the first non-empty, non-node_modules line", () => {
83 const st = [
84 "",
85 " at Module._compile (node:internal/modules)",
86 " at ./node_modules/foo/index.js:3:10",
87 " at ./src/app.ts:42:5",
88 ].join("\n");
89 expect(extractTopFrame(st)).toBe(
90 "at Module._compile (node:internal/modules)"
91 );
92 });
93
94 it("skips node_modules frames", () => {
95 const st = [
96 " at ./node_modules/react/cjs/react.js:10:1",
97 " at ./src/App.tsx:15:3",
98 ].join("\n");
99 expect(extractTopFrame(st)).toBe("at ./src/App.tsx:15:3");
100 });
101
102 it("caps at FRAME_MAX", () => {
103 const line = "at foo " + "x".repeat(2000);
104 const top = extractTopFrame(line);
105 expect(top.length).toBe(__internal.FRAME_MAX);
106 });
107
108 it("returns empty string on malformed / empty input", () => {
109 expect(extractTopFrame("")).toBe("");
110 expect(extractTopFrame(null)).toBe("");
111 expect(extractTopFrame(undefined)).toBe("");
112 expect(extractTopFrame(" \n\n ")).toBe("");
113 });
114});
115
116describe("prod-signals — sanitiseSource", () => {
117 it("accepts the allow-listed sources", () => {
118 for (const s of SIGNAL_SOURCES) expect(sanitiseSource(s)).toBe(s);
119 });
120
121 it("lower-cases and trims", () => {
122 expect(sanitiseSource(" CRONTECH ")).toBe("crontech");
123 expect(sanitiseSource("Gatetest")).toBe("gatetest");
124 });
125
126 it("falls back to 'manual' for unknown / missing", () => {
127 expect(sanitiseSource("datadog")).toBe("manual");
128 expect(sanitiseSource("")).toBe("manual");
129 expect(sanitiseSource(null)).toBe("manual");
130 expect(sanitiseSource(undefined)).toBe("manual");
131 expect(sanitiseSource(42 as any)).toBe("manual");
132 });
133});
134
135describe("prod-signals — sanitiseKind", () => {
136 it("accepts canonical kinds", () => {
137 expect(sanitiseKind("runtime_error")).toBe("runtime_error");
138 expect(sanitiseKind("test_failure")).toBe("test_failure");
139 expect(sanitiseKind("deploy_failure")).toBe("deploy_failure");
140 expect(sanitiseKind("performance")).toBe("performance");
141 expect(sanitiseKind("security")).toBe("security");
142 });
143
144 it("defaults unknown kinds to runtime_error", () => {
145 expect(sanitiseKind("weird")).toBe("runtime_error");
146 expect(sanitiseKind(null)).toBe("runtime_error");
147 expect(sanitiseKind(undefined)).toBe("runtime_error");
148 });
149});
150
151describe("prod-signals — sanitiseSeverity", () => {
152 it("accepts canonical severities", () => {
153 expect(sanitiseSeverity("info")).toBe("info");
154 expect(sanitiseSeverity("warning")).toBe("warning");
155 expect(sanitiseSeverity("error")).toBe("error");
156 expect(sanitiseSeverity("critical")).toBe("critical");
157 });
158
159 it("defaults to 'error'", () => {
160 expect(sanitiseSeverity("")).toBe("error");
161 expect(sanitiseSeverity("fatal")).toBe("error");
162 expect(sanitiseSeverity(null)).toBe("error");
163 expect(sanitiseSeverity(undefined)).toBe("error");
164 });
165});
166
167// ---------------------------------------------------------------------------
168// Route auth smoke tests. Without a live DB we only assert that
169// unauthenticated writes are rejected and that the shape of the error
170// is JSON (not an HTML /login redirect — API clients don't follow it).
171// These tolerate "not yet mounted" (404) so the file can land before the
172// main thread wires signals.ts into app.tsx.
173// ---------------------------------------------------------------------------
174describe("prod-signals — route auth", () => {
175 it("POST /api/v1/signals/error without auth → 401 JSON (or 404 pre-mount)", async () => {
176 const res = await app.request("/api/v1/signals/error", {
177 method: "POST",
178 headers: { "content-type": "application/json" },
179 body: JSON.stringify({
180 repo: "alice/demo",
181 commit_sha: "abc1234",
182 source: "crontech",
183 kind: "runtime_error",
184 message: "boom",
185 }),
186 });
187 expect([401, 404]).toContain(res.status);
188 });
189
190 it("POST with invalid bearer token → 401 JSON (or 404 pre-mount)", async () => {
191 const res = await app.request("/api/v1/signals/error", {
192 method: "POST",
193 headers: {
194 "content-type": "application/json",
195 authorization: "Bearer glc_not_a_real_token",
196 },
197 body: JSON.stringify({
198 repo: "alice/demo",
199 commit_sha: "abc1234",
200 source: "manual",
201 kind: "runtime_error",
202 message: "boom",
203 }),
204 });
205 expect([401, 404]).toContain(res.status);
206 });
207
208 it("POST dismiss without auth → 401 (or 404 pre-mount)", async () => {
209 const res = await app.request(
210 "/api/v1/signals/00000000-0000-0000-0000-000000000000/dismiss",
211 { method: "POST" }
212 );
213 expect([401, 404]).toContain(res.status);
214 });
215
216 it("POST resolve without auth → 401 (or 404 pre-mount)", async () => {
217 const res = await app.request(
218 "/api/v1/signals/00000000-0000-0000-0000-000000000000/resolve",
219 {
220 method: "POST",
221 headers: { "content-type": "application/json" },
222 body: "{}",
223 }
224 );
225 expect([401, 404]).toContain(res.status);
226 });
227
228 it("GET repo signals returns JSON (200 / 403 / 404 / 500 depending on env)", async () => {
229 const res = await app.request("/api/v1/repos/alice/demo/signals");
230 expect([200, 403, 404, 500]).toContain(res.status);
231 });
232
233 it("GET commit signals with invalid sha → 400 (or 404 pre-mount)", async () => {
234 const res = await app.request(
235 "/api/v1/repos/alice/demo/commits/NOT_HEX/signals"
236 );
237 expect([400, 404]).toContain(res.status);
238 });
239});