Files
Shade/packages/shade-storage-sqlite/tests/sqlite-inbox-store.test.ts

296 lines
7.8 KiB
TypeScript
Raw Permalink Normal View History

import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { tmpdir } from 'os';
import { join } from 'path';
import { unlinkSync } from 'fs';
import { SqliteInboxStore } from '../src/sqlite-inbox-store.js';
function randBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
globalThis.crypto.getRandomValues(buf);
return buf;
}
function tempDbPath(): string {
return join(
tmpdir(),
`shade-inbox-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
);
}
describe('SqliteInboxStore', () => {
let dbPath: string;
let store: SqliteInboxStore;
beforeEach(() => {
dbPath = tempDbPath();
store = new SqliteInboxStore(dbPath);
});
afterEach(() => {
store.close();
try {
unlinkSync(dbPath);
} catch {}
try {
unlinkSync(dbPath + '-wal');
} catch {}
try {
unlinkSync(dbPath + '-shm');
} catch {}
});
test('owner save + get', async () => {
const key = randBytes(32);
await store.saveAddressOwner('bob', key);
const got = await store.getAddressOwner('bob');
expect(got).not.toBeNull();
expect(got!).toEqual(key);
});
test('putBlob is idempotent on (address, msgId)', async () => {
const ct = randBytes(64);
const a = await store.putBlob({
address: 'bob',
msgId: 'a'.repeat(64),
ciphertext: ct,
expiresAt: Date.now() + 60_000,
});
expect(a.created).toBe(true);
const b = await store.putBlob({
address: 'bob',
msgId: 'a'.repeat(64),
ciphertext: ct,
expiresAt: Date.now() + 60_000,
});
expect(b.created).toBe(false);
expect(b.receivedAt).toBe(a.receivedAt);
});
test('fetchBlobs respects sinceCursor and expires_at', async () => {
const now = Date.now();
await store.putBlob({
address: 'bob',
msgId: '1'.repeat(64),
ciphertext: randBytes(8),
expiresAt: now + 60_000,
});
await store.putBlob({
address: 'bob',
msgId: '2'.repeat(64),
ciphertext: randBytes(8),
expiresAt: now - 1000, // already expired
});
await store.putBlob({
address: 'bob',
msgId: '3'.repeat(64),
ciphertext: randBytes(8),
expiresAt: now + 60_000,
});
const all = await store.fetchBlobs({
address: 'bob',
sinceCursor: 0,
now,
limit: 100,
});
// Expired one filtered out.
expect(all.length).toBe(2);
expect(all.map((r) => r.msgId).sort()).toEqual(['1'.repeat(64), '3'.repeat(64)]);
// Cursor advances strictly.
const half = await store.fetchBlobs({
address: 'bob',
sinceCursor: all[0]!.receivedAt,
now,
limit: 100,
});
expect(half.length).toBe(1);
expect(half[0]!.msgId).toBe(all[1]!.msgId);
});
test('purgeExpired removes only expired rows', async () => {
const now = Date.now();
await store.putBlob({
address: 'bob',
msgId: '1'.repeat(64),
ciphertext: randBytes(8),
expiresAt: now - 1,
});
await store.putBlob({
address: 'bob',
msgId: '2'.repeat(64),
ciphertext: randBytes(8),
expiresAt: now + 60_000,
});
const removed = await store.purgeExpired(now);
expect(removed).toBe(1);
const remaining = await store.fetchBlobs({
address: 'bob',
sinceCursor: 0,
now,
limit: 10,
});
expect(remaining.length).toBe(1);
expect(remaining[0]!.msgId).toBe('2'.repeat(64));
});
test('persists across reopen', async () => {
const key = randBytes(32);
await store.saveAddressOwner('bob', key);
const ct = randBytes(64);
await store.putBlob({
address: 'bob',
msgId: '5'.repeat(64),
ciphertext: ct,
expiresAt: Date.now() + 60_000,
});
store.close();
store = new SqliteInboxStore(dbPath);
const got = await store.getAddressOwner('bob');
expect(got).toEqual(key);
const blobs = await store.fetchBlobs({
address: 'bob',
sinceCursor: 0,
now: Date.now(),
limit: 10,
});
expect(blobs.length).toBe(1);
expect(blobs[0]!.ciphertext).toEqual(ct);
});
test('deleteAddress drops owner + blobs', async () => {
const key = randBytes(32);
await store.saveAddressOwner('bob', key);
await store.putBlob({
address: 'bob',
msgId: '7'.repeat(64),
ciphertext: randBytes(8),
expiresAt: Date.now() + 60_000,
});
await store.deleteAddress('bob');
expect(await store.getAddressOwner('bob')).toBeNull();
const blobs = await store.fetchBlobs({
address: 'bob',
sinceCursor: 0,
now: Date.now(),
limit: 10,
});
expect(blobs.length).toBe(0);
});
test('senderFp round-trips through put + fetch (V4.8)', async () => {
const ct = randBytes(40);
const fp = '0123456789abcdef';
await store.putBlob({
address: 'bob',
msgId: 'a'.repeat(64),
ciphertext: ct,
expiresAt: Date.now() + 60_000,
senderFp: fp,
});
const blobs = await store.fetchBlobs({
address: 'bob',
sinceCursor: 0,
now: Date.now(),
limit: 10,
});
expect(blobs.length).toBe(1);
expect(blobs[0]!.senderFp).toBe(fp);
});
test('senderFp omitted on put → fetched row has senderFp undefined (V4.8 backward-compat)', async () => {
const ct = randBytes(40);
await store.putBlob({
address: 'bob',
msgId: 'b'.repeat(64),
ciphertext: ct,
expiresAt: Date.now() + 60_000,
});
const blobs = await store.fetchBlobs({
address: 'bob',
sinceCursor: 0,
now: Date.now(),
limit: 10,
});
expect(blobs.length).toBe(1);
expect(blobs[0]!.senderFp).toBeUndefined();
});
test('ALTER TABLE migration adds sender_fp to a pre-4.8 schema (V4.8)', async () => {
// Reproduce a pre-4.8 schema in a fresh DB, then reopen via
// SqliteInboxStore which should run the idempotent ALTER without
// dropping the existing rows.
store.close();
try {
unlinkSync(dbPath);
} catch {}
try {
unlinkSync(dbPath + '-wal');
} catch {}
try {
unlinkSync(dbPath + '-shm');
} catch {}
const { Database } = await import('bun:sqlite');
const legacy = new Database(dbPath, { create: true });
legacy.exec(`
CREATE TABLE inbox_blobs (
address TEXT NOT NULL,
msg_id TEXT NOT NULL,
ciphertext TEXT NOT NULL,
received_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY (address, msg_id)
);
`);
legacy.prepare(
'INSERT INTO inbox_blobs (address, msg_id, ciphertext, received_at, expires_at) VALUES (?, ?, ?, ?, ?)',
).run('bob', 'c'.repeat(64), 'AAAA', Date.now(), Date.now() + 60_000);
legacy.close();
store = new SqliteInboxStore(dbPath);
const blobs = await store.fetchBlobs({
address: 'bob',
sinceCursor: 0,
now: Date.now(),
limit: 10,
});
expect(blobs.length).toBe(1);
expect(blobs[0]!.senderFp).toBeUndefined();
// New writes after migration carry senderFp.
await store.putBlob({
address: 'bob',
msgId: 'd'.repeat(64),
ciphertext: randBytes(8),
expiresAt: Date.now() + 60_000,
senderFp: 'feedfacedeadbeef',
});
const after = await store.fetchBlobs({
address: 'bob',
sinceCursor: 0,
now: Date.now(),
limit: 10,
});
const newer = after.find((b) => b.msgId === 'd'.repeat(64));
expect(newer?.senderFp).toBe('feedfacedeadbeef');
});
test('countBlobs ignores expired entries', async () => {
const now = Date.now();
await store.putBlob({
address: 'bob',
msgId: '1'.repeat(64),
ciphertext: randBytes(8),
expiresAt: now - 1,
});
await store.putBlob({
address: 'bob',
msgId: '2'.repeat(64),
ciphertext: randBytes(8),
expiresAt: now + 60_000,
});
expect(await store.countBlobs('bob', now)).toBe(1);
});
});