mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
feat+test+docs: D52 — daily audit rotation (lib/audit.mjs + bin/olp-audit-rotate.mjs) (#29)
Fifth Phase 3 D-day. Adds daily UTC-aware rotation to lib/audit.mjs
per ADR 0008 § 5 + ships an external cron tool. Rotation is
SYNCHRONOUS at v0.3.0 — synchronous design eliminates the race that
an async wrapper would create between date-change-detection and the
append.
lib/audit.mjs EXTENSIONS:
- New _maybeRotateAudit({ olpHome, logEvent }) (sync): probes live
audit.ndjson; if it holds events from past UTC date, renames it
to audit-YYYY-MM-DD.ndjson. Idempotent. If target file exists
(cron beat in-server check), logs warn + skips per § 5.3.
- appendAuditEvent extended: cheap fast-path date check via module-
cached _lastSeenUtcDate. On date change, calls _maybeRotateAudit
synchronously BEFORE appendFileSync — so old-date events land in
the rotated file and new-date events land in the fresh live file.
No event straddles the boundary.
- Why SYNCHRONOUS: an async wrapper would let the sync
appendFileSync race the not-yet-completed renameSync, landing
today's event in the about-to-be-renamed file. Sync rotation is
the only correct ordering at the append-fired-from-many-routes
scale OLP runs. (Test 26b-1 caught this during local run; the
initial async-wrapper implementation failed because the live
file at assertion time didn't exist.)
- New exports: _maybeRotateAudit (sync), getAuditRotateCount,
getAuditRotateFailCount, __resetAuditRotateState,
__setLastSeenUtcDateForTesting.
- First-event-date discovery: when probing the live file's date,
reads only the first ndjson line + parses its ts. Falls back to
file mtime if events absent (corrupt/empty edge).
bin/olp-audit-rotate.mjs (~95 lines): external cron tool per § 5.2.
Calls _maybeRotateAudit once + reports outcome. Exit codes 0
(success or no-op), 1 (bad usage), 2 (rotation failed). Installed
via package.json bin so `npx olp-audit-rotate [--olp-home=<path>]`
works. Example cron line in file header.
CONCURRENT-SAFETY (§ 5.3):
In-process sequential appends after the first date-change detection
short-circuit via the updated _lastSeenUtcDate cache → exactly 1
rename even under N sequential appends. Cross-process (cron + server)
coexistence handled by the "target already exists → skip + warn"
branch.
TESTS — Suite 26, +12 (588 → 600):
26a-1..5: _maybeRotateAudit (no live file / today already /
yesterday→rotate / idempotent re-call / cron-race target-exists
warn)
26b-1: appendAuditEvent past UTC date change triggers sync rotation
+ append lands in fresh live file
26c-1: 10 sequential appendAuditEvent across date change → exactly
1 rotation + all 10 events in new live file
26d-1..4: bin/olp-audit-rotate.mjs CLI (--help / no-live-file /
yesterday-file-rotates / unknown-flag exit 1)
26e-1: rotated files queryable via lib/audit-query.mjs
discoverAuditFiles + readAuditWindow cross-file read
package.json: bin.olp-audit-rotate + scripts.olp-audit-rotate entries
added.
DOCUMENTATION:
- AGENTS.md: lib/audit.mjs marker promoted ✅ (D45 append + D52
rotation both shipped); new bin/olp-audit-rotate.mjs entry.
- CHANGELOG.md: D52 entry under Unreleased per release_kit overlay.
NOT IN D52:
- tried_providers schema fix (D53; D45 P2 deferral)
- E2E + docs polish (D54)
- Phase 3 close → v0.3.0 (D55; maintainer-triggered)
Test count: 588 → 600 (+12). Verified locally via npm test.
AUTHORITY:
- ADR 0008 § 5.1 (first-append-after-UTC-midnight trigger).
- ADR 0008 § 5.2 (external cron alternative).
- ADR 0008 § 5.3 (concurrent-rotation safety + cron-coexistence).
- ADR 0008 § 5.4 (renamed-file query path consumed by D49 lib/
audit-query.mjs).
- CLAUDE.md release_kit overlay phase_rolling_mode — under
Unreleased.
- Standing autopilot grant.
ALIGNMENT.md scope check: extends lib/audit.mjs (a Phase 2 internal
module) + adds new bin/ CLI + small package.json bin/scripts entries.
No provider plugin / entry surface / IR change.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -11726,3 +11726,236 @@ describe('Suite 25 — D51 dashboard.html UI smoke (Phase 3, ADR 0008 § 6)', ()
|
||||
assert.match(r.body, /401.*owner-tier/i, 'dashboard error banner must mention owner-tier in 401 case');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Suite 26: D52 audit rotation (Phase 3, ADR 0008 § 5) ──────────────────
|
||||
//
|
||||
// Tests for daily audit rotation:
|
||||
// - First append after UTC date change triggers rename
|
||||
// - Concurrent appends during rotation produce exactly one rename
|
||||
// - External cron tool (bin/olp-audit-rotate.mjs) coexists with in-server
|
||||
// - Rotated files readable by lib/audit-query.mjs after rotation
|
||||
|
||||
import {
|
||||
appendAuditEvent as appendAuditEventS26,
|
||||
_maybeRotateAudit,
|
||||
getAuditRotateCount,
|
||||
__resetAuditRotateState,
|
||||
__setLastSeenUtcDateForTesting,
|
||||
} from './lib/audit.mjs';
|
||||
import { runCli as runAuditRotateCli } from './bin/olp-audit-rotate.mjs';
|
||||
|
||||
describe('Suite 26 — D52 audit rotation (Phase 3, ADR 0008 § 5)', () => {
|
||||
|
||||
function writeAuditFile(tmp, filename, events) {
|
||||
mkdirSync(_pathJoinForSetup(tmp, 'logs'), { recursive: true, mode: 0o700 });
|
||||
const path = _pathJoinForSetup(tmp, 'logs', filename);
|
||||
const lines = events.map(ev => JSON.stringify(ev)).join('\n') + '\n';
|
||||
fsWriteFileSyncForS23(path, lines, { mode: 0o600 });
|
||||
}
|
||||
|
||||
function makeEvent(ts) {
|
||||
return {
|
||||
ts,
|
||||
key_id: 'k1',
|
||||
owner_tier: 'owner',
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
status_code: 200,
|
||||
latency_ms: 100,
|
||||
cache_status: 'miss',
|
||||
fallback_hops: 0,
|
||||
tried_providers: ['anthropic'],
|
||||
};
|
||||
}
|
||||
|
||||
describe('26a — _maybeRotateAudit', () => {
|
||||
let TMP;
|
||||
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-26a-')); });
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetAuditRotateState(); });
|
||||
|
||||
it('26a-1: no live file → no rotation, returns { rotated: false }', () => {
|
||||
__resetAuditRotateState();
|
||||
const r = _maybeRotateAudit({ olpHome: TMP });
|
||||
assert.equal(r.rotated, false);
|
||||
});
|
||||
|
||||
it('26a-2: live file already on today → no rotation', () => {
|
||||
__resetAuditRotateState();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
writeAuditFile(TMP, 'audit.ndjson', [makeEvent(`${today}T10:00:00Z`)]);
|
||||
const r = _maybeRotateAudit({ olpHome: TMP });
|
||||
assert.equal(r.rotated, false);
|
||||
});
|
||||
|
||||
it('26a-3: live file holds yesterday\'s events → rotated to audit-YYYY-MM-DD.ndjson', () => {
|
||||
__resetAuditRotateState();
|
||||
const livePath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson');
|
||||
rmSync(livePath, { force: true });
|
||||
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
|
||||
writeAuditFile(TMP, 'audit.ndjson', [makeEvent(`${yesterday}T15:00:00Z`)]);
|
||||
const r = _maybeRotateAudit({ olpHome: TMP });
|
||||
assert.equal(r.rotated, true);
|
||||
assert.equal(r.dateUsed, yesterday);
|
||||
const rotatedPath = _pathJoinForSetup(TMP, 'logs', `audit-${yesterday}.ndjson`);
|
||||
assert.ok(fsExistsSync(rotatedPath));
|
||||
assert.ok(!fsExistsSync(livePath));
|
||||
assert.equal(getAuditRotateCount(), 1);
|
||||
});
|
||||
|
||||
it('26a-4: idempotent — second call after rotation is a no-op', () => {
|
||||
const r = _maybeRotateAudit({ olpHome: TMP });
|
||||
assert.equal(r.rotated, false);
|
||||
assert.equal(getAuditRotateCount(), 1);
|
||||
});
|
||||
|
||||
it('26a-5: rotation target already exists → skip + warn (cron-race safety)', () => {
|
||||
__resetAuditRotateState();
|
||||
const livePath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson');
|
||||
rmSync(livePath, { force: true });
|
||||
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
|
||||
writeAuditFile(TMP, `audit-${yesterday}.ndjson`, [makeEvent(`${yesterday}T08:00:00Z`)]);
|
||||
writeAuditFile(TMP, 'audit.ndjson', [makeEvent(`${yesterday}T20:00:00Z`)]);
|
||||
|
||||
let warned = false;
|
||||
const r = _maybeRotateAudit({
|
||||
olpHome: TMP,
|
||||
logEvent: (level, ev) => { if (level === 'warn' && ev === 'audit_rotate_target_exists') warned = true; },
|
||||
});
|
||||
assert.equal(r.rotated, false);
|
||||
assert.ok(warned, 'must warn when rotation target already exists');
|
||||
assert.ok(fsExistsSync(livePath));
|
||||
});
|
||||
});
|
||||
|
||||
describe('26b — appendAuditEvent triggers rotation on date change', () => {
|
||||
let TMP;
|
||||
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-26b-')); });
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetAuditRotateState(); });
|
||||
|
||||
it('26b-1: appending past a UTC date change triggers sync rotation + append lands in new file', () => {
|
||||
__resetAuditRotateState();
|
||||
const livePath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson');
|
||||
rmSync(livePath, { force: true });
|
||||
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
|
||||
writeAuditFile(TMP, 'audit.ndjson', [makeEvent(`${yesterday}T10:00:00Z`)]);
|
||||
__setLastSeenUtcDateForTesting(yesterday);
|
||||
|
||||
const todayTs = new Date().toISOString();
|
||||
appendAuditEventS26(makeEvent(todayTs), { olpHome: TMP });
|
||||
|
||||
const rotatedPath = _pathJoinForSetup(TMP, 'logs', `audit-${yesterday}.ndjson`);
|
||||
assert.ok(fsExistsSync(rotatedPath), `yesterday's rotated file must exist (${rotatedPath})`);
|
||||
assert.ok(fsExistsSync(livePath), 'live file must exist (with today\'s new event)');
|
||||
const liveContent = fsReadFileSync(livePath, 'utf-8');
|
||||
assert.ok(liveContent.includes(todayTs), 'live file must contain today\'s appended event');
|
||||
assert.ok(!liveContent.includes(`${yesterday}T10:00:00Z`), 'live file must NOT contain yesterday\'s content (it rotated)');
|
||||
assert.equal(getAuditRotateCount(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('26c — concurrent appends during rotation', () => {
|
||||
let TMP;
|
||||
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-26c-')); });
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetAuditRotateState(); });
|
||||
|
||||
it('26c-1: N sequential appendAuditEvent during date change → exactly 1 rotation + all events land in live', () => {
|
||||
__resetAuditRotateState();
|
||||
const livePath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson');
|
||||
rmSync(livePath, { force: true });
|
||||
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
|
||||
writeAuditFile(TMP, 'audit.ndjson', [makeEvent(`${yesterday}T10:00:00Z`)]);
|
||||
__setLastSeenUtcDateForTesting(yesterday);
|
||||
|
||||
// 10 sync appends in quick succession. The first triggers rotation
|
||||
// (date change detected); subsequent appends short-circuit the date
|
||||
// check because _lastSeenUtcDate was updated inside the first call's
|
||||
// _maybeRotateAudit. Result: exactly 1 rotation + all 10 events in
|
||||
// the new live file.
|
||||
const todayTs = new Date().toISOString();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
appendAuditEventS26(makeEvent(todayTs), { olpHome: TMP });
|
||||
}
|
||||
|
||||
assert.equal(getAuditRotateCount(), 1, 'exactly 1 rotation despite 10 sequential appends past date change');
|
||||
const rotatedPath = _pathJoinForSetup(TMP, 'logs', `audit-${yesterday}.ndjson`);
|
||||
assert.ok(fsExistsSync(rotatedPath));
|
||||
const liveContent = fsReadFileSync(livePath, 'utf-8');
|
||||
const liveLines = liveContent.split('\n').filter(Boolean);
|
||||
assert.equal(liveLines.length, 10, 'live file has all 10 appended events');
|
||||
});
|
||||
});
|
||||
|
||||
describe('26d — bin/olp-audit-rotate.mjs CLI', () => {
|
||||
let TMP;
|
||||
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-26d-')); });
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetAuditRotateState(); });
|
||||
|
||||
it('26d-1: --help → exit 0 with usage', async () => {
|
||||
let out = '';
|
||||
const code = await runAuditRotateCli(['--help'], { out: s => { out += s; }, err: () => {} });
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /OLP audit rotation cron tool/);
|
||||
});
|
||||
|
||||
it('26d-2: no live file → exit 0 "no rotation needed"', async () => {
|
||||
__resetAuditRotateState();
|
||||
let out = '';
|
||||
const code = await runAuditRotateCli([`--olp-home=${TMP}`], { out: s => { out += s; }, err: () => {} });
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /No rotation needed/);
|
||||
});
|
||||
|
||||
it('26d-3: live file with yesterday events → CLI rotates + exit 0', async () => {
|
||||
__resetAuditRotateState();
|
||||
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
|
||||
writeAuditFile(TMP, 'audit.ndjson', [makeEvent(`${yesterday}T10:00:00Z`)]);
|
||||
|
||||
let out = '';
|
||||
const code = await runAuditRotateCli([`--olp-home=${TMP}`], { out: s => { out += s; }, err: () => {} });
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /Rotated.*dateUsed=/);
|
||||
const rotatedPath = _pathJoinForSetup(TMP, 'logs', `audit-${yesterday}.ndjson`);
|
||||
assert.ok(fsExistsSync(rotatedPath));
|
||||
});
|
||||
|
||||
it('26d-4: unknown flag → exit 1', async () => {
|
||||
let err = '';
|
||||
const code = await runAuditRotateCli([`--olp-home=${TMP}`, '--unknown'], {
|
||||
out: () => {}, err: s => { err += s; },
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.match(err, /unknown flag --unknown/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('26e — rotated files queryable via lib/audit-query.mjs', () => {
|
||||
let TMP;
|
||||
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-26e-')); });
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetAuditRotateState(); });
|
||||
|
||||
it('26e-1: after rotation, discoverAuditFiles + readAuditWindow see both live + rotated', () => {
|
||||
__resetAuditRotateState();
|
||||
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
writeAuditFile(TMP, 'audit.ndjson', [
|
||||
makeEvent(`${yesterday}T10:00:00Z`),
|
||||
makeEvent(`${yesterday}T11:00:00Z`),
|
||||
]);
|
||||
__setLastSeenUtcDateForTesting(yesterday);
|
||||
|
||||
// Sync rotation: completes before append returns
|
||||
appendAuditEventS26(makeEvent(`${today}T05:00:00Z`), { olpHome: TMP });
|
||||
|
||||
const files = discoverAuditFiles({ olpHome: TMP });
|
||||
assert.ok(files.has('live'), 'live file present');
|
||||
assert.ok(files.has(yesterday), `rotated file for ${yesterday} present`);
|
||||
|
||||
const startMs = Date.parse(`${yesterday}T00:00:00Z`);
|
||||
const endMs = Date.parse(`${today}T23:59:59Z`);
|
||||
const events = [...readAuditWindow({ startMs, endMs, olpHome: TMP })];
|
||||
assert.equal(events.length, 3, 'cross-file read yields yesterday(2) + today(1) = 3 events');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user