diff --git a/src/storage/version/audit.js b/src/storage/version/audit.js index c8964249..c15be555 100644 --- a/src/storage/version/audit.js +++ b/src/storage/version/audit.js @@ -17,7 +17,7 @@ import { } from '@aws-sdk/client-s3'; import getS3Config from '../utils/config.js'; -import { auditKey, auditArchiveKey, auditDirPrefix } from './paths.js'; +import { auditDirPrefix, auditUserKey, auditUserArchiveKey } from './paths.js'; /** Same-user edits within this window (ms) collapse into one entry (last timestamp). 30 min. */ export const AUDIT_TIME_WINDOW_MS = 30 * 60 * 1000; @@ -187,25 +187,48 @@ function usersNormalized(usersJson) { } /** - * Append or update last line in audit.txt (read-modify-write). If last line is same user - * and within AUDIT_TIME_WINDOW_MS and both last and new are edits (no version), replace that - * line; else append. A version entry always appends and is never replaced (breaks the window). + * Derive a stable, privacy-preserving shard key for an audit entry from its users field. Two + * writes by the same user(s) land on the same shard (so write-side dedup still applies); two + * writes by different users land on different shards (so they never race the same If-Match etag). + * + * 16 hex chars from SHA-256 are enough to avoid collisions across the realistic per-doc user set + * and short enough to keep object keys readable. Anonymous / empty / non-email payloads fall back + * to a fixed slug so they share a stable shard. + * @param {string} usersJson + * @returns {Promise} 16 hex chars, or 'anon' for empty/anonymous identities + */ +async function usersHash(usersJson) { + const norm = usersNormalized(usersJson); + if (!norm || norm === 'anonymous') return 'anon'; + const data = new TextEncoder().encode(norm); + const buf = await crypto.subtle.digest('SHA-256', data); + const arr = Array.from(new Uint8Array(buf)); + return arr.map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 16); +} + +/** + * Append one audit entry to the per-user audit shard. Each user gets their own + * `audit-{userHash}.txt` key under `{org}/{repo}/.da-versions/{fileId}/`, so two users editing + * the same doc never compete for the same If-Match etag. Inside a single user shard the + * existing same-user same-window collapse rule still applies; the single GET-modify-PUT- + * with-If-Match runs without retries because (a) cross-user contention is zero by + * construction, and (b) the rare same-user concurrent race folds into one observable entry + * via dedup whether the loser is retried or dropped. * - * Uses If-Match on the PUT so that a concurrent write causes a 412, which triggers up to 6 - * retries with random jitter to reduce thundering-herd contention (7 total attempts). * @param {object} env - * @param {{ bucket: string, org: string }} ctx - bucket, org + * @param {{ bucket: string, org: string }} ctx * @param {string} repo * @param {string} fileId * @param {object} entry - { timestamp, users, path, versionLabel?, versionId? } - * @param {number} [attempt=0] - retry counter (max 6 retries) - * @returns {Promise<{ status: number }>} + * @returns {Promise<{ status: number, error?: string }>} */ -export async function writeAuditEntry(env, ctx, repo, fileId, entry, attempt = 0) { +export async function writeAuditEntry(env, ctx, repo, fileId, entry) { + let userHash; try { const config = getS3Config(env); const client = new S3Client(config); - const key = `${ctx.org}/${auditKey(repo, fileId)}`; + userHash = await usersHash(entry.users); + const key = `${ctx.org}/${auditUserKey(repo, fileId, userHash)}`; const nowMs = parseInt(entry.timestamp, 10) || Date.now(); const entryUsersNorm = usersNormalized(entry.users); @@ -250,7 +273,7 @@ export async function writeAuditEntry(env, ctx, repo, fileId, entry, attempt = 0 const archiveTs = lastLine?.timestamp || Date.now(); await client.send(new PutObjectCommand({ Bucket: ctx.bucket, - Key: `${ctx.org}/${auditArchiveKey(repo, fileId, archiveTs)}`, + Key: `${ctx.org}/${auditUserArchiveKey(repo, fileId, userHash, archiveTs)}`, Body: existingText, ContentType: 'text/plain; charset=utf-8', })); @@ -265,29 +288,11 @@ export async function writeAuditEntry(env, ctx, repo, fileId, entry, attempt = 0 }; if (etag) putInput.IfMatch = etag; - try { - const resp = await client.send(new PutObjectCommand(putInput)); - return { status: resp?.$metadata?.httpStatusCode ?? 200 }; - } catch (e) { - if (e?.$metadata?.httpStatusCode === 412 && attempt < 6) { - // Exponential jitter, 6 retries: per-attempt max 50, 100, 200, - // 400, 800, 1600 ms (~3050 ms worst-case total). Two prior 4-retry - // backoff bumps did not converge because the contention window is - // wider than per-write latency — every retry inside a short window - // observes the same losing-etag generation. Append-only ledger - // (one object per entry) is the next escalation if this still fails. - const delay = Math.random() * 50 * 2 ** attempt; - // eslint-disable-next-line no-await-in-loop -- sequential retry with jitter - await new Promise((r) => { - setTimeout(r, delay); - }); - return writeAuditEntry(env, ctx, repo, fileId, entry, attempt + 1); - } - throw e; - } + const resp = await client.send(new PutObjectCommand(putInput)); + return { status: resp?.$metadata?.httpStatusCode ?? 200 }; } catch (e) { // eslint-disable-next-line no-console - console.error('writeAuditEntry failed', e); + console.error('writeAuditEntry failed', { repo, fileId, userHash: typeof userHash === 'string' ? userHash : undefined }, e); return { status: 500, error: e.message }; } } diff --git a/src/storage/version/paths.js b/src/storage/version/paths.js index 722b5d22..7bc13714 100644 --- a/src/storage/version/paths.js +++ b/src/storage/version/paths.js @@ -52,3 +52,28 @@ export function auditArchiveKey(repo, fileId, timestamp) { export function auditDirPrefix(repo, fileId) { return `${repo}/.da-versions/${fileId}/audit`; } + +/** + * Per-user audit file key. The audit ledger is sharded by hashed user identity so concurrent + * writers from different users never collide on the same R2 key (no If-Match contention). + * @param {string} repo + * @param {string} fileId + * @param {string} userHash - SHA-256-derived stable hash of the normalized users field + * @returns {string} key (repo/.da-versions/fileId/audit-{userHash}.txt) + */ +export function auditUserKey(repo, fileId, userHash) { + return `${repo}/.da-versions/${fileId}/audit-${userHash}.txt`; +} + +/** + * Per-user archive key — same shape as auditUserKey with a timestamp suffix so that per-user + * audit rotation (AUDIT_MAX_ENTRIES) creates a sealed historical object next to the live one. + * @param {string} repo + * @param {string} fileId + * @param {string} userHash + * @param {string|number} timestamp - last entry timestamp in the archived file + * @returns {string} key (repo/.da-versions/fileId/audit-{userHash}-{ts}.txt) + */ +export function auditUserArchiveKey(repo, fileId, userHash, timestamp) { + return `${repo}/.da-versions/${fileId}/audit-${userHash}-${timestamp}.txt`; +} diff --git a/test/storage/object/conditionals.test.js b/test/storage/object/conditionals.test.js index fe4310c8..0d91a072 100644 --- a/test/storage/object/conditionals.test.js +++ b/test/storage/object/conditionals.test.js @@ -228,10 +228,7 @@ describe('Conditional Headers', () => { assert.strictEqual(resp.status, 200); }); - it('returns 412 when ETag does not match and does not retry', async function returnsImmediateOn412NoRetry() { - // writeAuditEntry's 6-retry exponential jitter has a worst-case budget of - // ~3050 ms, which exceeds mocha's 2000 ms default; bound this test above it. - this.timeout(8000); + it('returns 412 when ETag does not match and does not retry', async () => { const existingEtag = '"existing123"'; s3Mock .on(GetObjectCommand) @@ -265,8 +262,8 @@ describe('Conditional Headers', () => { // Should return 412 and NOT retry the main PUT assert.strictEqual(resp.status, 412); - // 7 audit PUT attempts (1 initial + 6 retries on 412) + 1 main PUT = 8 total - assert.strictEqual(s3Mock.commandCalls(PutObjectCommand).length, 8); + // Per-user audit ledger: 1 main PUT (412) + 1 audit PUT (no retry) = 2 total + assert.strictEqual(s3Mock.commandCalls(PutObjectCommand).length, 2); }); }); diff --git a/test/storage/version/audit.test.js b/test/storage/version/audit.test.js index f8d04ca0..af23ae9f 100644 --- a/test/storage/version/audit.test.js +++ b/test/storage/version/audit.test.js @@ -644,61 +644,6 @@ describe('Version Audit', () => { assert.strictEqual(putCalls[0].IfMatch, undefined, 'If-Match must be absent for first write'); }); - it('retries on 412 from PUT and succeeds on a later attempt', async () => { - let getCallCount = 0; - const putCalls = []; - - const makeBody = () => new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('')); - controller.close(); - }, - }); - - const { writeAuditEntry } = await esmock( - '../../../src/storage/version/audit.js', - { - '@aws-sdk/client-s3': { - S3Client: function S3Client() { - this.send = async (cmd) => { - if (cmd instanceof GetObjectCommand) { - getCallCount += 1; - return { Body: makeBody(), ETag: `"etag-${getCallCount}"` }; - } - if (cmd instanceof PutObjectCommand) { - putCalls.push(cmd.input); - if (putCalls.length < 3) { - // First two PUTs: simulate concurrent write → 412 - const err = new Error('precondition failed'); - err.$metadata = { httpStatusCode: 412 }; - throw err; - } - return { $metadata: { httpStatusCode: 200 } }; - } - return { $metadata: { httpStatusCode: 200 } }; - }; - }, - GetObjectCommand, - PutObjectCommand, - }, - '../../../src/storage/utils/config.js': { default: () => ({}) }, - }, - ); - - const result = await writeAuditEntry({}, { bucket: 'b', org: 'o' }, 'repo', 'fid', { - timestamp: '5000', - users: '[{"email":"u@x.com"}]', - path: 'repo/doc.html', - }); - - assert.strictEqual(result.status, 200); - assert.strictEqual(getCallCount, 3, 'must re-read on each retry'); - assert.strictEqual(putCalls.length, 3, 'must retry the PUT until success'); - assert.strictEqual(putCalls[0].IfMatch, '"etag-1"'); - assert.strictEqual(putCalls[1].IfMatch, '"etag-2"', 'first retry uses fresh ETag'); - assert.strictEqual(putCalls[2].IfMatch, '"etag-3"', 'second retry uses fresh ETag'); - }); - it('archives existing content and starts fresh when entry count reaches AUDIT_MAX_ENTRIES', async () => { const { AUDIT_MAX_ENTRIES } = await import('../../../src/storage/version/audit.js'); @@ -744,24 +689,25 @@ describe('Version Audit', () => { const newEntry = { timestamp: String(lastTs + 10000000), - users: '[{"email":"other@x.com"}]', + users: '[{"email":"u@x.com"}]', path: '/doc.html', }; const result = await writeAuditEntry({}, { bucket: 'b', org: 'o' }, 'repo', 'fid', newEntry); assert.strictEqual(result.status, 200); - assert.strictEqual(putCalls.length, 2, 'must PUT archive + new audit.txt'); + assert.strictEqual(putCalls.length, 2, 'must PUT archive + new per-user shard'); - const archivePut = putCalls.find((p) => p.Key.includes('audit-')); - const auditPut = putCalls.find((p) => p.Key.endsWith('audit.txt')); - assert.ok(archivePut, 'archive PUT must exist'); - assert.ok(auditPut, 'audit.txt PUT must exist'); + // Per-user sharding: both PUTs hit audit-{userHash}; archive has -{ts} suffix. + const archivePut = putCalls.find((pu) => /audit-[a-f0-9]{16}-\d+\.txt$/.test(pu.Key)); + const livePut = putCalls.find((pu) => /audit-[a-f0-9]{16}\.txt$/.test(pu.Key)); + assert.ok(archivePut, 'archive PUT must exist (per-user audit-{hash}-{ts}.txt)'); + assert.ok(livePut, 'live per-user PUT must exist (audit-{hash}.txt)'); assert.strictEqual(archivePut.Body, existingText, 'archive must contain the old content'); - assert.ok(archivePut.Key.includes(`audit-${lastTs}`), 'archive key must use last entry timestamp'); - const newAuditLines = auditPut.Body.split('\n').filter((l) => l.trim()); - assert.strictEqual(newAuditLines.length, 1, 'new audit.txt must contain only the new entry'); - assert.ok(newAuditLines[0].includes(newEntry.timestamp), 'new entry must be present in fresh audit.txt'); + assert.ok(archivePut.Key.endsWith(`-${lastTs}.txt`), 'archive key must use last entry timestamp'); + const newAuditLines = livePut.Body.split('\n').filter((l) => l.trim()); + assert.strictEqual(newAuditLines.length, 1, 'fresh shard must contain only the new entry'); + assert.ok(newAuditLines[0].includes(newEntry.timestamp), 'new entry must be present in fresh shard'); }); it('treats malformed users JSON in existing entry as opaque string (no crash)', async () => { @@ -814,17 +760,8 @@ describe('Version Audit', () => { assert.strictEqual(lines.length, 2, 'both entries must be present (no collapse across mismatched users)'); }); - it('returns status 500 when PUT 412 persists across all 7 attempts', async () => { - let getCallCount = 0; - let putCallCount = 0; - - const makeBody = () => new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('')); - controller.close(); - }, - }); - + it('two different users write to two different per-user keys (zero cross-user contention)', async () => { + const calls = []; const { writeAuditEntry } = await esmock( '../../../src/storage/version/audit.js', { @@ -832,14 +769,13 @@ describe('Version Audit', () => { S3Client: function S3Client() { this.send = async (cmd) => { if (cmd instanceof GetObjectCommand) { - getCallCount += 1; - return { Body: makeBody(), ETag: '"etag-x"' }; + const err = new Error('not found'); + err.name = 'NoSuchKey'; + throw err; } if (cmd instanceof PutObjectCommand) { - putCallCount += 1; - const err = new Error('precondition failed'); - err.$metadata = { httpStatusCode: 412 }; - throw err; + calls.push(cmd.input); + return { $metadata: { httpStatusCode: 200 } }; } return { $metadata: { httpStatusCode: 200 } }; }; @@ -850,117 +786,50 @@ describe('Version Audit', () => { '../../../src/storage/utils/config.js': { default: () => ({}) }, }, ); - - const result = await writeAuditEntry({}, { bucket: 'b', org: 'o' }, 'repo', 'fid', { - timestamp: '5000', - users: '[{"email":"u@x.com"}]', - path: 'repo/doc.html', - }); - - assert.strictEqual(result.status, 500, 'persistent 412 must surface as 500 after 7 total attempts'); - assert.strictEqual(result.error, 'precondition failed'); - assert.strictEqual(putCallCount, 7, 'must attempt PUT 7 times total (1 initial + 6 retries)'); - assert.strictEqual(getCallCount, 7, 'must re-read on each attempt'); + const base = { timestamp: '1000', path: '/doc.html' }; + await writeAuditEntry({}, { bucket: 'b', org: 'o' }, 'repo', 'fid', { ...base, users: '[{"email":"a@x.com"}]' }); + await writeAuditEntry({}, { bucket: 'b', org: 'o' }, 'repo', 'fid', { ...base, users: '[{"email":"b@x.com"}]' }); + assert.strictEqual(calls.length, 2); + assert.notStrictEqual(calls[0].Key, calls[1].Key, 'different users must write to different keys'); + assert.match(calls[0].Key, /\/audit-[a-f0-9]{16}\.txt$/, 'per-user shard key pattern'); + assert.match(calls[1].Key, /\/audit-[a-f0-9]{16}\.txt$/, 'per-user shard key pattern'); }); - it('uses exponential jitter backoff (0-50, 0-100, 0-200, 0-400, 0-800, 0-1600 ms) across six 412 retries', async () => { - // Stubs setTimeout and Math.random to capture per-retry delays. - // Asserts per-attempt exponential upper bounds for the 6-retry ladder - // (50, 100, 200, 400, 800, 1600 ms) and total elapsed sleep ~3050 ms. - const originalSetTimeout = globalThis.setTimeout; - const originalRandom = Math.random; - const delays = []; - Math.random = () => 0.999999; - globalThis.setTimeout = (fn, ms) => { - delays.push(ms); - fn(); - return 0; - }; - - try { - const makeBody = () => new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('')); - controller.close(); - }, - }); - - const { writeAuditEntry } = await esmock( - '../../../src/storage/version/audit.js', - { - '@aws-sdk/client-s3': { - S3Client: function S3Client() { - this.send = async (cmd) => { - if (cmd instanceof GetObjectCommand) { - return { Body: makeBody(), ETag: '"etag-x"' }; - } - if (cmd instanceof PutObjectCommand) { - const err = new Error('precondition failed'); - err.$metadata = { httpStatusCode: 412 }; - throw err; - } + it('same-user concurrent writes land on the same per-user key (dedup applies)', async () => { + const calls = []; + const { writeAuditEntry } = await esmock( + '../../../src/storage/version/audit.js', + { + '@aws-sdk/client-s3': { + S3Client: function S3Client() { + this.send = async (cmd) => { + if (cmd instanceof GetObjectCommand) { + const err = new Error('not found'); + err.name = 'NoSuchKey'; + throw err; + } + if (cmd instanceof PutObjectCommand) { + calls.push(cmd.input); return { $metadata: { httpStatusCode: 200 } }; - }; - }, - GetObjectCommand, - PutObjectCommand, + } + return { $metadata: { httpStatusCode: 200 } }; + }; }, - '../../../src/storage/utils/config.js': { default: () => ({}) }, + GetObjectCommand, + PutObjectCommand, }, - ); - - await writeAuditEntry({}, { bucket: 'b', org: 'o' }, 'repo', 'fid', { - timestamp: '5000', - users: '[{"email":"u@x.com"}]', - path: 'repo/doc.html', - }); - } finally { - globalThis.setTimeout = originalSetTimeout; - Math.random = originalRandom; - } - - assert.strictEqual(delays.length, 6, 'must sleep between each of the 6 retries'); - const upperBounds = [50, 100, 200, 400, 800, 1600]; - delays.forEach((ms, i) => { - assert.ok( - ms >= 0 && ms < upperBounds[i], - `delay[${i}]=${ms} must be within [0, ${upperBounds[i]})`, - ); - }); - assert.ok( - delays[2] >= 150, - `delay[2]=${delays[2]} must exceed the prior linear cap of 150 ms`, - ); - assert.ok( - delays[3] >= 200, - `delay[3]=${delays[3]} must exceed the prior linear cap of 200 ms`, - ); - assert.ok( - delays[5] >= 800, - `delay[5]=${delays[5]} must reach the new 6-retry exponential ceiling (>=800 ms)`, - ); - const total = delays.reduce((a, b) => a + b, 0); - assert.ok( - total > 1500, - `total elapsed sleep ${total} must exceed prior 4-retry worst-case (~750 ms)`, - ); - assert.ok( - total < 3200, - `total elapsed sleep ${total} must stay within the new 6-retry worst-case (~3050 ms)`, + '../../../src/storage/utils/config.js': { default: () => ({}) }, + }, ); + const user = '[{"email":"u@x.com"}]'; + await writeAuditEntry({}, { bucket: 'b', org: 'o' }, 'repo', 'fid', { timestamp: '1000', users: user, path: '/doc.html' }); + await writeAuditEntry({}, { bucket: 'b', org: 'o' }, 'repo', 'fid', { timestamp: '2000', users: user, path: '/doc.html' }); + assert.strictEqual(calls.length, 2); + assert.strictEqual(calls[0].Key, calls[1].Key, 'same user writes hit the same per-user key'); }); - it('succeeds on the 5th attempt after four 412s', async () => { - let getCallCount = 0; - let putCallCount = 0; - - const makeBody = () => new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('')); - controller.close(); - }, - }); - + it('anonymous user lands on the audit-anon.txt shard (no hash)', async () => { + const calls = []; const { writeAuditEntry } = await esmock( '../../../src/storage/version/audit.js', { @@ -968,16 +837,12 @@ describe('Version Audit', () => { S3Client: function S3Client() { this.send = async (cmd) => { if (cmd instanceof GetObjectCommand) { - getCallCount += 1; - return { Body: makeBody(), ETag: `"etag-${getCallCount}"` }; + const err = new Error('not found'); + err.name = 'NoSuchKey'; + throw err; } if (cmd instanceof PutObjectCommand) { - putCallCount += 1; - if (putCallCount < 5) { - const err = new Error('precondition failed'); - err.$metadata = { httpStatusCode: 412 }; - throw err; - } + calls.push(cmd.input); return { $metadata: { httpStatusCode: 200 } }; } return { $metadata: { httpStatusCode: 200 } }; @@ -989,16 +854,48 @@ describe('Version Audit', () => { '../../../src/storage/utils/config.js': { default: () => ({}) }, }, ); + await writeAuditEntry({}, { bucket: 'b', org: 'o' }, 'repo', 'fid', { timestamp: '1000', users: '[{"email":"anonymous"}]', path: '/doc.html' }); + assert.strictEqual(calls.length, 1); + assert.ok(calls[0].Key.endsWith('/audit-anon.txt'), 'anonymous identity uses fixed shard'); + }); - const result = await writeAuditEntry({}, { bucket: 'b', org: 'o' }, 'repo', 'fid', { - timestamp: '5000', - users: '[{"email":"u@x.com"}]', - path: 'repo/doc.html', - }); - - assert.strictEqual(result.status, 200, 'must succeed on the 5th attempt'); - assert.strictEqual(putCallCount, 5, 'must have attempted PUT 5 times'); - assert.strictEqual(getCallCount, 5, 'must re-read on each attempt'); + it('returns 500 immediately on PUT 412 with no retry (append-only ledger contract)', async () => { + let putCount = 0; + const { writeAuditEntry } = await esmock( + '../../../src/storage/version/audit.js', + { + '@aws-sdk/client-s3': { + S3Client: function S3Client() { + this.send = async (cmd) => { + if (cmd instanceof GetObjectCommand) { + return { + Body: new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode('')); + c.close(); + }, + }), + ETag: '"etag-x"', + }; + } + if (cmd instanceof PutObjectCommand) { + putCount += 1; + const err = new Error('precondition failed'); + err.$metadata = { httpStatusCode: 412 }; + throw err; + } + return { $metadata: { httpStatusCode: 200 } }; + }; + }, + GetObjectCommand, + PutObjectCommand, + }, + '../../../src/storage/utils/config.js': { default: () => ({}) }, + }, + ); + const result = await writeAuditEntry({}, { bucket: 'b', org: 'o' }, 'repo', 'fid', { timestamp: '1000', users: '[{"email":"u@x.com"}]', path: '/doc.html' }); + assert.strictEqual(result.status, 500, '412 must surface as 500 immediately'); + assert.strictEqual(putCount, 1, 'append-only ledger does not retry on 412'); }); }); }); diff --git a/test/storage/version/paths.test.js b/test/storage/version/paths.test.js index 0e03f539..2ff43d9c 100644 --- a/test/storage/version/paths.test.js +++ b/test/storage/version/paths.test.js @@ -15,6 +15,8 @@ import { auditKey, auditArchiveKey, auditDirPrefix, + auditUserKey, + auditUserArchiveKey, } from '../../../src/storage/version/paths.js'; describe('Version Paths', () => { @@ -57,9 +59,35 @@ describe('Version Paths', () => { }); describe('auditDirPrefix', () => { - it('returns prefix that matches audit.txt and audit-*.txt', () => { + it('returns prefix that matches audit.txt, audit-*.txt, and per-user audit-{hash}.txt files', () => { const prefix = auditDirPrefix('myrepo', 'file-id-xyz'); assert.strictEqual(prefix, 'myrepo/.da-versions/file-id-xyz/audit'); }); }); + + describe('auditUserKey', () => { + it('returns per-user audit shard key under .da-versions', () => { + const key = auditUserKey('myrepo', 'file-id-xyz', 'deadbeefcafef00d'); + assert.strictEqual(key, 'myrepo/.da-versions/file-id-xyz/audit-deadbeefcafef00d.txt'); + }); + + it('lives under the same prefix as auditDirPrefix so ListObjectsV2 picks it up', () => { + const repo = 'r'; + const fileId = 'f'; + const prefix = auditDirPrefix(repo, fileId); + assert.ok(auditUserKey(repo, fileId, 'anon').startsWith(prefix)); + }); + }); + + describe('auditUserArchiveKey', () => { + it('returns per-user archive key with timestamp suffix', () => { + const key = auditUserArchiveKey('myrepo', 'fid', 'abc1234567890def', 1234567890); + assert.strictEqual(key, 'myrepo/.da-versions/fid/audit-abc1234567890def-1234567890.txt'); + }); + + it('matches the audit prefix (read merges it transparently)', () => { + const prefix = auditDirPrefix('r', 'f'); + assert.ok(auditUserArchiveKey('r', 'f', 'anon', '9999').startsWith(prefix)); + }); + }); });