Initial Commit
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
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";
|
||||
|
||||
describe("AuditLogger integration", () => {
|
||||
let adapter: MemoryAuditAdapter;
|
||||
let logger: AuditLogger;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new MemoryAuditAdapter();
|
||||
logger = new AuditLogger({
|
||||
storage: adapter,
|
||||
secretKey: "integration-secret-key",
|
||||
enableChainIntegrity: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("full lifecycle: log, query, verify, export", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Log several events
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "alice" },
|
||||
});
|
||||
await logger.log({
|
||||
type: AuditEventType.DATA_READ,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "alice" },
|
||||
target: { type: "report", id: "r1" },
|
||||
});
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGOUT,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "alice" },
|
||||
});
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "bob" },
|
||||
});
|
||||
|
||||
// Query all events
|
||||
const all = await logger.query({});
|
||||
expect(all.length).toBe(4);
|
||||
|
||||
// Query by user
|
||||
const aliceEvents = await logger.query({ userId: "alice" });
|
||||
expect(aliceEvents.length).toBe(3);
|
||||
|
||||
// Verify chain integrity
|
||||
const ordered = await logger.query({ order: "asc" });
|
||||
const verifyResult = await logger.verifyChainIntegrity(ordered);
|
||||
expect(verifyResult.valid).toBe(true);
|
||||
|
||||
// GDPR export
|
||||
const gdprExport = await logger.exportGDPRData("alice");
|
||||
expect(gdprExport.userId).toBe("alice");
|
||||
expect(gdprExport.summary.totalEvents).toBe(3);
|
||||
expect(gdprExport.summary.eventTypes[AuditEventType.AUTH_LOGIN]).toBe(
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it("chain links correctly across multiple events", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let tick = 0;
|
||||
const chainLogger = new AuditLogger({
|
||||
storage: adapter,
|
||||
secretKey: "integration-secret-key",
|
||||
enableChainIntegrity: true,
|
||||
clock: () => new Date("2024-01-01").getTime() + tick++ * 1000,
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
events.push(
|
||||
await chainLogger.log({
|
||||
type: AuditEventType.DATA_READ,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: `user-${i}` },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Verify each event links to previous
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
expect(events[i]?.previousHash).toBe(events[i - 1]?.hash);
|
||||
}
|
||||
|
||||
const stored = await chainLogger.query({ order: "asc" });
|
||||
const chainEvents = stored.slice(-5); // last 5 we logged
|
||||
const result = await chainLogger.verifyChainIntegrity(chainEvents);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("retention policy lifecycle", { timeout: 30000 }, async () => {
|
||||
const now = new Date("2024-12-31").getTime();
|
||||
const retentionLogger = new AuditLogger({
|
||||
storage: adapter,
|
||||
secretKey: "secret",
|
||||
enableChainIntegrity: false,
|
||||
retentionPolicy: {
|
||||
retentionDays: 1,
|
||||
archiveBeforeDelete: true,
|
||||
archiveLocation: "s3://archive",
|
||||
},
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
// Add old event
|
||||
await adapter.save({
|
||||
id: "old-event",
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: {},
|
||||
timestamp: new Date("2024-01-01"),
|
||||
hash: "h",
|
||||
});
|
||||
|
||||
const result = await retentionLogger.applyRetentionPolicy();
|
||||
expect(result.eventsArchived).toBe(1);
|
||||
expect(result.eventsDeleted).toBe(0); // already moved to archive
|
||||
expect(adapter.snapshot().length).toBe(0);
|
||||
expect(adapter.archivedSnapshot().length).toBe(1);
|
||||
});
|
||||
|
||||
it("multi-tenant chain isolation", { timeout: 30000 }, async () => {
|
||||
// Each tenant gets its own chain: getLatestEvent(tenantId) scopes the lookup
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
tenantId: "t1",
|
||||
});
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u2" },
|
||||
tenantId: "t2",
|
||||
});
|
||||
await logger.log({
|
||||
type: AuditEventType.DATA_READ,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
tenantId: "t1",
|
||||
});
|
||||
|
||||
const t1Events = await logger.query({ tenantId: "t1", order: "asc" });
|
||||
const t2Events = await logger.query({ tenantId: "t2", order: "asc" });
|
||||
|
||||
// First t1 event has no predecessor → no previousHash
|
||||
expect(t1Events[0]?.previousHash).toBeUndefined();
|
||||
// Second t1 event links to first t1 event
|
||||
expect(t1Events[1]?.previousHash).toBe(t1Events[0]?.hash);
|
||||
// t2 only has one event, so no previousHash
|
||||
expect(t2Events[0]?.previousHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tamper detection in integration", { timeout: 30000 }, async () => {
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
});
|
||||
await logger.log({
|
||||
type: AuditEventType.DATA_READ,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
});
|
||||
|
||||
const events = await logger.query({ order: "asc" });
|
||||
|
||||
// Tamper with first event
|
||||
const tampered = [...events];
|
||||
tampered[0] = { ...events[0]!, actor: { userId: "attacker" } };
|
||||
|
||||
const result = await logger.verifyChainIntegrity(tampered);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.brokenAt).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
Reference in New Issue
Block a user