195 lines
6.7 KiB
TypeScript
195 lines
6.7 KiB
TypeScript
import { describe, it } from "@suaveplan/testing/runner";
|
|
import * as fc from "fast-check";
|
|
import { MemoryAuditAdapter } from "../src/adapters/memory/memory.js";
|
|
import { AuditEventType, AuditSeverity } from "../src/constants/index.js";
|
|
import { AuditLogger } from "../src/core/audit-logger/audit-logger.js";
|
|
import {
|
|
computeEventHash,
|
|
verifyChain,
|
|
} from "../src/core/chain-integrity/chain-integrity.js";
|
|
import type { AuditEvent } from "../src/types/audit-logging.types.js";
|
|
|
|
const eventBaseArb = fc.record({
|
|
id: fc.uuid(),
|
|
type: fc.constantFrom(...Object.values(AuditEventType)),
|
|
severity: fc.constantFrom(...Object.values(AuditSeverity)),
|
|
actor: fc.record({
|
|
userId: fc.option(fc.uuid(), { nil: undefined }),
|
|
sessionId: fc.option(fc.uuid(), { nil: undefined }),
|
|
}),
|
|
timestamp: fc.date({
|
|
min: new Date("2020-01-01"),
|
|
max: new Date("2030-01-01"),
|
|
}),
|
|
});
|
|
|
|
describe("property: HMAC stability", () => {
|
|
it("same inputs produce same hash", { timeout: 30000 }, () => {
|
|
fc.assert(
|
|
fc.property(
|
|
eventBaseArb,
|
|
fc.string({ minLength: 1 }),
|
|
(event, secret) => {
|
|
const h1 = computeEventHash(event, secret);
|
|
const h2 = computeEventHash(event, secret);
|
|
return h1 === h2;
|
|
}
|
|
),
|
|
{ numRuns: 50 }
|
|
);
|
|
});
|
|
|
|
it("different secrets produce different hashes", { timeout: 30000 }, () => {
|
|
fc.assert(
|
|
fc.property(
|
|
eventBaseArb,
|
|
fc.string({ minLength: 1 }),
|
|
fc.string({ minLength: 1 }),
|
|
(event, s1, s2) => {
|
|
fc.pre(s1 !== s2);
|
|
return (
|
|
computeEventHash(event, s1) !==
|
|
computeEventHash(event, s2)
|
|
);
|
|
}
|
|
),
|
|
{ numRuns: 50 }
|
|
);
|
|
});
|
|
|
|
it("different events produce different hashes (same secret)", {
|
|
timeout: 30000,
|
|
}, () => {
|
|
fc.assert(
|
|
fc.property(
|
|
eventBaseArb,
|
|
eventBaseArb,
|
|
fc.string({ minLength: 1 }),
|
|
(e1, e2, secret) => {
|
|
// Only guarantee different hashes when key fields differ
|
|
fc.pre(e1.id !== e2.id);
|
|
return (
|
|
computeEventHash(e1, secret) !==
|
|
computeEventHash(e2, secret)
|
|
);
|
|
}
|
|
),
|
|
{ numRuns: 50 }
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("property: chain integrity mutation detection", () => {
|
|
it("single mutation breaks chain at mutation index", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.array(eventBaseArb, { minLength: 2, maxLength: 5 }),
|
|
fc.string({ minLength: 1 }),
|
|
async (bases, secret) => {
|
|
// Build a valid chain
|
|
const events: AuditEvent[] = [];
|
|
let previousHash: string | undefined;
|
|
for (const base of bases) {
|
|
const hash = computeEventHash(
|
|
base,
|
|
secret,
|
|
previousHash
|
|
);
|
|
const event: AuditEvent = {
|
|
...base,
|
|
hash,
|
|
...(previousHash !== undefined
|
|
? { previousHash }
|
|
: {}),
|
|
};
|
|
events.push(event);
|
|
previousHash = hash;
|
|
}
|
|
|
|
const validResult = verifyChain(events, secret);
|
|
if (!validResult.valid) return false;
|
|
|
|
// Tamper with a random event (change userId)
|
|
const tamperIdx = 0;
|
|
const tampered = [...events];
|
|
tampered[tamperIdx] = {
|
|
...events[tamperIdx]!,
|
|
actor: { userId: "tampered-user" },
|
|
};
|
|
|
|
const brokenResult = verifyChain(tampered, secret);
|
|
return (
|
|
!brokenResult.valid &&
|
|
brokenResult.brokenAt === tamperIdx
|
|
);
|
|
}
|
|
),
|
|
{ numRuns: 30 }
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("property: query filter correctness", () => {
|
|
it("userId filter returns only matching events", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.array(fc.uuid(), { minLength: 1, maxLength: 3 }),
|
|
async (userIds) => {
|
|
const adapter = new MemoryAuditAdapter();
|
|
const logger = new AuditLogger({
|
|
storage: adapter,
|
|
secretKey: "s",
|
|
});
|
|
|
|
for (const userId of userIds) {
|
|
await logger.log({
|
|
type: AuditEventType.AUTH_LOGIN,
|
|
severity: AuditSeverity.INFO,
|
|
actor: { userId },
|
|
});
|
|
}
|
|
|
|
for (const userId of userIds) {
|
|
const results = await logger.query({ userId });
|
|
return results.every((e) => e.actor.userId === userId);
|
|
}
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 20 }
|
|
);
|
|
});
|
|
|
|
it("limit never exceeds requested count", { timeout: 30000 }, async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.integer({ min: 1, max: 10 }),
|
|
fc.integer({ min: 0, max: 5 }),
|
|
async (total, limit) => {
|
|
const adapter = new MemoryAuditAdapter();
|
|
const logger = new AuditLogger({
|
|
storage: adapter,
|
|
secretKey: "s",
|
|
});
|
|
|
|
for (let i = 0; i < total; i++) {
|
|
await logger.log({
|
|
type: AuditEventType.AUTH_LOGIN,
|
|
severity: AuditSeverity.INFO,
|
|
actor: { userId: `u-${i}` },
|
|
});
|
|
}
|
|
|
|
const results = await logger.query({ limit });
|
|
return results.length <= limit;
|
|
}
|
|
),
|
|
{ numRuns: 20 }
|
|
);
|
|
});
|
|
});
|