438 tests per windows builds now
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
canUserBuild,
|
||||
getBuildRateLimitStats,
|
||||
recordBuildEnd,
|
||||
recordBuildStart,
|
||||
} from "./build-rate-limit";
|
||||
|
||||
// Module state is process-global. Each test uses a unique userId so concurrent
|
||||
// or sequential tests don't leak state into each other, and always pairs every
|
||||
// recordBuildStart with a recordBuildEnd in a try/finally to keep global counts
|
||||
// clean for unrelated tests.
|
||||
|
||||
let nextUserId = 1_000_000;
|
||||
function uniqueUser(): number {
|
||||
nextUserId += 1;
|
||||
return nextUserId;
|
||||
}
|
||||
|
||||
describe("canUserBuild", () => {
|
||||
test("allows a fresh user", () => {
|
||||
expect(canUserBuild(uniqueUser())).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
test("blocks user once their per-user concurrent limit is reached", () => {
|
||||
const userId = uniqueUser();
|
||||
const started: number[] = [];
|
||||
try {
|
||||
// Saturate the per-user concurrent slot (default config: 1).
|
||||
while (true) {
|
||||
const decision = canUserBuild(userId);
|
||||
if (!decision.allowed) break;
|
||||
recordBuildStart(userId);
|
||||
started.push(userId);
|
||||
if (started.length > 20) throw new Error("unexpected: never hit per-user cap");
|
||||
}
|
||||
const blocked = canUserBuild(userId);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.reason).toMatch(/concurrent per user/i);
|
||||
} finally {
|
||||
for (const u of started) recordBuildEnd(u);
|
||||
}
|
||||
});
|
||||
|
||||
test("releases the slot after recordBuildEnd", () => {
|
||||
const userId = uniqueUser();
|
||||
recordBuildStart(userId);
|
||||
try {
|
||||
const mid = canUserBuild(userId);
|
||||
// May be blocked depending on per-user cap; either way recordBuildEnd must restore capacity.
|
||||
if (!mid.allowed) {
|
||||
recordBuildEnd(userId);
|
||||
expect(canUserBuild(userId).allowed).toBe(true);
|
||||
// Re-start so the finally block's recordBuildEnd matches a started build.
|
||||
recordBuildStart(userId);
|
||||
}
|
||||
} finally {
|
||||
recordBuildEnd(userId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordBuildStart / recordBuildEnd", () => {
|
||||
test("globalActive increases then decreases symmetrically", () => {
|
||||
const userId = uniqueUser();
|
||||
const before = getBuildRateLimitStats().globalActive;
|
||||
recordBuildStart(userId);
|
||||
try {
|
||||
expect(getBuildRateLimitStats().globalActive).toBe(before + 1);
|
||||
} finally {
|
||||
recordBuildEnd(userId);
|
||||
}
|
||||
expect(getBuildRateLimitStats().globalActive).toBe(before);
|
||||
});
|
||||
|
||||
test("recordBuildEnd on a user with no active builds does not underflow", () => {
|
||||
const userId = uniqueUser();
|
||||
const before = getBuildRateLimitStats().globalActive;
|
||||
// No matching start. Should be a no-op for global count.
|
||||
recordBuildEnd(userId);
|
||||
expect(getBuildRateLimitStats().globalActive).toBe(before);
|
||||
});
|
||||
|
||||
test("multiple starts accumulate active count per user", () => {
|
||||
const userId = uniqueUser();
|
||||
recordBuildStart(userId);
|
||||
recordBuildStart(userId);
|
||||
try {
|
||||
const stats = getBuildRateLimitStats();
|
||||
const entry = stats.perUser.find((u) => u.userId === userId);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.active).toBe(2);
|
||||
expect(entry!.recentCount).toBe(2);
|
||||
} finally {
|
||||
recordBuildEnd(userId);
|
||||
recordBuildEnd(userId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBuildRateLimitStats", () => {
|
||||
test("omits users with no recent or active builds", () => {
|
||||
const userId = uniqueUser();
|
||||
recordBuildStart(userId);
|
||||
recordBuildEnd(userId);
|
||||
// Without waiting an hour the timestamp is still 'recent', so the entry stays —
|
||||
// but stats should still report it as active=0 if anything is reported at all.
|
||||
const stats = getBuildRateLimitStats();
|
||||
const entry = stats.perUser.find((u) => u.userId === userId);
|
||||
if (entry) {
|
||||
expect(entry.active).toBe(0);
|
||||
expect(entry.recentCount).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { generateBuildMutex, sanitizeMutex, sanitizeOutputName } from "./build-utils";
|
||||
|
||||
describe("generateBuildMutex", () => {
|
||||
test("returns string of requested length", () => {
|
||||
expect(generateBuildMutex(24)).toHaveLength(24);
|
||||
expect(generateBuildMutex(1)).toHaveLength(1);
|
||||
expect(generateBuildMutex(64)).toHaveLength(64);
|
||||
});
|
||||
|
||||
test("defaults to length 24", () => {
|
||||
expect(generateBuildMutex()).toHaveLength(24);
|
||||
});
|
||||
|
||||
test("only uses allowed alphabet characters", () => {
|
||||
const value = generateBuildMutex(256);
|
||||
expect(value).toMatch(/^[A-Za-z0-9._-]+$/);
|
||||
});
|
||||
|
||||
test("produces different values across calls", () => {
|
||||
const a = generateBuildMutex(24);
|
||||
const b = generateBuildMutex(24);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
test("result passes sanitizeMutex", () => {
|
||||
expect(sanitizeMutex(generateBuildMutex(24))).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeMutex", () => {
|
||||
test("returns undefined for falsy input", () => {
|
||||
expect(sanitizeMutex(undefined)).toBeUndefined();
|
||||
expect(sanitizeMutex("")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for whitespace-only input", () => {
|
||||
expect(sanitizeMutex(" ")).toBeUndefined();
|
||||
expect(sanitizeMutex("\t\n")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("trims surrounding whitespace", () => {
|
||||
expect(sanitizeMutex(" abc ")).toBe("abc");
|
||||
});
|
||||
|
||||
test("accepts allowed characters", () => {
|
||||
expect(sanitizeMutex("Abc.123_-xyz")).toBe("Abc.123_-xyz");
|
||||
});
|
||||
|
||||
test("rejects spaces inside the value", () => {
|
||||
expect(() => sanitizeMutex("ab cd")).toThrow(/Mutex must be/);
|
||||
});
|
||||
|
||||
test("rejects disallowed punctuation", () => {
|
||||
expect(() => sanitizeMutex("abc/def")).toThrow();
|
||||
expect(() => sanitizeMutex("abc@def")).toThrow();
|
||||
expect(() => sanitizeMutex("abc#def")).toThrow();
|
||||
});
|
||||
|
||||
test("enforces max length of 64", () => {
|
||||
expect(sanitizeMutex("a".repeat(64))).toBe("a".repeat(64));
|
||||
expect(() => sanitizeMutex("a".repeat(65))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeOutputName", () => {
|
||||
test("accepts plain filenames", () => {
|
||||
expect(sanitizeOutputName("agent.exe")).toBe("agent.exe");
|
||||
expect(sanitizeOutputName("my-build_v2.0.bin")).toBe("my-build_v2.0.bin");
|
||||
});
|
||||
|
||||
test("strips path components from traversal attempts", () => {
|
||||
// path.basename removes the directory portion, neutralizing traversal.
|
||||
// The returned value still has to match the allowed-character set.
|
||||
expect(sanitizeOutputName("../etc/passwd")).toBe("passwd");
|
||||
expect(sanitizeOutputName("/etc/shadow")).toBe("shadow");
|
||||
});
|
||||
|
||||
test("rejects when basename itself contains a disallowed character", () => {
|
||||
expect(() => sanitizeOutputName("bad name")).toThrow(/Invalid output filename/);
|
||||
expect(() => sanitizeOutputName("weird$.bin")).toThrow();
|
||||
});
|
||||
|
||||
test("rejects names with disallowed characters", () => {
|
||||
expect(() => sanitizeOutputName("agent name.exe")).toThrow();
|
||||
expect(() => sanitizeOutputName("agent$.exe")).toThrow();
|
||||
expect(() => sanitizeOutputName("agent*.exe")).toThrow();
|
||||
});
|
||||
|
||||
test("rejects empty result", () => {
|
||||
expect(() => sanitizeOutputName("")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mimeType, secureHeaders } from "./http-utils";
|
||||
import { SECURITY_HEADERS } from "./http-security";
|
||||
|
||||
describe("mimeType", () => {
|
||||
test("maps common web extensions", () => {
|
||||
expect(mimeType("index.html")).toBe("text/html; charset=utf-8");
|
||||
expect(mimeType("app.css")).toBe("text/css; charset=utf-8");
|
||||
expect(mimeType("bundle.js")).toBe("text/javascript; charset=utf-8");
|
||||
expect(mimeType("worker.mjs")).toBe("text/javascript; charset=utf-8");
|
||||
expect(mimeType("data.json")).toBe("application/json");
|
||||
});
|
||||
|
||||
test("maps image extensions", () => {
|
||||
expect(mimeType("a.png")).toBe("image/png");
|
||||
expect(mimeType("a.jpg")).toBe("image/jpeg");
|
||||
expect(mimeType("a.jpeg")).toBe("image/jpeg");
|
||||
expect(mimeType("a.webp")).toBe("image/webp");
|
||||
expect(mimeType("a.svg")).toBe("image/svg+xml");
|
||||
expect(mimeType("a.ico")).toBe("image/x-icon");
|
||||
});
|
||||
|
||||
test("maps font extensions", () => {
|
||||
expect(mimeType("Inter.woff")).toBe("font/woff");
|
||||
expect(mimeType("Inter.woff2")).toBe("font/woff2");
|
||||
expect(mimeType("Inter.ttf")).toBe("font/ttf");
|
||||
});
|
||||
|
||||
test("is case insensitive on the extension", () => {
|
||||
expect(mimeType("PIC.PNG")).toBe("image/png");
|
||||
expect(mimeType("Style.CSS")).toBe("text/css; charset=utf-8");
|
||||
});
|
||||
|
||||
test("uses only the final extension", () => {
|
||||
expect(mimeType("archive.tar.gz")).toBe("application/octet-stream");
|
||||
expect(mimeType("script.min.js")).toBe("text/javascript; charset=utf-8");
|
||||
});
|
||||
|
||||
test("returns octet-stream for unknown extensions", () => {
|
||||
expect(mimeType("file.xyz")).toBe("application/octet-stream");
|
||||
expect(mimeType("binary.bin")).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
test("returns octet-stream when there is no extension", () => {
|
||||
expect(mimeType("README")).toBe("application/octet-stream");
|
||||
expect(mimeType("")).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
test("handles full paths", () => {
|
||||
expect(mimeType("/var/www/index.html")).toBe("text/html; charset=utf-8");
|
||||
expect(mimeType("C:\\dist\\app.js")).toBe("text/javascript; charset=utf-8");
|
||||
});
|
||||
});
|
||||
|
||||
describe("secureHeaders", () => {
|
||||
test("includes every security header by default", () => {
|
||||
const headers = secureHeaders();
|
||||
for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
|
||||
expect(headers[name as keyof typeof headers]).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
test("omits Content-Type when none provided", () => {
|
||||
const headers = secureHeaders();
|
||||
expect((headers as Record<string, string>)["Content-Type"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("adds Content-Type when provided", () => {
|
||||
const headers = secureHeaders("application/json");
|
||||
expect((headers as Record<string, string>)["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
test("returns a fresh object each call (no shared mutation)", () => {
|
||||
const a = secureHeaders("text/plain") as Record<string, string>;
|
||||
const b = secureHeaders("text/html") as Record<string, string>;
|
||||
a["Content-Type"] = "mutated";
|
||||
expect(b["Content-Type"]).toBe("text/html");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { sanitizePluginId } from "./plugin-utils";
|
||||
|
||||
describe("sanitizePluginId", () => {
|
||||
test("accepts a plain id", () => {
|
||||
expect(sanitizePluginId("my-plugin")).toBe("my-plugin");
|
||||
expect(sanitizePluginId("plugin.v2_0")).toBe("plugin.v2_0");
|
||||
});
|
||||
|
||||
test("strips path components to defend against traversal", () => {
|
||||
expect(sanitizePluginId("../../../etc/passwd")).toBe("passwd");
|
||||
expect(sanitizePluginId("/var/log/syslog")).toBe("syslog");
|
||||
expect(sanitizePluginId("nested/dir/plugin")).toBe("plugin");
|
||||
});
|
||||
|
||||
test("strips disallowed characters from the basename", () => {
|
||||
expect(sanitizePluginId("hello world!")).toBe("helloworld");
|
||||
expect(sanitizePluginId("plug$in@123")).toBe("plugin123");
|
||||
});
|
||||
|
||||
test("throws when nothing survives sanitization", () => {
|
||||
expect(() => sanitizePluginId("")).toThrow(/Invalid plugin id/);
|
||||
expect(() => sanitizePluginId("!!!@@@")).toThrow(/Invalid plugin id/);
|
||||
expect(() => sanitizePluginId("/")).toThrow(/Invalid plugin id/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user