Files
PotentialStorage/test/browser/storage.test.ts
T
Claude 68d1a1c8e9
CI / Node 24 (push) Successful in 15s
CI / Node 26 (push) Successful in 16s
CI / Node 22 (pull_request) Successful in 13s
CI / Node 24 (pull_request) Failing after 3s
CI / Node 26 (pull_request) Failing after 4s
CI / Chromium (push) Failing after 1m8s
CI / Publish to staging registry (push) Skipped
CI / Chromium (pull_request) Failing after 1m3s
CI / Publish to staging registry (pull_request) Skipped
CI / Node 22 (push) Failing after 4s
Add encryption, Chromium tests and a README
- KVStorageEncrypted: AES-GCM per value (fresh IV, storage key as AAD), keys stay
  readable; decrypts and re-emits pushed changes and passes subscriptions through, so it
  can be a composite source. generateKey / exportKey / importKey / deriveKey (PBKDF2).
- KVStorageHttpServer: allowOrigins (CORS), including cache-control for SSE preflights.
- Chromium tests (Vitest browser mode + Playwright) against a real cross-origin server:
  conformance for IndexedDB, encrypted, HTTP and composite stores; signed requests and
  SSE; two devices syncing, offline writes surviving a reload. New CI job.
- Composite fixes found by those tests: sync() during a running sync schedules one more
  (changes between the first scan and the stream connecting were missed); a queued write
  the source refuses is dropped, restored and reported (onError) instead of blocking the
  queue forever.
- README with examples, mirrored in test/readme.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
2026-10-23 14:25:22 +00:00

