Skip to content
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"compile": "deno run --allow-env --allow-ffi --allow-sys='hostname' --allow-run --allow-read --allow-write=./dist scripts/compile.ts",
"build": "deno run --node-modules-dir --allow-ffi --allow-run --allow-read --allow-env --allow-write=./build --allow-sys='cpus,homedir' scripts/dashboard-esbuild.ts && deno run --node-modules-dir --allow-ffi --allow-run --allow-read --allow-env --allow-write=./build --allow-sys='cpus,homedir,hostname' scripts/build.ts",
"dist": "deno task lint && deno task build && deno task compile",
"test": "deno task lint && deno test --unstable-worker-options --allow-read=.,$HOME/.cache,$HOME/Library/Caches/deno --allow-write=.,$HOME/.cache,$HOME/Library/Caches/deno --allow-env --allow-ffi --allow-net --allow-sys"
"test": "deno task lint && deno test --no-check --sloppy-imports --unstable-worker-options --allow-read=.,$HOME/.cache,$HOME/Library/Caches/deno --allow-write=.,$HOME/.cache,$HOME/Library/Caches/deno --allow-env --allow-ffi --allow-net --allow-sys"
Comment thread
corrideat marked this conversation as resolved.
Outdated
},
"imports": {
"@db/sqlite": "jsr:@db/sqlite@0.13.0",
Expand Down
223 changes: 223 additions & 0 deletions src/serve/routes-kv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import 'jsr:@db/sqlite'
import {
buildShelterAuthHeader,
buildSignedKvPayload,
createCID,
createTestIdentity,
multicodes,
sbp,
startTestServer,
stopTestServer
} from './routes-test-helpers.ts'

Deno.test({
name: 'routes: KV store endpoints',
sanitizeResources: false,
sanitizeOps: false,
async fn (t: Deno.TestContext) {
const baseURL = await startTestServer()

try {
const owner = createTestIdentity()

await t.step('setup: register billable entity for owner', async () => {
await sbp('chelonia.db/set', owner.contractID, 'identity-contract-data')
await sbp('chelonia.db/set', `head=${owner.contractID}`, JSON.stringify({
HEAD: createCID('owner-head', multicodes.SHELTER_CONTRACT_DATA),
previousKeyOp: null,
height: 0
}))
})

await t.step('POST /kv without auth returns 401', async () => {
const cid = createCID('kv-contract', multicodes.SHELTER_CONTRACT_DATA)
const res = await fetch(`${baseURL}/kv/${cid}/mykey`, {
method: 'POST',
headers: { 'content-type': 'application/octet-stream', 'if-match': '*' },
body: 'test'
})
await res.body?.cancel()
if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`)
})

await t.step('POST /kv with auth but mismatched contractID returns 401', async () => {
const other = createTestIdentity()
const otherAuth = buildShelterAuthHeader(other.contractID, other.SAK)
const res = await fetch(`${baseURL}/kv/${owner.contractID}/mykey`, {
method: 'POST',
headers: {
authorization: otherAuth,
'content-type': 'application/octet-stream',
'if-match': '*'
},
body: 'test'
})
await res.body?.cancel()
if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`)
})

await t.step('POST /kv without If-Match header returns 400', async () => {
const auth = buildShelterAuthHeader(owner.contractID, owner.SAK)
const res = await fetch(`${baseURL}/kv/${owner.contractID}/mykey`, {
method: 'POST',
headers: { authorization: auth, 'content-type': 'application/octet-stream' },
body: 'test'
})
await res.body?.cancel()
if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`)
})

await t.step('POST /kv with invalid key (_private prefix) returns 400', async () => {
const auth = buildShelterAuthHeader(owner.contractID, owner.SAK)
const res = await fetch(`${baseURL}/kv/${owner.contractID}/_private_secret`, {
method: 'POST',
headers: {
authorization: auth,
'content-type': 'application/octet-stream',
'if-match': '*'
},
body: 'test'
})
await res.body?.cancel()
if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`)
})

await t.step('POST /kv with wrong CID type returns 400', async () => {
const fileCID = createCID('not-contract', multicodes.SHELTER_FILE_MANIFEST)
const auth = buildShelterAuthHeader(owner.contractID, owner.SAK)
const res = await fetch(`${baseURL}/kv/${fileCID}/mykey`, {
method: 'POST',
headers: {
authorization: auth,
'content-type': 'application/octet-stream',
'if-match': '*'
},
body: 'test'
})
await res.body?.cancel()
if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`)
})

await t.step('POST /kv with valid signed payload stores value and returns 204', async () => {
const auth = buildShelterAuthHeader(owner.contractID, owner.SAK)
const payload = buildSignedKvPayload(owner.contractID, 'testkey', 0, { hello: 'world' }, owner.SAK)
const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, {
method: 'POST',
headers: {
authorization: auth,
'content-type': 'application/octet-stream',
'if-match': '*'
},
body: payload
})
await res.body?.cancel()
if (res.status !== 204) throw new Error(`Expected 204 but got ${res.status}`)
})

await t.step('GET /kv without auth returns 401', async () => {
const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`)
await res.body?.cancel()
if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`)
})

await t.step('GET /kv with valid auth returns stored value with ETag', async () => {
const auth = buildShelterAuthHeader(owner.contractID, owner.SAK)
const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, {
headers: { authorization: auth }
})
if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`)
const body = await res.text()
if (!body) throw new Error('Expected non-empty body')
const etag = res.headers.get('etag')
if (!etag) throw new Error('Expected ETag header')
const xcid = res.headers.get('x-cid')
if (!xcid) throw new Error('Expected x-cid header')
})

await t.step('GET /kv with valid auth for nonexistent key returns 404', async () => {
const auth = buildShelterAuthHeader(owner.contractID, owner.SAK)
const res = await fetch(`${baseURL}/kv/${owner.contractID}/nonexistent`, {
headers: { authorization: auth }
})
await res.body?.cancel()
if (res.status !== 404) throw new Error(`Expected 404 but got ${res.status}`)
})

await t.step('POST /kv with mismatched ETag returns 412 with current value', async () => {
const auth = buildShelterAuthHeader(owner.contractID, owner.SAK)
const payload = buildSignedKvPayload(owner.contractID, 'testkey', 0, { updated: true }, owner.SAK)
const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, {
method: 'POST',
headers: {
authorization: auth,
'content-type': 'application/octet-stream',
'if-match': '"wrong-etag"'
},
body: payload
})
if (res.status !== 412) throw new Error(`Expected 412 but got ${res.status}`)
const body = await res.text()
if (!body) throw new Error('Expected response body with current value')
const xcid = res.headers.get('x-cid')
if (!xcid) throw new Error('Expected x-cid header on 412')
})

