Files
xbin/test/repository.spec.ts
T

538 lines
16 KiB
TypeScript

/**
* Repository-level tests for D1, R2, import normalization, and burn-after-reading coordination.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { env } from 'cloudflare:test';
import { HttpError } from '../src/lib/model';
import {
buildCommentBlobKey,
buildPasteBlobKey,
claimBurnAfterReading,
consumeBurnAfterReading,
coordinatorClaim,
coordinatorConsume,
createComment,
createPaste,
deleteObjectKeys,
deletePasteByUser,
enqueueGcMessages,
ensurePasteReadable,
findExpiredPasteIds,
generateId,
generateSecretToken,
getCommentRows,
getEnvelope,
getPasteRow,
importPrivateBinBundle,
loadPasteResponse,
purgePasteStorage,
putEnvelope,
releaseExpiredClaims,
sha256Hex,
} from '../src/lib/repository';
import type { PasteRow, PasteStatus } from '../src/lib/types';
import { resetState, sampleEnvelope } from './support';
// Helper rows let the tests target individual repository branches without full request setup.
async function insertPasteRow(overrides: Partial<PasteRow> & { id: string }): Promise<PasteRow> {
const row: PasteRow = {
id: overrides.id,
schema_version: 2,
blob_key: overrides.blob_key ?? buildPasteBlobKey(overrides.id),
created_at: overrides.created_at ?? 1_700_000_000_000,
expire_at: overrides.expire_at ?? null,
status: overrides.status ?? 'active',
burn_after_reading: overrides.burn_after_reading ?? 0,
discussion_open: overrides.discussion_open ?? 0,
formatter: overrides.formatter ?? 'plaintext',
has_attachment: overrides.has_attachment ?? 0,
delete_token_hash: overrides.delete_token_hash ?? 'delete-hash',
claim_token_hash: overrides.claim_token_hash ?? null,
claim_expires_at: overrides.claim_expires_at ?? null,
comment_count: overrides.comment_count ?? 0,
size_bytes: overrides.size_bytes ?? 10,
import_source: overrides.import_source ?? null,
metadata_json: overrides.metadata_json ?? '{}',
};
await env.DB.prepare(
`INSERT INTO pastes (
id, schema_version, blob_key, created_at, expire_at, status,
burn_after_reading, discussion_open, formatter, has_attachment,
delete_token_hash, claim_token_hash, claim_expires_at,
comment_count, size_bytes, import_source, metadata_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)`,
)
.bind(
row.id,
row.schema_version,
row.blob_key,
row.created_at,
row.expire_at,
row.status,
row.burn_after_reading,
row.discussion_open,
row.formatter,
row.has_attachment,
row.delete_token_hash,
row.claim_token_hash,
row.claim_expires_at,
row.comment_count,
row.size_bytes,
row.import_source,
row.metadata_json,
)
.run();
return row;
}
beforeEach(async () => {
await resetState();
});
describe('repository helpers', () => {
it('stores and removes envelopes in R2', async () => {
const pasteId = 'abcdef1234567890';
const commentId = '1234567890abcdef';
const pasteKey = buildPasteBlobKey(pasteId);
const commentKey = buildCommentBlobKey(pasteId, commentId);
expect(generateId()).toMatch(/^[a-f0-9]{16}$/);
expect(generateSecretToken()).toMatch(/^[A-Za-z0-9_-]+$/);
expect(await sha256Hex('hello')).toHaveLength(64);
await putEnvelope(env.PASTES_BUCKET, pasteKey, sampleEnvelope());
await putEnvelope(env.PASTES_BUCKET, commentKey, sampleEnvelope({ ct: 'comment' }));
expect((await getEnvelope(env.PASTES_BUCKET, pasteKey))?.ct).toBe('ciphertext');
expect((await getEnvelope(env.PASTES_BUCKET, commentKey))?.ct).toBe('comment');
await deleteObjectKeys(env.PASTES_BUCKET, [pasteKey, commentKey]);
expect(await getEnvelope(env.PASTES_BUCKET, pasteKey)).toBeNull();
expect(await getEnvelope(env.PASTES_BUCKET, commentKey)).toBeNull();
});
it('imports bundles, sanitizes imported fields, and rejects duplicates or expired pastes', async () => {
await expect(importPrivateBinBundle(env, null as never, Date.now())).rejects.toThrow(HttpError);
const expiredResult = await importPrivateBinBundle(
env,
{
source: 'privatebin-filesystem',
pasteId: 'aaaaaaaaaaaaaaaa',
paste: sampleEnvelope({
meta: { created: 1_700_000_000, expire_date: 1 },
id: 'discard-me',
}),
},
Date.now(),
);
expect(expiredResult).toEqual({
id: 'aaaaaaaaaaaaaaaa',
importedComments: 0,
skipped: true,
status: 'expired',
});
const imported = await importPrivateBinBundle(
env,
{
source: 'filesystem',
pasteId: 'bbbbbbbbbbbbbbbb',
paste: sampleEnvelope({
meta: { created: '1700000000', expire_date: '4102444800' },
id: 'discard-me',
parentId: 'discard-me-too',
}),
comments: [
{
id: 'cccccccccccccccc',
v: 2,
adata: ['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none'],
ct: 'comment-ciphertext',
pasteid: 'bbbbbbbbbbbbbbbb',
parentid: 'bbbbbbbbbbbbbbbb',
parentId: 'bbbbbbbbbbbbbbbb',
meta: { created: '1700000001' },
},
],
},
Date.now(),
);
expect(imported.skipped).toBe(false);
expect(imported.importedComments).toBe(1);
expect(imported.deleteToken).toMatch(/^[A-Za-z0-9_-]+$/);
const storedPaste = await getEnvelope(env.PASTES_BUCKET, buildPasteBlobKey('bbbbbbbbbbbbbbbb'));
const storedComment = await getEnvelope(env.PASTES_BUCKET, buildCommentBlobKey('bbbbbbbbbbbbbbbb', 'cccccccccccccccc'));
expect(storedPaste).not.toHaveProperty('id');
expect(storedPaste).not.toHaveProperty('parentId');
expect(storedComment).not.toHaveProperty('id');
expect(storedComment?.pasteid).toBe('bbbbbbbbbbbbbbbb');
expect(storedComment?.parentid).toBe('bbbbbbbbbbbbbbbb');
await expect(
importPrivateBinBundle(
env,
{
source: 'filesystem',
pasteId: 'bbbbbbbbbbbbbbbb',
paste: sampleEnvelope(),
},
Date.now(),
),
).rejects.toThrow('Paste already exists.');
});
it('imports modern comments, falls back imported defaults, and rejects malformed bundles', async () => {
const modernPaste = sampleEnvelope();
delete modernPaste.meta;
const imported = await importPrivateBinBundle(
env,
{
source: ' ',
pasteId: 'dededededededede',
paste: modernPaste,
comments: [
{
id: 'efefefefefefefef',
v: 2,
adata: [['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none'], 'plaintext', 0, 0],
ct: 'modern-comment-ciphertext',
},
],
},
1_700_000_000_000,
);
expect(imported.status).toBe('active');
expect((await getPasteRow(env, 'dededededededede'))?.import_source).toBe('privatebin-filesystem');
expect((await getCommentRows(env, 'dededededededede'))[0]?.parent_id).toBe('dededededededede');
expect((await getEnvelope(env.PASTES_BUCKET, buildCommentBlobKey('dededededededede', 'efefefefefefefef')))?.pasteid).toBe(
'dededededededede',
);
await expect(
importPrivateBinBundle(
env,
{
source: 'filesystem',
pasteId: '1010101010101010',
paste: {
v: 2,
adata: [null, 'plaintext', 0, 0],
ct: 'invalid-imported-paste',
} as never,
},
Date.now(),
),
).rejects.toThrow('Invalid imported paste.');
await expect(
importPrivateBinBundle(
env,
{
source: 'filesystem',
pasteId: '2020202020202020',
paste: sampleEnvelope(),
comments: [
{
id: '3030303030303030',
v: 2,
adata: [null, 'plaintext', 0, 0],
ct: 'invalid-comment',
} as never,
],
},
Date.now(),
),
).rejects.toThrow('Invalid imported comment.');
await expect(
importPrivateBinBundle(
env,
{
source: 'filesystem',
pasteId: '4040404040404040',
paste: sampleEnvelope(),
comments: [
{
id: '5050505050505050',
v: 2,
adata: ['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none'],
ct: 'mismatch-comment',
pasteid: '6060606060606060',
parentid: '4040404040404040',
},
],
},
Date.now(),
),
).rejects.toThrow('Imported comment paste identifier does not match.');
});
it('loads paste responses, skips missing comment blobs, and deletes pastes by token', async () => {
const created = await createPaste(
env,
{
envelope: sampleEnvelope({
meta: { expire: '1day' },
}),
expireKey: '1day',
turnstileToken: null,
},
1_700_000_000_000,
);
await createComment(
env,
created.id,
{
envelope: sampleEnvelope({
ct: 'comment-ciphertext',
}),
parentId: created.id,
turnstileToken: null,
},
1_700_000_001_000,
);
const comments = await getCommentRows(env, created.id);
await deleteObjectKeys(env.PASTES_BUCKET, [comments[0].blob_key]);
const row = await getPasteRow(env, created.id);
const response = await loadPasteResponse(env, row!, 1_700_000_000_500);
expect(response?.comments).toHaveLength(0);
expect((response?.meta as Record<string, number>).time_to_live).toBeGreaterThan(0);
expect(await deletePasteByUser(env, created.id, 'wrong-token')).toBe(false);
expect(await deletePasteByUser(env, created.id, created.deleteToken)).toBe(true);
expect(await deletePasteByUser(env, created.id, created.deleteToken)).toBe(false);
const noExpiry = await insertPasteRow({
id: '9090909090909090',
expire_at: null,
});
await putEnvelope(env.PASTES_BUCKET, buildPasteBlobKey('9090909090909090'), sampleEnvelope());
expect((await loadPasteResponse(env, noExpiry, Date.now()))?.meta.time_to_live).toBe(0);
});
it('stores attachment metadata when the envelope meta block is absent', async () => {
const envelope = sampleEnvelope({
attachment: {
v: 2,
adata: [['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none']],
ct: 'attachment-ciphertext',
},
meta: undefined,
});
delete envelope.meta;
const created = await createPaste(
env,
{
envelope,
expireKey: 'never',
turnstileToken: null,
},
Date.now(),
);
const row = await getPasteRow(env, created.id);
expect(row?.has_attachment).toBe(1);
expect(row?.expire_at).toBeNull();
const response = await loadPasteResponse(env, row!, Date.now());
expect(response?.meta.time_to_live).toBe(0);
expect(response?.meta.comment_count).toBe(0);
});
it('releases claims, finds expired ids, and enqueues gc batches', async () => {
await insertPasteRow({
id: '1111111111111111',
status: 'claimed',
burn_after_reading: 1,
claim_expires_at: 10,
});
await insertPasteRow({
id: '2222222222222222',
status: 'active',
expire_at: 20,
});
await insertPasteRow({
id: '3333333333333333',
status: 'claimed',
expire_at: 30,
});
expect(await releaseExpiredClaims(env, 100)).toBe(1);
expect(await findExpiredPasteIds(env, 100, 1)).toEqual(['2222222222222222']);
const queued: unknown[] = [];
await enqueueGcMessages(
{
...env,
GC_QUEUE: {
sendBatch: async (messages: unknown[]) => {
queued.push(...messages);
},
} as Queue,
},
[
{ pasteId: '2222222222222222', finalStatus: 'expired' },
{ pasteId: '3333333333333333', finalStatus: 'deleted' },
],
);
expect(queued).toHaveLength(2);
await enqueueGcMessages(
{
...env,
GC_QUEUE: {
sendBatch: async () => {
throw new Error('should not run');
},
} as Queue,
},
[],
);
});
it('handles coordinator and readability edge cases', async () => {
expect(await coordinatorClaim(env, 'ffffffffffffffff', 1000)).toEqual({
ok: false,
message: 'Document is unavailable.',
});
await insertPasteRow({
id: '4444444444444444',
burn_after_reading: 0,
});
expect(await coordinatorClaim(env, '4444444444444444', 1000)).toEqual({
ok: false,
message: 'Document is not burn-after-reading.',
});
await insertPasteRow({
id: '5555555555555555',
burn_after_reading: 1,
status: 'claimed',
claim_expires_at: Date.now() + 10_000,
});
expect(await coordinatorClaim(env, '5555555555555555', 1000)).toEqual({
ok: false,
message: 'Document is already being viewed.',
});
await putEnvelope(env.PASTES_BUCKET, buildPasteBlobKey('6666666666666666'), sampleEnvelope());
await insertPasteRow({
id: '6666666666666666',
burn_after_reading: 1,
});
const claim = await coordinatorClaim(env, '6666666666666666', 5_000);
expect(claim.ok).toBe(true);
expect(claim.claimToken).toBeTruthy();
expect(await coordinatorConsume(env, '6666666666666666', 'wrong-token')).toEqual({
ok: false,
message: 'Invalid claim token.',
});
expect(await coordinatorConsume(env, '6666666666666666', claim.claimToken!)).toEqual({ ok: true });
expect(await getEnvelope(env.PASTES_BUCKET, buildPasteBlobKey('6666666666666666'))).toBeNull();
expect(() => ensurePasteReadable(null, Date.now())).toThrow('Document does not exist, has expired or has been deleted.');
const deleted = await insertPasteRow({
id: '7777777777777777',
status: 'deleted',
});
expect(() => ensurePasteReadable(deleted, Date.now())).toThrow('Document does not exist, has expired or has been deleted.');
const readable = await insertPasteRow({
id: '8888888888888888',
status: 'active',
expire_at: Date.now() + 10_000,
});
expect(ensurePasteReadable(readable, Date.now()).id).toBe('8888888888888888');
expect(() =>
ensurePasteReadable(
{
...readable,
expire_at: Date.now() - 1,
},
Date.now(),
),
).toThrow('Document does not exist, has expired or has been deleted.');
expect(await consumeBurnAfterReading(env, 'ffffffffffffffff', 'token')).toEqual({
ok: false,
message: 'Document cannot be consumed.',
});
});
it('uses the durable object stub for claim and consume helpers', async () => {
await putEnvelope(env.PASTES_BUCKET, buildPasteBlobKey('9999999999999999'), sampleEnvelope());
await insertPasteRow({
id: '9999999999999999',
burn_after_reading: 1,
});
const claim = await claimBurnAfterReading(env, '9999999999999999');
expect(claim.ok).toBe(true);
expect(claim.claimToken).toBeTruthy();
const consume = await consumeBurnAfterReading(env, '9999999999999999', claim.claimToken!);
expect(consume).toEqual({ ok: true });
const stub = env.PASTE_COORDINATOR.get(env.PASTE_COORDINATOR.idFromName('aaaaaaaaaaaaaaaa'));
const missingId = await stub.fetch('https://coordinator.local/claim', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
});
expect(missingId.status).toBe(400);
expect((await missingId.json()).ok).toBe(false);
const missingClaimToken = await stub.fetch('https://coordinator.local/consume', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ pasteId: 'aaaaaaaaaaaaaaaa' }),
});
expect(missingClaimToken.status).toBe(400);
expect((await missingClaimToken.json()).ok).toBe(false);
const missingRoute = await stub.fetch('https://coordinator.local/unknown', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ pasteId: 'aaaaaaaaaaaaaaaa' }),
});
expect(missingRoute.status).toBe(404);
expect((await missingRoute.json()).ok).toBe(false);
});
it('purges storage and marks final statuses', async () => {
await putEnvelope(env.PASTES_BUCKET, buildPasteBlobKey('abababababababab'), sampleEnvelope());
await putEnvelope(env.PASTES_BUCKET, buildCommentBlobKey('abababababababab', 'cdcdcdcdcdcdcdcd'), sampleEnvelope({ ct: 'comment' }));
await insertPasteRow({
id: 'abababababababab',
comment_count: 1,
});
await env.DB.prepare(
`INSERT INTO comments (id, paste_id, parent_id, blob_key, created_at, size_bytes)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
)
.bind(
'cdcdcdcdcdcdcdcd',
'abababababababab',
'abababababababab',
buildCommentBlobKey('abababababababab', 'cdcdcdcdcdcdcdcd'),
Date.now(),
10,
)
.run();
await purgePasteStorage(env, 'abababababababab', 'expired');
expect(await getEnvelope(env.PASTES_BUCKET, buildPasteBlobKey('abababababababab'))).toBeNull();
expect(await getCommentRows(env, 'abababababababab')).toHaveLength(0);
expect((await getPasteRow(env, 'abababababababab'))?.status).toBe('expired');
});
});