139 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, inject, it, vi } from 'vitest'
import { DocumentStorageKV } from '../../src/document-storage/document-storage-kv.js'
import type { BaseKVStorage } from '../../src/kv-storage/base-kv-storage.js'
import { KVStorageComposite } from '../../src/kv-storage/kv-storage-composite.js'
import { KVStorageEncrypted } from '../../src/kv-storage/kv-storage-encrypted.js'
import { KVStorageHttp } from '../../src/kv-storage/kv-storage-http.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import { KVStorageIndexedDB } from '../../src/kv-storage/kv-storage-indexeddb.js'
import { Ed25519Signer, type RequestSigner } from '../../src/utils/request-auth.js'
import { collect } from '../helpers.js'
import { runKVStorageConformance } from '../kv-storage/kv-storage.conformance.js'
const serverUrl = inject('serverUrl')
const key = await KVStorageEncrypted.generateKey()
let n = 0
const unique = (name: string) => `${name}-${Date.now()}-${n++}`
/** A fresh store on the test server: `open/<id>` (no auth) or `signed/<id>` (Ed25519). */
const space = (auth: 'open' | 'signed') => `${auth}/${unique('space')}`
/** A client for a store on the test server (another origin, so CORS applies). */
function http(path = space('open'), options: { signer?: RequestSigner; network?: { online: boolean } } = {}) {
return new KVStorageHttp({
url: `${serverUrl}/${path}`,
signer: options.signer,
fetch: (url, init) => (options.network?.online === false ? Promise.reject(new TypeError('Failed to fetch')) : fetch(url, init)),
})
}
runKVStorageConformance('KVStorageIndexedDB (Chromium)', () => new KVStorageIndexedDB({ name: unique('idb') }))
runKVStorageConformance('KVStorageEncrypted over IndexedDB (Chromium)', () => new KVStorageEncrypted(new KVStorageIndexedDB({ name: unique('idb') }), { key }))
runKVStorageConformance('KVStorageHttp (Chromium, cross-origin)', () => http())
runKVStorageConformance(
'KVStorageComposite: encrypted HTTP + memory + IndexedDB (Chromium)',
() =>
new KVStorageComposite({
source: new KVStorageEncrypted(http(), { key }),
replicas: [new KVStorageInMemory(), new KVStorageIndexedDB({ name: unique('idb') })],
}),
)
describe('signed requests (Chromium)', () => {
it('signs with Ed25519 and only allows the signer’s own keys', async () => {
const path = space('signed')
const alice = await Ed25519Signer.generate()
const bob = await Ed25519Signer.generate()
const client = http(path, { signer: alice })
await client.child(alice.publicKey).set('profile', { name: 'Alice' })
expect(await client.child(alice.publicKey).get('profile')).toEqual({ name: 'Alice' })
await expect(client.child(bob.publicKey).set('profile', {})).rejects.toMatchObject({ status: 403 })
await expect(http(path).child(alice.publicKey).get('profile')).rejects.toMatchObject({ status: 401 })
})
it('streams changes over SSE across origins', async () => {
const path = space('signed')
const alice = await Ed25519Signer.generate()
const watcher = http(path, { signer: alice })
await watcher.child(alice.publicKey).subscribe()
const pushed = watcher.waitFor('change', (e) => e.origin === 'remote')
await http(path, { signer: alice }).child(alice.publicKey).set('x', 1n)
expect((await pushed).changes).toEqual([{ key: `${alice.publicKey}/x`, value: 1n }])
await watcher.close()
})
})
describe('two devices syncing through the server (Chromium)', () => {
/** One device: an encrypted, signed source and a persistent IndexedDB replica and queue. */
function device(path: string, signer: Ed25519Signer, dbName: string, network = { online: true }) {
const indexedDb = new KVStorageIndexedDB({ name: dbName })
const db = new KVStorageComposite({
source: new KVStorageEncrypted(http(path, { signer, network }), { key }),
replicas: [new KVStorageInMemory(), indexedDb],
queue: indexedDb.child('~sync'),
prefix: signer.publicKey,
retryMs: 50,
})
return { db, user: db.child(signer.publicKey), network }
}
it('pushes writes from one device to the other', async () => {
const path = space('signed')
const signer = await Ed25519Signer.generate()
const a = device(path, signer, unique('a'))
const b = device(path, signer, unique('b'))
await Promise.all([a.db.sync(), b.db.sync()])
const seen = b.db.waitFor('change', (e) => e.origin === 'remote')
await a.user.child('notes').set('1', { text: 'from A' })
expect((await seen).changes).toEqual([{ key: `${signer.publicKey}/notes/1`, value: { text: 'from A' } }])
expect(await b.user.child('notes').get('1')).toEqual({ text: 'from A' })
await Promise.all([a.db.close(), b.db.close()])
})
it('works offline, survives a reload with queued writes, and syncs when back online', async () => {
const path = space('signed')
const signer = await Ed25519Signer.generate()
const dbName = unique('a')
const first = device(path, signer, dbName)
const docs = new DocumentStorageKV(first.user).child('todos')
await docs.set('1', { title: 'Synced', done: false })
await first.db.sync()
first.network.online = false
await docs.update('1', { $set: { done: true } })
await docs.set('2', { title: 'Written offline', done: false })
expect(first.db.queued).toBe(2)
await first.db.close()
// "Reload": new instances over the same IndexedDB database, still offline.
const network = { online: false }
const reloaded = device(path, signer, dbName, network)
const reloadedDocs = new DocumentStorageKV(reloaded.user).child('todos')
expect(await collect(reloadedDocs.find({ done: false }))).toEqual([{ key: '2', data: { title: 'Written offline', done: false } }])
network.online = true
await reloaded.db.sync()
expect(reloaded.db.queued).toBe(0)
const other = device(path, signer, unique('b'))
await other.db.sync()
expect(await collect(new DocumentStorageKV(other.user).child('todos').find())).toEqual([
{ key: '1', data: { title: 'Synced', done: true } },
{ key: '2', data: { title: 'Written offline', done: false } },
])
await Promise.all([reloaded.db.close(), other.db.close()])
})
it('stores only ciphertext on the server', async () => {
const path = space('signed')
const signer = await Ed25519Signer.generate()
const a = device(path, signer, unique('a'))
await a.user.set('secret', 'the plans')
const raw: BaseKVStorage = http(path, { signer })
const [stored] = await raw.readMany([`${signer.publicKey}/secret`])
expect(stored).toBeInstanceOf(Uint8Array)
await vi.waitFor(async () => expect(await a.user.get('secret')).toBe('the plans'))
await a.db.close()
})
})