Initial commit: SIC harness (backend, web, pi-adapter, configs, docs)
- pnpm monorepo: apps/api (Fastify + SQLite + SSE), apps/web (React+Vite), packages/shared, packages/pi-adapter - Local auth (admin/webhook-runner roles) + Keycloak JWT ready - Multi-session chat with reliable history (user persisted before LLM, assistant persisted after stream) - Markdown knowledge base with /api/docs/search + /api/docs/:id - YAML webhook catalog with backend-only execution, retry/backoff, audit (webhook_runs), and per-user rate limit - Skills config (sre-on-call, blameless-postmortem, security-incident) injected into LLM system prompt - LLM provider failover chain (config/models.yml fallback + LLM_FALLBACK_CHAIN override) - Context-aware webhooks panel + backend id-mention safety net - Per-message stats (time/duration/tokens/model), Markdown+GFM render, code & table copy/download buttons - Vitest suite, end-to-end smoke test (scripts/smoke.mjs), per-session system prompt override - /metrics Prometheus endpoint + /api/metrics JSON, request-id correlation - dotenv with explicit repo-root path; envString/envNumber helpers (handles empty-string env) - Runbooks + SOPs under knowledge/ in English; README, docs, and INDEX.md in English
This commit is contained in:
151
apps/api/test/integration.test.ts
Normal file
151
apps/api/test/integration.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import type { AppDatabase } from "../src/db/database.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { createSessionRepository, createMessageRepository } from "../src/sessions/repository.js";
|
||||
import { createWebhookAuditRepository } from "../src/webhooks/audit.js";
|
||||
import { runWebhookAuditPurge } from "../src/webhooks/audit.js";
|
||||
|
||||
let db: AppDatabase;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const dir = join(tmpdir(), `sic-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
dbPath = join(dir, "test.db");
|
||||
db = new Database(dbPath);
|
||||
db.pragma("foreign_keys = ON");
|
||||
migrate(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
if (existsSync(dbPath)) rmSync(dbPath, { force: true });
|
||||
});
|
||||
|
||||
describe("session isolation", () => {
|
||||
it("never returns sessions/messages from another user", () => {
|
||||
const sessions = createSessionRepository(db);
|
||||
const messages = createMessageRepository(db);
|
||||
|
||||
const a = sessions.create("user-a", "A session");
|
||||
const b = sessions.create("user-b", "B session");
|
||||
|
||||
messages.create({ sessionId: a.id, userId: "user-a", role: "user", content: "a msg" });
|
||||
messages.create({ sessionId: b.id, userId: "user-b", role: "user", content: "b msg" });
|
||||
|
||||
// List filter
|
||||
expect(sessions.list("user-a").map((s) => s.id)).toEqual([a.id]);
|
||||
expect(sessions.list("user-b").map((s) => s.id)).toEqual([b.id]);
|
||||
|
||||
// get() requires matching user_id
|
||||
expect(sessions.get("user-a", b.id)).toBeFalsy();
|
||||
expect(sessions.get("user-b", a.id)).toBeFalsy();
|
||||
|
||||
// Messages filter by both session_id and user_id
|
||||
const bMessages = messages.listForSession("user-a", b.id);
|
||||
expect(bMessages).toEqual([]);
|
||||
const aMessages = messages.listForSession("user-a", a.id);
|
||||
expect(aMessages).toHaveLength(1);
|
||||
expect(aMessages[0]?.content).toBe("a msg");
|
||||
});
|
||||
|
||||
it("delete cascades to messages", () => {
|
||||
const sessions = createSessionRepository(db);
|
||||
const messages = createMessageRepository(db);
|
||||
|
||||
const s = sessions.create("user-a", null);
|
||||
const m = messages.create({
|
||||
sessionId: s.id,
|
||||
userId: "user-a",
|
||||
role: "user",
|
||||
content: "will be cascaded",
|
||||
});
|
||||
|
||||
sessions.delete("user-a", s.id);
|
||||
expect(messages.listForSession("user-a", s.id)).toEqual([]);
|
||||
// Direct DB check that the message row is gone (not just hidden)
|
||||
const row = db.prepare("SELECT id FROM chat_messages WHERE id = ?").get(m.id);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updateTitle only affects the owner's session", () => {
|
||||
const sessions = createSessionRepository(db);
|
||||
const a = sessions.create("user-a", "Old");
|
||||
sessions.updateTitle("user-a", a.id, "New");
|
||||
expect(sessions.get("user-a", a.id)?.title).toBe("New");
|
||||
});
|
||||
});
|
||||
|
||||
describe("webhook audit + retention", () => {
|
||||
it("usageForUserSince aggregates per webhook", () => {
|
||||
const sessions = createSessionRepository(db);
|
||||
const audit = createWebhookAuditRepository(db);
|
||||
const s1 = sessions.create("user-a", "test");
|
||||
const s2 = sessions.create("user-b", "test");
|
||||
const now = Date.now();
|
||||
const fresh = new Date(now - 1_000).toISOString();
|
||||
const old = new Date(now - 100 * 86_400_000).toISOString();
|
||||
|
||||
audit.create({ webhookId: "dns-flush", userId: "user-a", sessionId: s1.id, status: "success", createdAt: fresh });
|
||||
audit.create({ webhookId: "dns-flush", userId: "user-a", sessionId: s1.id, status: "success", createdAt: fresh });
|
||||
audit.create({ webhookId: "dns-flush", userId: "user-a", sessionId: s1.id, status: "error", createdAt: fresh });
|
||||
audit.create({ webhookId: "dns-flush", userId: "user-a", sessionId: s1.id, status: "success", createdAt: old });
|
||||
audit.create({ webhookId: "other-hook", userId: "user-b", sessionId: s2.id, status: "success", createdAt: fresh });
|
||||
|
||||
const since = new Date(now - 7 * 86_400_000).toISOString();
|
||||
const usage = audit.usageForUserSince(since, "user-a");
|
||||
expect(usage["dns-flush"]?.runs).toBe(3);
|
||||
expect(usage["dns-flush"]?.successes).toBe(2);
|
||||
expect(usage["dns-flush"]?.successRate).toBeCloseTo(2 / 3);
|
||||
expect(usage["other-hook"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retention purge deletes old rows but keeps recent ones", () => {
|
||||
const sessions = createSessionRepository(db);
|
||||
const audit = createWebhookAuditRepository(db);
|
||||
const s = sessions.create("user-a", "test");
|
||||
const now = Date.now();
|
||||
const fresh = new Date(now - 60_000).toISOString();
|
||||
const stale = new Date(now - 100 * 86_400_000).toISOString();
|
||||
audit.create({ webhookId: "w", userId: "user-a", sessionId: s.id, status: "success", createdAt: fresh });
|
||||
audit.create({ webhookId: "w", userId: "user-a", sessionId: s.id, status: "success", createdAt: stale });
|
||||
audit.create({ webhookId: "w", userId: "user-a", sessionId: s.id, status: "success", createdAt: stale });
|
||||
|
||||
const report = runWebhookAuditPurge(db, { retentionDays: 30, maxPerUser: 0 });
|
||||
expect(report.deletedByAge).toBe(2);
|
||||
|
||||
const remaining = db.prepare("SELECT COUNT(*) as n FROM webhook_runs").get() as { n: number };
|
||||
expect(remaining.n).toBe(1);
|
||||
});
|
||||
|
||||
it("per-user cap keeps the most recent N", () => {
|
||||
const sessions = createSessionRepository(db);
|
||||
const audit = createWebhookAuditRepository(db);
|
||||
const s = sessions.create("user-a", "test");
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const ts = new Date(now - i * 1000).toISOString();
|
||||
audit.create({ webhookId: "w", userId: "user-a", sessionId: s.id, status: "success", createdAt: ts });
|
||||
}
|
||||
const report = runWebhookAuditPurge(db, { retentionDays: 0, maxPerUser: 3 });
|
||||
expect(report.deletedByCap).toBe(5);
|
||||
const remaining = db.prepare("SELECT COUNT(*) as n FROM webhook_runs").get() as { n: number };
|
||||
expect(remaining.n).toBe(3);
|
||||
});
|
||||
|
||||
it("listForSession enforces user_id", () => {
|
||||
const sessions = createSessionRepository(db);
|
||||
const audit = createWebhookAuditRepository(db);
|
||||
const sa = sessions.create("user-a", "test");
|
||||
const sb = sessions.create("user-b", "test");
|
||||
audit.create({ webhookId: "w", userId: "user-a", sessionId: sa.id, status: "success" });
|
||||
audit.create({ webhookId: "w", userId: "user-b", sessionId: sb.id, status: "success" });
|
||||
expect(audit.listForSession("user-a", sa.id)).toHaveLength(1);
|
||||
expect(audit.listForSession("user-b", sb.id)).toHaveLength(1);
|
||||
expect(audit.listForSession("user-a", sa.id)[0]?.user_id).toBe("user-a");
|
||||
});
|
||||
});
|
||||
118
apps/api/test/rag-client.test.ts
Normal file
118
apps/api/test/rag-client.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createServer, type Server } from "node:http";
|
||||
|
||||
let server: Server;
|
||||
let port = 0;
|
||||
|
||||
beforeEach(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
||||
// Decode the path so ids with reserved characters (e.g. "runbooks:vpn")
|
||||
// match whether the client encoded the colon as %3A or not.
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
if (req.method === "POST" && pathname === "/search") {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{ id: "remote:1", title: "Remote doc", source: "remote", tags: ["remote"], relevance: 0.9, excerpt: "x" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && pathname === "/docs/remote:1") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: "remote:1",
|
||||
title: "Remote doc",
|
||||
source: "remote",
|
||||
tags: ["remote"],
|
||||
headings: ["Section"],
|
||||
content: "Full remote content",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const a = server.address();
|
||||
port = typeof a === "object" && a ? a.port : 0;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe("rag client", () => {
|
||||
it("searches via the configured endpoint when set", async () => {
|
||||
const { searchViaRag } = await import("../src/rag/client.js");
|
||||
const items = await searchViaRag(
|
||||
{
|
||||
endpoint: `http://127.0.0.1:${port}`,
|
||||
authToken: "",
|
||||
timeoutMs: 5000,
|
||||
fallbackToLocal: false,
|
||||
chunkStrategy: "heading",
|
||||
chunkSizeChars: 1500,
|
||||
topK: 5,
|
||||
minRelevance: 0,
|
||||
includeTags: [],
|
||||
excludeTags: [],
|
||||
},
|
||||
"anything",
|
||||
3,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.id).toBe("remote:1");
|
||||
expect(items[0]?.relevance).toBe(0.9);
|
||||
});
|
||||
|
||||
it("fetches a single doc via the endpoint", async () => {
|
||||
const { getViaRag } = await import("../src/rag/client.js");
|
||||
const doc = await getViaRag(
|
||||
{
|
||||
endpoint: `http://127.0.0.1:${port}`,
|
||||
authToken: "secret",
|
||||
timeoutMs: 5000,
|
||||
fallbackToLocal: false,
|
||||
chunkStrategy: "heading",
|
||||
chunkSizeChars: 1500,
|
||||
topK: 5,
|
||||
minRelevance: 0,
|
||||
includeTags: [],
|
||||
excludeTags: [],
|
||||
},
|
||||
"remote:1",
|
||||
);
|
||||
expect(doc?.id).toBe("remote:1");
|
||||
expect(doc?.content).toBe("Full remote content");
|
||||
});
|
||||
|
||||
it("isRagRemote returns true when endpoint is set, false otherwise", async () => {
|
||||
const { isRagRemote } = await import("../src/rag/client.js");
|
||||
const base = {
|
||||
authToken: "",
|
||||
timeoutMs: 1000,
|
||||
fallbackToLocal: true,
|
||||
chunkStrategy: "heading" as const,
|
||||
chunkSizeChars: 1500,
|
||||
topK: 5,
|
||||
minRelevance: 0,
|
||||
includeTags: [],
|
||||
excludeTags: [],
|
||||
};
|
||||
expect(isRagRemote({ ...base, endpoint: "" })).toBe(false);
|
||||
expect(isRagRemote({ ...base, endpoint: "http://x" })).toBe(true);
|
||||
});
|
||||
});
|
||||
57
apps/api/test/rate-limit.test.ts
Normal file
57
apps/api/test/rate-limit.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRateLimiter } from "../src/rate-limit.js";
|
||||
|
||||
describe("rate-limit", () => {
|
||||
it("accepts up to burst then rejects", () => {
|
||||
const lim = createRateLimiter({ perMinute: 60, burst: 3 });
|
||||
expect(lim.consume("u1", 0)).toEqual({ ok: true, remaining: 2 });
|
||||
expect(lim.consume("u1", 0)).toEqual({ ok: true, remaining: 1 });
|
||||
expect(lim.consume("u1", 0)).toEqual({ ok: true, remaining: 0 });
|
||||
const denied = lim.consume("u1", 0);
|
||||
expect(denied.ok).toBe(false);
|
||||
if (!denied.ok) {
|
||||
expect(denied.retryAfterMs).toBeGreaterThan(0);
|
||||
expect(denied.retryAfterMs).toBeLessThanOrEqual(1000);
|
||||
}
|
||||
});
|
||||
|
||||
it("refills tokens over time", () => {
|
||||
const lim = createRateLimiter({ perMinute: 60, burst: 2 });
|
||||
expect(lim.consume("u1", 0).ok).toBe(true);
|
||||
expect(lim.consume("u1", 0).ok).toBe(true);
|
||||
expect(lim.consume("u1", 0).ok).toBe(false);
|
||||
// 1 second later, 1 token refilled
|
||||
expect(lim.consume("u1", 1000).ok).toBe(true);
|
||||
expect(lim.consume("u1", 1000).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("isolates buckets per id", () => {
|
||||
const lim = createRateLimiter({ perMinute: 60, burst: 1 });
|
||||
expect(lim.consume("u1", 0).ok).toBe(true);
|
||||
expect(lim.consume("u1", 0).ok).toBe(false);
|
||||
// u2 has its own bucket
|
||||
expect(lim.consume("u2", 0).ok).toBe(true);
|
||||
expect(lim.consume("u2", 0).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("caps refill at burst", () => {
|
||||
const lim = createRateLimiter({ perMinute: 60, burst: 2 });
|
||||
// Wait a long time, tokens should still be capped at 2
|
||||
const result = lim.consume("u1", 60_000);
|
||||
expect(result).toEqual({ ok: true, remaining: 1 });
|
||||
expect(lim.consume("u1", 60_000).ok).toBe(true);
|
||||
expect(lim.consume("u1", 60_000).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("reset clears a single bucket or all", () => {
|
||||
const lim = createRateLimiter({ perMinute: 60, burst: 1 });
|
||||
lim.consume("u1", 0);
|
||||
lim.consume("u2", 0);
|
||||
expect(lim.size()).toBe(2);
|
||||
lim.reset("u1");
|
||||
expect(lim.size()).toBe(1);
|
||||
expect(lim.consume("u1", 0).ok).toBe(true);
|
||||
lim.reset();
|
||||
expect(lim.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user