@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
140 lines (124 loc) • 6.63 kB
text/typescript
import { describe, expect, it } from "bun:test"
import { addr, dec, i32, key, str, u256 } from "../schema"
import { InvalidAttributeNameError, InvalidValueError } from "../schema/errors"
import { and, eq, exists, gt, startsWith } from "./expression"
import { toRpcSelect } from "./selection"
const ACCOUNT = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
const ENTITY_KEY = `0x${"ab".repeat(32)}` as const
/**
* The query string is built by concatenation, so every value and name that reaches it is checked
* here against the shapes that would change what the node parses rather than what it matches.
*/
describe("string literals cannot break out of their quotes", () => {
it("doubles every quote, which is the language's only escape", () => {
expect(String(eq("x", str("a' OR '1'='1")))).toBe("x = str('a'' OR ''1''=''1')")
expect(String(eq("x", str("')")))).toBe("x = str(''')')")
expect(String(eq("x", str("'")))).toBe("x = str('''')")
expect(String(startsWith("x", "pre' OR '1"))).toBe("x STARTSWITH str('pre'' OR ''1')")
})
it("leaves a backslash literal — the language has no backslash escape to subvert", () => {
// If `\` escaped anything, `\''` would close the string. It does not: `\` is an ordinary char,
// and the quote after it is still doubled.
expect(String(eq("x", str("a\\")))).toBe("x = str('a\\')")
// Backslash, then a doubled quote, then the terminator — the backslash buys nothing.
expect(String(eq("x", str("a\\'")))).toBe("x = str('a\\''')")
})
it("keeps a comment token harmless inside the quotes", () => {
expect(String(eq("x", str("a -- comment")))).toBe("x = str('a -- comment')")
})
it("keeps control characters inside the quotes, where the node reads them verbatim", () => {
// The node's lexer takes every character up to the closing quote as part of the string, so a
// newline cannot end the literal and turn the rest of the value into query text.
for (const code of [0x00, 0x09, 0x0a, 0x0d, 0x1f, 0x7f]) {
const char = String.fromCharCode(code)
const hostile = `a${char}' AND flagged = true`
expect(String(eq("x", hostile))).toBe(`x = str('a${char}'' AND flagged = true')`)
expect(String(startsWith("x", hostile))).toBe(
`x STARTSWITH str('a${char}'' AND flagged = true')`,
)
}
})
it("still allows every printable character, including the awkward ones", () => {
for (const value of ["a b", "a\\b", "a--b", "a)b(", "a=b", "élan", "日本語", 'a"b']) {
expect(() => str(value)).not.toThrow()
}
})
})
describe("attribute names cannot smuggle a comment", () => {
it("rejects a name containing the comment token", () => {
// `-` is a legal name character, so `a--b` passes the grammar — and renders a query whose
// entire remainder is commented out. Verified before the fix: `and(eq("approved", true),
// eq("a--b", 1), eq("level", 5))` rendered `approved = true AND a--b = i32(1) AND level =
// i32(5)`, in which the node sees only `approved = true AND a`.
expect(() => eq("a--b", 1)).toThrow(InvalidAttributeNameError)
expect(() => eq("a--b", 1)).toThrow(/opens a comment in the query language/)
expect(() => exists("owner-name--x")).toThrow(InvalidAttributeNameError)
// Rejected on the write path too, so nothing unqueryable can be stored in the first place.
expect(() => str("fine")).not.toThrow()
})
it("keeps a single dash, which is unambiguous", () => {
expect(String(eq("a-b", 1))).toBe("a-b = i32(1)")
expect(String(eq("a-", 1))).toBe("a- = i32(1)")
})
it("rejects everything else outside the grammar", () => {
for (const name of ["x' OR '1", "x)", "a b", "x'", "x(", "1x", "x;", "a".repeat(33)]) {
expect(() => eq(name, 1)).toThrow(InvalidAttributeNameError)
}
})
})
describe("typed literals are closed by construction", () => {
it("renders numerics as digits and nothing else", () => {
expect(String(eq("x", i32(-2147483648)))).toBe("x = i32(-2147483648)")
expect(String(eq("x", u256(2n ** 256n - 1n)))).toBe(
"x = u256(115792089237316195423570985008687907853269984665640564039457584007913129639935)",
)
// Large and small decimals stay in positional notation — no exponent for a parser to choke on.
expect(String(eq("x", dec("1000000000000000000000")))).toBe("x = dec(1000000000000000000000)")
expect(String(eq("x", dec("-0.000000000000000001")))).toBe("x = dec(-0.000000000000000001)")
expect(String(eq("x", u256(10n ** 30n)))).not.toMatch(/[eE+]/)
})
it("renders the byte-shaped types as fixed-width hex", () => {
expect(String(eq("x", key(ENTITY_KEY)))).toMatch(/^x = key\(0x[0-9a-f]{64}\)$/)
expect(String(eq("x", addr(ACCOUNT)))).toMatch(/^x = addr\(0x[0-9a-fA-F]{40}\)$/)
})
it("refuses hostile input at the constructor, before it can be rendered", () => {
expect(() => u256("1) OR true --" as string)).toThrow(InvalidValueError)
expect(() => addr("0x0') OR true --")).toThrow(InvalidValueError)
expect(() => key("0xab') OR true --" as `0x${string}`)).toThrow(InvalidValueError)
expect(() => dec("1) OR true")).toThrow(InvalidValueError)
expect(() => i32("1) OR true" as unknown as number)).toThrow(InvalidValueError)
})
it("cannot be handed a hostile value through an untyped object either", () => {
// toValue re-runs the constructor, so a look-alike carrying a string gets validated, not
// interpolated.
expect(() => eq("x", { type: "u256", value: "1) OR true --" } as unknown as string)).toThrow(
InvalidValueError,
)
})
})
describe("the assembled query", () => {
it("keeps every predicate intact through a hostile filter set", () => {
const query = String(
and(
eq("approved", true),
eq("note", str("a') OR true --")),
gt("level", i32(5)),
eq("$owner", addr(ACCOUNT)),
),
)
expect(query).toBe(
"approved = true AND note = str('a'') OR true --') AND level > i32(5) AND " +
`$owner = addr(${ACCOUNT})`,
)
// The guard predicates survive the hostile one, in both directions.
expect(query).toContain("approved = true")
expect(query).toContain("level > i32(5)")
})
})
describe("the selection", () => {
it("checks named attributes rather than forwarding them", () => {
expect(() => toRpcSelect({ $key: true, "a' OR '1": true })).toThrow(InvalidAttributeNameError)
expect(() => toRpcSelect({ $key: true, "a--b": true })).toThrow(InvalidAttributeNameError)
expect(toRpcSelect({ $key: true, ok: true })).toMatchObject({ attributes: { ok: true } })
})
})