await t.step('POST /kv with matching ETag updates value', async () => {
const auth1 = buildShelterAuthHeader(owner.contractID, owner.SAK)
const getRes = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, {
headers: { authorization: auth1 }
})
const xcid = getRes.headers.get('x-cid')
await getRes.body?.cancel()
if (!xcid) throw new Error('Expected x-cid from GET')

const auth2 = buildShelterAuthHeader(owner.contractID, owner.SAK)
const payload = buildSignedKvPayload(owner.contractID, 'testkey', 0, { updated: true }, owner.SAK)
const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, {
method: 'POST',
headers: {
authorization: auth2,
'content-type': 'application/octet-stream',
'if-match': xcid
},
body: payload
})
await res.body?.cancel()
if (res.status !== 204) throw new Error(`Expected 204 but got ${res.status}`)
})

await t.step('POST /kv with wrong height returns 409', async () => {
const auth = buildShelterAuthHeader(owner.contractID, owner.SAK)
const payload = buildSignedKvPayload(owner.contractID, 'testkey', 999, { bad: true }, owner.SAK)
const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, {
method: 'POST',
headers: {
authorization: auth,
'content-type': 'application/octet-stream',
'if-match': '*'
},
body: payload
})
if (res.status !== 409) throw new Error(`Expected 409 but got ${res.status}`)
await res.body?.cancel()
})

await t.step('POST /kv with invalid payload returns 422', async () => {
const auth = buildShelterAuthHeader(owner.contractID, owner.SAK)
const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, {
method: 'POST',
headers: {
authorization: auth,
'content-type': 'application/octet-stream',
'if-match': '*'
},
body: 'not-valid-json'
})
await res.body?.cancel()
if (res.status !== 422) throw new Error(`Expected 422 but got ${res.status}`)
})
} finally {
await stopTestServer()
}
}
})
Loading
Loading