138 lines
5.7 KiB
TypeScript
138 lines
5.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdirSync, readFileSync, readdirSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { ManagedStorage } from "../../apps/api/src/managed-storage.js";
|
|
import {
|
|
LOG_FILE_LIMIT,
|
|
LOG_RETENTION_DAYS,
|
|
LOG_SIZE_LIMIT_BYTES,
|
|
StructuredJsonlLogger,
|
|
sanitizeDiagnosticRecord,
|
|
} from "../../apps/api/src/structured-log.js";
|
|
|
|
const temporaryRoots: string[] = [];
|
|
|
|
function fixtureRoot() {
|
|
const root = join(tmpdir(), `dada-wp0-08-${randomUUID()}`);
|
|
mkdirSync(root, { recursive: true });
|
|
temporaryRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
function evidence(name: string, value: unknown) {
|
|
const directory = process.env.DADA_EVIDENCE_DIR_LOG;
|
|
if (!directory) return;
|
|
mkdirSync(directory, { recursive: true });
|
|
writeFileSync(join(directory, name), `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
afterEach(async () => {
|
|
const { rm } = await import("node:fs/promises");
|
|
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { force: true, recursive: true })));
|
|
});
|
|
|
|
describe("TDD-WP0-LOG-001 rotation and redaction", () => {
|
|
it("enforces 10 MiB, 10 files, 30 days and emits only allowlisted JSONL fields", () => {
|
|
const root = fixtureRoot();
|
|
const logDirectory = join(root, "logs", "api");
|
|
mkdirSync(logDirectory, { recursive: true });
|
|
const activePath = join(logDirectory, "api.jsonl");
|
|
const seedLine = `${JSON.stringify({
|
|
component: "api",
|
|
correlation_id: "corr_fixture",
|
|
schema_version: "1.0",
|
|
status_category: "ready",
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
})}\n`;
|
|
const repeats = Math.floor(LOG_SIZE_LIMIT_BYTES / Buffer.byteLength(seedLine));
|
|
writeFileSync(activePath, seedLine.repeat(repeats));
|
|
const bytesBeforeBoundaryWrite = statSync(activePath).size;
|
|
|
|
const marker = `trap-${randomUUID()}`;
|
|
const logger = new StructuredJsonlLogger({ component: "api", directory: logDirectory });
|
|
logger.write({
|
|
absolute_path: join(root, marker),
|
|
api_key: `${marker}-key`,
|
|
correlation_id: "corr_boundary",
|
|
duration_ms: 12,
|
|
email: `${marker}@example.invalid`,
|
|
error_category: "none",
|
|
image: Buffer.from(marker),
|
|
object_id: "obj_boundary",
|
|
prompt: `${marker} private prompt`,
|
|
provider_error: `${marker} upstream body`,
|
|
status_category: "completed",
|
|
});
|
|
expect(statSync(join(logDirectory, "api.1.jsonl")).size).toBeLessThanOrEqual(LOG_SIZE_LIMIT_BYTES);
|
|
expect(statSync(activePath).size).toBeLessThanOrEqual(LOG_SIZE_LIMIT_BYTES);
|
|
|
|
for (let index = 2; index <= 14; index += 1) writeFileSync(join(logDirectory, `api.${index}.jsonl`), seedLine);
|
|
const stalePath = join(logDirectory, "api.4.jsonl");
|
|
const stale = new Date(Date.now() - (LOG_RETENTION_DAYS + 1) * 24 * 60 * 60 * 1000);
|
|
utimesSync(stalePath, stale, stale);
|
|
new StructuredJsonlLogger({ component: "api", directory: logDirectory }).maintain();
|
|
|
|
const files = readdirSync(logDirectory).sort();
|
|
const scanned = files.map((name) => readFileSync(join(logDirectory, name), "utf8")).join("");
|
|
expect(files.length).toBeLessThanOrEqual(LOG_FILE_LIMIT);
|
|
expect(files).not.toContain("api.4.jsonl");
|
|
expect(scanned).not.toContain(marker);
|
|
const activeEntry = JSON.parse(readFileSync(activePath, "utf8").trim()) as Record<string, unknown>;
|
|
expect(Object.keys(activeEntry).sort()).toEqual([
|
|
"component", "correlation_id", "duration_ms", "error_category", "object_id", "schema_version", "status_category", "timestamp",
|
|
]);
|
|
|
|
const diagnostic = sanitizeDiagnosticRecord({
|
|
app_version: "0.0.0",
|
|
checked_at: "2026-01-01T00:00:00.000Z",
|
|
component: "api",
|
|
message_key: "log_write_failed",
|
|
private_content: marker,
|
|
result: "fail",
|
|
stable_check_code: "log_writable",
|
|
user_path: join(root, marker),
|
|
});
|
|
expect(JSON.stringify(diagnostic)).not.toContain(marker);
|
|
evidence("log-manifest.json", {
|
|
bytes_before_boundary_write: bytesBeforeBoundaryWrite,
|
|
file_limit: LOG_FILE_LIMIT,
|
|
files: files.map((name) => ({ name, size: statSync(join(logDirectory, name)).size })),
|
|
retention_days: LOG_RETENTION_DAYS,
|
|
size_limit_bytes: LOG_SIZE_LIMIT_BYTES,
|
|
status: "passed",
|
|
total_generated_bytes: bytesBeforeBoundaryWrite + statSync(activePath).size,
|
|
});
|
|
evidence("redaction.json", { diagnostic, marker_absent: true, status: "passed" });
|
|
});
|
|
|
|
it("marks storage unavailable when the log directory cannot be written", () => {
|
|
const root = fixtureRoot();
|
|
const storage = new ManagedStorage({ dataRoot: root, databasePath: join(root, "db", "dada.sqlite3") });
|
|
const blockedDirectory = join(root, "logs-blocked");
|
|
writeFileSync(blockedDirectory, "not-a-directory");
|
|
const logger = new StructuredJsonlLogger({
|
|
component: "api",
|
|
directory: blockedDirectory,
|
|
onWriteFailure: () => storage.setLogAvailability(false),
|
|
});
|
|
expect(() => logger.write({ correlation_id: "corr_log_failure", error_category: "log_write_failed", status_category: "failed" })).toThrow("log_write_failed");
|
|
expect(storage.getState().storage_status).toBe("unavailable");
|
|
expect(storage.inspectAction("ai_call")).toBe("reject_unavailable");
|
|
evidence("response.json", {
|
|
ai_call: storage.inspectAction("ai_call"),
|
|
business_objects_changed: 0,
|
|
storage_status: storage.getState().storage_status,
|
|
status: "passed",
|
|
});
|
|
storage.setLogAvailability(true);
|
|
expect(storage.getState().storage_status).toBe("unavailable");
|
|
storage.applyControlledMeasurement(0);
|
|
expect(storage.getState().storage_status).toBe("active");
|
|
storage.close();
|
|
});
|
|
});
|