// Hardening tests for the schema-sheets read path (design Risk #2 — JMESPath
// injection, and Risk #9 — full-scan amplification). Covers the validation
// chokepoint (safeJmesPath) and listApps()'s scan bound + error wrapping using
// a stubbed `sheets` handle, so no relay/swarm/Corestore is needed.
import test from 'node:test'
import assert from 'node:assert/strict'
import sheetsCatalog from '../backend/sheets-catalog.js'
const { SheetsCatalog, APPS_SCHEMA, rowToApp, safeJmesPath, MAX_QUERY_LEN, MAX_SHEETS_ROWS } = sheetsCatalog
test('sheets catalogue maps Pear v3 native delivery columns into the shared app contract', () => {
const key = 'a'.repeat(52)
assert.deepEqual(APPS_SCHEMA.anyOf.at(-1), {
properties: { nativeDeliveryStatus: { const: 'available' } },
required: ['nativeDeliveryStatus', 'nativeDeliveryKind', 'nativeInstallLink']
})
const app = rowToApp({
uuid: 'native-demo',
json: {
name: 'Native Demo',
type: 'standalone',
nativeDeliveryStatus: 'available',
nativeDeliveryKind: 'pear-v3',
nativeInstallLink: `pear://${key}`,
nativeProductName: 'Native Demo',
nativeTargets: ['linux-x64']
}
})
assert.deepEqual(app.nativeDelivery, {
status: 'available',
kind: 'pear-v3',
installLink: `pear://${key}`,
productName: 'Native Demo',
targets: ['linux-x64']
})
})
test('safeJmesPath treats empty/absent query as "no filter"', () => {
assert.equal(safeJmesPath(undefined), undefined)
assert.equal(safeJmesPath(null), undefined)
assert.equal(safeJmesPath(''), undefined)
})
test('safeJmesPath rejects non-string queries instead of coercing them', () => {
assert.throws(() => safeJmesPath(123), /must be a string/)
assert.throws(() => safeJmesPath({}), /must be a string/)
assert.throws(() => safeJmesPath(['x']), /must be a string/)
assert.throws(() => safeJmesPath(true), /must be a string/)
})
test('safeJmesPath caps query length', () => {
assert.throws(() => safeJmesPath('['.repeat(MAX_QUERY_LEN + 1)), /too long/)
})
test('safeJmesPath rejects backticks and non-whitelisted shapes', () => {
assert.throws(() => safeJmesPath('[?name==`evil`]'), /Unsupported/) // raw literal
assert.throws(() => safeJmesPath('name'), /Unsupported/) // not a [?…] filter
assert.throws(() => safeJmesPath('[?to_string(@)]'), /Unsupported/) // builtin call w/ @ ref
})
test('safeJmesPath accepts a whitelisted filter unchanged', () => {
const q = "[?type=='standalone']"
assert.equal(safeJmesPath(q), q)
assert.equal(safeJmesPath("[?contains(name, 'chat')]"), "[?contains(name, 'chat')]")
})
// --- listApps(): scan bound + evaluation error wrapping --------------------
function makeCatalog (listImpl) {
const sc = new SheetsCatalog({}, null) // store/swarm unused on the read path
sc.appsSchemaId = async () => 'apps-schema-1'
sc.sheets = { list: listImpl }
return sc
}
test('listApps passes a row-scan limit to sheets.list', async () => {
let captured
const sc = makeCatalog(async (sid, opts) => { captured = opts; return [] })
await sc.listApps()
assert.equal(captured.limit, MAX_SHEETS_ROWS)
assert.equal(captured.query, undefined)
await sc.listApps("[?type=='standalone']")
assert.equal(captured.limit, MAX_SHEETS_ROWS)
assert.equal(captured.query, "[?type=='standalone']")
})
test('listApps caps the returned row set at MAX_SHEETS_ROWS', async () => {
const huge = Array.from({ length: MAX_SHEETS_ROWS + 50 }, (_, i) => ({
uuid: 'app-' + i,
json: { name: 'App ' + i, type: 'standalone', link: 'hyper://' + i.toString(16).padStart(64, '0') + '/' }
}))
const sc = makeCatalog(async () => huge)
const apps = await sc.listApps()
assert.equal(apps.length, MAX_SHEETS_ROWS)
})
test('listApps wraps a malformed-but-allowed query in a clean error', async () => {
// Mimics schema-sheets compile() throwing on an expression the whitelist let
// through (e.g. unbalanced parens) — the raw library stack must not escape.
const sc = makeCatalog(async () => { throw new Error('Syntax error: unexpected token at 3') })
await assert.rejects(() => sc.listApps('[?(((]'), /Invalid search query/)
})
test('listApps does NOT mask a genuine (non-query) failure', async () => {
const sc = makeCatalog(async () => { throw new Error('storage offline') })
await assert.rejects(() => sc.listApps(), /storage offline/)
})
test('listApps returns [] when the room has no apps schema yet', async () => {
const sc = new SheetsCatalog({}, null)
sc.appsSchemaId = async () => null
sc.sheets = { list: async () => { throw new Error('should not be called') } }
assert.deepEqual(await sc.listApps("[?type=='standalone']"), [])
})
test('listSchemas exposes sanitized schema names and ids for RPC callers', async () => {
const sc = new SheetsCatalog({}, null)
sc.sheets = {
listSchemas: async () => [
{ name: 'apps', schemaId: 'schema-apps', extra: 'ignored' },
{ name: '', schemaId: 'empty-name' },
{ name: 'missing-id' },
{ name: 'buffer-id', schemaId: Buffer.from([0x12, 0x34]) }
]
}
assert.deepEqual(await sc.listSchemas(), [
{ name: 'apps', schemaId: 'schema-apps' },
{ name: 'buffer-id', schemaId: '1234' }
])
})