Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

235 lines
7.9 KiB
TypeScript

/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { TableQueryValidationError } from '@/lib/table/errors'
import {
validatePredicate,
validatePredicateShape,
validateSortSpec,
} from '@/lib/table/query-builder/validate'
import type { ColumnDefinition } from '@/lib/table/types'
const COLS: ColumnDefinition[] = [
{ name: 'wins', type: 'number' },
{ name: 'status', type: 'string' },
{ name: 'metadata', type: 'json' },
]
describe('validatePredicate', () => {
it('accepts a valid predicate over real + system columns', () => {
expect(() =>
validatePredicate(
{
all: [
{ field: 'wins', op: 'gte', value: 10 },
{ field: 'createdAt', op: 'lt', value: '2026-01-01' },
{ any: [{ field: 'status', op: 'eq', value: 'active' }] },
],
},
COLS
)
).not.toThrow()
})
it('rejects an unknown column', () => {
expect(() => validatePredicate({ all: [{ field: 'nope', op: 'eq', value: 1 }] }, COLS)).toThrow(
/Unknown filter column/
)
})
it('rejects equality/containment ops on a json column', () => {
for (const op of ['eq', 'ne', 'in', 'nin'] as const) {
const value = op === 'in' || op === 'nin' ? ['x'] : 'x'
expect(() => validatePredicate({ all: [{ field: 'metadata', op, value }] }, COLS)).toThrow(
/json column/
)
}
})
it('allows text-match / null ops on a json column', () => {
expect(() =>
validatePredicate({ all: [{ field: 'metadata', op: 'ilike', value: '*x*' }] }, COLS)
).not.toThrow()
expect(() =>
validatePredicate({ all: [{ field: 'metadata', op: 'isNull' }] }, COLS)
).not.toThrow()
})
it('rejects an empty in/nin array', () => {
expect(() =>
validatePredicate({ all: [{ field: 'wins', op: 'in', value: [] }] }, COLS)
).toThrow(/non-empty array/)
})
it('rejects an invalid field name', () => {
expect(() =>
validatePredicate({ all: [{ field: "x'; DROP", op: 'eq', value: 1 }] }, COLS)
).toThrow(/Invalid filter column/)
})
it('carries the INVALID_FILTER code', () => {
try {
validatePredicate({ all: [{ field: 'nope', op: 'eq', value: 1 }] }, COLS)
expect.unreachable()
} catch (e) {
expect(e).toBeInstanceOf(TableQueryValidationError)
expect((e as TableQueryValidationError).code).toBe('INVALID_FILTER')
}
})
})
describe('validateSortSpec', () => {
it('accepts real and system columns', () => {
expect(() =>
validateSortSpec(
[
{ field: 'wins', direction: 'desc' },
{ field: 'updatedAt', direction: 'asc' },
],
COLS
)
).not.toThrow()
})
it('rejects an unknown sort column with INVALID_ORDER', () => {
try {
validateSortSpec([{ field: 'nope', direction: 'asc' }], COLS)
expect.unreachable()
} catch (e) {
expect((e as TableQueryValidationError).code).toBe('INVALID_ORDER')
}
})
})
/**
* These shapes pass structural validation but compile to a clause the legacy
* `$`-grammar discards — which turns a bulk delete/update into "match everything".
*/
describe('validatePredicate — leaves that would silently widen a bulk write', () => {
it('rejects a scalar op given an array (the eq-instead-of-in mistake)', () => {
expect(() =>
validatePredicate({ all: [{ field: 'status', op: 'eq', value: ['a', 'b'] }] }, COLS)
).toThrow(/does not accept an array — use "in"/)
})
it('rejects a value-taking op with no value', () => {
expect(() => validatePredicate({ all: [{ field: 'status', op: 'eq' }] }, COLS)).toThrow(
/requires a value/
)
expect(() => validatePredicate({ all: [{ field: 'wins', op: 'gte' }] }, COLS)).toThrow(
/requires a value/
)
})
it('still allows the valueless ops', () => {
for (const op of ['isNull', 'isNotNull', 'isEmpty', 'isNotEmpty'] as const) {
expect(() => validatePredicate({ all: [{ field: 'status', op }] }, COLS)).not.toThrow()
}
})
it('rejects a hybrid group+leaf node instead of silently picking one', () => {
// Read group-first by the engine, leaf-first by predicateToFilter — so the gate
// would validate one predicate and the bulk-write path execute another.
expect(() =>
validatePredicate(
{
all: [{ field: 'status', op: 'eq', value: 'benign' }],
field: 'nope',
op: 'isNotNull',
} as never,
COLS
)
).toThrow(/not both/)
})
it('rejects a node carrying both group keys instead of dropping the "any" half', () => {
// Every group-first traversal reads `all` and drops `any` — half the
// caller's conditions would vanish, widening a bulk delete/update.
expect(() =>
validatePredicate(
{
all: [{ field: 'status', op: 'eq', value: 'archived' }],
any: [{ field: 'status', op: 'eq', value: 'stale' }],
} as never,
COLS
)
).toThrow(/either "all" or "any"/)
})
it('caps in/nin list length', () => {
const huge = Array.from({ length: 1001 }, (_, i) => `v${i}`)
expect(() =>
validatePredicate({ all: [{ field: 'status', op: 'in', value: huge }] }, COLS)
).toThrow(/at most 1000 values/)
expect(() =>
validatePredicate({ all: [{ field: 'status', op: 'in', value: huge.slice(0, 1000) }] }, COLS)
).not.toThrow()
})
})
/**
* The copilot's `query_user_table` catalog entry still advertises the legacy
* `$`-grammar, so the agent sends `{ status: { $eq: 'x' } }`. That shape hit
* `validateLeaf` with `field: undefined` and came back as
* `Unknown filter column "undefined"` — a message that sends an LLM retrying
* column names instead of switching grammars.
*/
describe('validatePredicate — legacy $-grammar diagnostics', () => {
it('names the grammar mistake instead of blaming a phantom column', () => {
for (const legacy of [
{ status: { $eq: 'active' } },
{ $or: [{ status: 'a' }, { status: 'b' }] },
{ wins: { $gte: 10 } },
]) {
expect(() => validatePredicate(legacy as never, COLS)).toThrow(/legacy operator-object/)
expect(() => validatePredicate(legacy as never, COLS)).not.toThrow(/undefined/)
}
})
it('still rejects a plain non-predicate object clearly', () => {
expect(() => validatePredicate({ nonsense: 'x' } as never, COLS)).toThrow(
/must be a group .* or a condition/
)
})
})
/**
* Bugbot round 2: `{all: []}` passes the dual union via the non-empty-OBJECT
* legacy branch, then compiled to no WHERE clause — silently widening a run /
* cancel / delete scope to every row. The shape validator now mirrors the Zod
* contract's .min(1).
*/
describe('empty groups are rejected at every layer', () => {
it('validatePredicateShape rejects an empty group', () => {
expect(() => validatePredicateShape({ all: [] })).toThrow(/at least one condition/)
expect(() => validatePredicateShape({ any: [] })).toThrow(/at least one condition/)
expect(() => validatePredicateShape({ all: [{ any: [] }] } as never)).toThrow(
/at least one condition/
)
})
it('validatePredicate rejects it too', () => {
expect(() => validatePredicate({ all: [] }, COLS)).toThrow(/at least one condition/)
})
})
describe('predicate complexity limits', () => {
it('rejects deeply nested untrusted input before recursive validation', () => {
let predicate: unknown = { field: 'status', op: 'eq', value: 'active' }
for (let depth = 0; depth < 20_000; depth++) predicate = { all: [predicate] }
expect(() => validatePredicateShape(predicate as never)).toThrow(/Filter nesting is too deep/)
})
it('rejects oversized groups at the shared runtime boundary', () => {
const conditions = Array.from({ length: 101 }, () => ({
field: 'status',
op: 'eq' as const,
value: 'active',
}))
expect(() => validatePredicateShape({ all: conditions })).toThrow(/at most 100 conditions/)
})
})