Files
base/apis/rest_http_test.go
zeekay 9ab6a9a0e5 base: the table wire is ours, named in our own words
The wire carried another vendor's namespace in its error codes (PGRST1xx) and
in the media type a caller sends to claim a single row. Both ends are ours, so
it said someone else's name for no reason a caller could act on.

Two vocabularies meet in a fault and the split is the point: a constraint the
STORE refused answers with the store's own SQLSTATE, because every SQL engine
already agrees on how to say that; a request the WIRE would not accept answers
with a word of ours — bad_query, bad_body, not_one_row, refused, not_found,
store_failed — because that is a fact about this API and nobody else gets to
name it. Cardinality is asked for with application/vnd.hanzo.object+json.

LLM.md described the product as a translation of somebody else's seven
services, mapping their names onto ours, which is both wrong (their auth is not
Hanzo IAM) and backwards. It says what Base is: one binary, one file, the
surfaces it serves, and rules enforced here in Go rather than pushed down.

plugins/zap/forward.go still called itself additive to four native message
types that plugin.go had already removed — one node, one way in, and the table
wire arrives over it like every other route.
2026-08-13 22:46:01 -07:00

547 lines
18 KiB
Go

package apis_test
import (
"net/http"
"strings"
"testing"
"github.com/hanzoai/base/tests"
)
// wantRange asserts the Content-Range a scenario should answer with. The count
// rides in the header on this wire, so a body assertion cannot see it.
func wantRange(want string) func(t testing.TB, app *tests.TestApp, res *http.Response) {
return func(t testing.TB, app *tests.TestApp, res *http.Response) {
if got := res.Header.Get("Content-Range"); got != want {
t.Errorf("Content-Range = %q, want %q", got, want)
}
}
}
// The this wire door over real HTTP, against the same fixtures the collections
// door is tested on. The translation tests prove the query maps correctly; these
// prove the wire — the shape of the body, the count in the header, and above all
// that the door decides who may read a row exactly as the other one does.
func TestRestApiList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "a missing table is a 404, as on the other door",
Method: http.MethodGet,
URL: "/v1/rest/missing",
ExpectedStatus: 404,
ExpectedContent: []string{`"code":"not_found"`, `"details"`, `"hint"`},
ExpectedEvents: map[string]int{"*": 0},
},
{
// THE property that makes this door safe to expose. It runs the same
// handler, so the list rule that refuses an unauthenticated caller on
// /v1/collections/demo1/records must refuse the same caller here. A
// second door that answered 200 would be a read bypass, and it would
// look like a feature.
Name: "a collection with no list rule still needs superuser auth",
Method: http.MethodGet,
URL: "/v1/rest/demo1",
ExpectedStatus: 403,
ExpectedContent: []string{`"code":"refused"`, `Only superusers`},
ExpectedEvents: map[string]int{"*": 0},
},
{
// The wire difference: a BARE ARRAY, not {items,page,perPage,...}.
// The leading `[{` and the absence of `"items"` are what a another store
// client depends on.
Name: "a public table answers a bare array",
Method: http.MethodGet,
URL: "/v1/rest/demo2",
ExpectedStatus: 200,
ExpectedContent: []string{
`[{`,
`"id":"0yxhwia2amd8gec"`,
`"id":"achvryl401bhse3"`,
`"id":"llvuca81nly1qls"`,
},
NotExpectedContent: []string{
`"items"`,
`"totalItems"`,
`"perPage"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 3,
},
},
{
Name: "limit narrows the window",
Method: http.MethodGet,
URL: "/v1/rest/demo2?limit=1",
ExpectedStatus: 200,
ExpectedContent: []string{
`[{`,
},
NotExpectedContent: []string{`"items"`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 1,
},
},
{
// An unsatisfiable window is refused rather than rounded, so a caller
// never receives real rows for a range it did not ask for.
Name: "an offset off a page boundary is refused",
Method: http.MethodGet,
URL: "/v1/rest/demo2?limit=25&offset=30",
ExpectedStatus: 400,
ExpectedContent: []string{`"code":"bad_query"`, `page boundary`},
NotExpectedContent: []string{`"data":{}`, `"status":400`},
ExpectedEvents: map[string]int{"*": 0},
},
{
// THE test whose absence hid a bug that made every filtered read a
// 400: a filter that is supposed to SUCCEED, executed end to end.
// demo2 holds test1/test2/test3, so eq on a title returns exactly one.
Name: "a filter that should match, matches",
Method: http.MethodGet,
URL: "/v1/rest/demo2?title=eq.test2",
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"achvryl401bhse3"`,
`"title":"test2"`,
},
NotExpectedContent: []string{`"id":"llvuca81nly1qls"`, `"items"`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 1,
},
},
{
Name: "like matches on a wildcard",
Method: http.MethodGet,
URL: "/v1/rest/demo2?title=like.*est3*",
ExpectedStatus: 200,
ExpectedContent: []string{`"id":"0yxhwia2amd8gec"`},
NotExpectedContent: []string{`"id":"llvuca81nly1qls"`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 1,
},
},
{
Name: "in matches several",
Method: http.MethodGet,
URL: "/v1/rest/demo2?title=in.(test1,test3)",
ExpectedStatus: 200,
ExpectedContent: []string{`"id":"llvuca81nly1qls"`, `"id":"0yxhwia2amd8gec"`},
NotExpectedContent: []string{`"id":"achvryl401bhse3"`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 2,
},
},
{
Name: "a boolean column filters on is",
Method: http.MethodGet,
URL: "/v1/rest/demo2?active=is.false",
ExpectedStatus: 200,
ExpectedContent: []string{`"id":"llvuca81nly1qls"`},
NotExpectedContent: []string{`"id":"achvryl401bhse3"`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 1,
},
},
{
// A column that is not a field on this collection is refused by the
// resolver, so a caller cannot name arbitrary SQL as a column.
Name: "an unknown column is refused",
Method: http.MethodGet,
URL: "/v1/rest/demo2?nosuchcolumn=eq.x",
ExpectedStatus: 400,
ExpectedContent: []string{`"code":"bad_query"`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "an unknown operator is refused",
Method: http.MethodGet,
URL: "/v1/rest/demo2?title=matches.x",
ExpectedStatus: 400,
ExpectedContent: []string{`"code":"bad_query"`, `unknown operator`},
NotExpectedContent: []string{`"data":{}`, `"status":400`},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
// The count rides in Content-Range, never in the body — and it is computed only
// when asked for, so an unasked count reports `*` rather than a number nobody
// paid for.
func TestRestApiCount(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "no Prefer means no count, and the header says so",
Method: http.MethodGet,
URL: "/v1/rest/demo2",
ExpectedStatus: 200,
AfterTestFunc: wantRange("0-2/*"),
ExpectedContent: []string{`[{`, `"id":"llvuca81nly1qls"`},
NotExpectedContent: []string{`"totalItems"`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 3,
},
},
{
Name: "Prefer: count=exact reports the total",
Method: http.MethodGet,
URL: "/v1/rest/demo2",
Headers: map[string]string{
"Prefer": "count=exact",
},
ExpectedStatus: 200,
AfterTestFunc: wantRange("0-2/3"),
ExpectedContent: []string{`[{`, `"id":"llvuca81nly1qls"`},
NotExpectedContent: []string{`"totalItems"`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 3,
},
},
{
// The range is derived from the window that actually ran, so the
// second page reports where it really starts rather than 0.
Name: "the second page reports its own offset",
Method: http.MethodGet,
URL: "/v1/rest/demo2?limit=2&offset=2",
Headers: map[string]string{
"Prefer": "count=exact",
},
ExpectedStatus: 200,
AfterTestFunc: wantRange("2-2/3"),
ExpectedContent: []string{`"id":"0yxhwia2amd8gec"`},
NotExpectedContent: []string{`"id":"llvuca81nly1qls"`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
// The write verbs. The wire's PATCH and DELETE are FILTERED where Base's are
// by-id, so these prove the door resolves a filter to rows and then lets Base's
// own rules decide, rather than reimplementing them.
func TestRestApiWrites(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
// The catastrophic shape, refused. this wire allows an unfiltered
// DELETE and a REST client sends it without comment, so the first time
// anyone learns what it means is after the table is empty.
Name: "an unfiltered delete is refused",
Method: http.MethodDelete,
URL: "/v1/rest/demo2",
ExpectedStatus: 400,
ExpectedContent: []string{`"code":"bad_query"`, `needs a filter`, `every row`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "an unfiltered update is refused the same way",
Method: http.MethodPatch,
URL: "/v1/rest/demo2",
Body: strings.NewReader(`{"title":"x"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"code":"bad_query"`, `needs a filter`},
ExpectedEvents: map[string]int{"*": 0},
},
{
// The error body is the shape clients branch on: message/details/
// hint/code, never Base's {data,message,status}.
Name: "an error carries the fields a client reads",
Method: http.MethodDelete,
URL: "/v1/rest/demo2",
ExpectedStatus: 400,
ExpectedContent: []string{`"message"`, `"details"`, `"hint"`, `"code"`},
NotExpectedContent: []string{`"data":{}`, `"status":400`},
ExpectedEvents: map[string]int{"*": 0},
},
{
// A filtered write still obeys the collection's own rules: demo1 has
// no list rule, so an unauthenticated caller cannot even resolve the
// rows, let alone change them.
Name: "a filtered delete on a guarded collection still refuses",
Method: http.MethodDelete,
URL: "/v1/rest/demo1?id=eq.imy661ixudk5izi",
ExpectedStatus: 403,
ExpectedContent: []string{`Only superusers`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "a bulk insert is refused rather than half-applied",
Method: http.MethodPost,
URL: "/v1/rest/demo2",
Body: strings.NewReader(`[{"title":"a"},{"title":"b"}]`),
ExpectedStatus: 501,
ExpectedContent: []string{`"code":"bad_query"`, `one object per request`},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
// The writes that are supposed to WORK, executed end to end. Their absence is
// exactly what let a broken filter ship green, so the success path is the part
// that has to be measured rather than reasoned about.
func TestRestApiWritesThatSucceed(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
// this wire returns nothing unless asked, and a REST client reads an
// empty body as exactly that. 201, no body.
Name: "insert returns 201 and no body by default",
Method: http.MethodPost,
URL: "/v1/rest/demo2",
Body: strings.NewReader(`{"title":"from-rest"}`),
ExpectedStatus: 201,
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
},
{
Name: "insert returns the row when asked",
Method: http.MethodPost,
URL: "/v1/rest/demo2",
Body: strings.NewReader(`{"title":"from-rest"}`),
Headers: map[string]string{
"Prefer": "return=representation",
},
ExpectedStatus: 201,
ExpectedContent: []string{`"title":"from-rest"`},
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
},
{
// .single() on a write: a bare object, not a one-element array. A
// server that answers `[{...}]` hands every .single() caller an array
// where they wrote data.id.
Name: "insert answers a bare object for .single()",
Method: http.MethodPost,
URL: "/v1/rest/demo2",
Body: strings.NewReader(`{"title":"single-rest"}`),
Headers: map[string]string{
"Prefer": "return=representation",
"Accept": "application/vnd.hanzo.object+json",
},
ExpectedStatus: 201,
ExpectedContent: []string{`"title":"single-rest"`},
NotExpectedContent: []string{`[{`},
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
},
{
Name: "a filtered update changes the rows the filter selects",
Method: http.MethodPatch,
URL: "/v1/rest/demo2?title=eq.test1",
Body: strings.NewReader(`{"title":"renamed"}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{"OnRecordUpdateRequest": 1},
},
{
Name: "a filtered update returns the changed rows when asked",
Method: http.MethodPatch,
URL: "/v1/rest/demo2?title=eq.test1",
Body: strings.NewReader(`{"title":"renamed"}`),
Headers: map[string]string{
"Prefer": "return=representation",
},
ExpectedStatus: 200,
ExpectedContent: []string{`"title":"renamed"`},
NotExpectedContent: []string{`"title":"test1"`},
ExpectedEvents: map[string]int{"OnRecordUpdateRequest": 1},
},
{
Name: "a filtered delete removes them",
Method: http.MethodDelete,
URL: "/v1/rest/demo2?title=eq.test1",
ExpectedStatus: 204,
ExpectedEvents: map[string]int{"OnRecordDeleteRequest": 1},
},
{
// A filter that selects nothing is a no-op, not an error — and the
// count says so.
Name: "a filter that matches nothing changes nothing",
Method: http.MethodDelete,
URL: "/v1/rest/demo2?title=eq.nosuchtitle",
ExpectedStatus: 204,
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
// A duplicate is ordinary application logic, not an exceptional case: clients
// branch on error.code === '23505' to show "that name is taken". demo2 carries
// idx_unique_demo2_title, so inserting a title it already holds is the real
// thing rather than a simulated one.
func TestRestApiDuplicateIsSQLState23505(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "a unique violation answers 409 with the SQLSTATE",
Method: http.MethodPost,
URL: "/v1/rest/demo2",
Body: strings.NewReader(`{"title":"test1"}`),
ExpectedStatus: 409,
ExpectedContent: []string{
`"code":"23505"`,
`duplicate key value violates unique constraint`,
`already exists`,
},
NotExpectedContent: []string{`"data":{`, `"status":409`},
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
},
{
// The same write on Base's own door keeps Base's shape — the
// this wire body belongs to the this wire wire, not to the store.
Name: "the collections door still answers its own shape",
Method: http.MethodPost,
URL: "/v1/collections/demo2/records",
Body: strings.NewReader(`{"title":"test1"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`validation_not_unique`},
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
// A count query is a HEAD. a REST client issues one for
// .select('*', {count: 'exact', head: true}) and reads the total out of
// Content-Range — the rows are exactly what it does not want.
func TestRestApiHeadIsTheCountQuery(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "HEAD answers the count in the header and no body",
Method: http.MethodHead,
URL: "/v1/rest/demo2",
Headers: map[string]string{
"Prefer": "count=exact",
},
ExpectedStatus: 200,
AfterTestFunc: wantRange("0-2/3"),
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 3,
},
},
{
// The filter still applies — a count is a read like any other, so it
// answers for the rows the filter selects rather than the table.
Name: "a filtered count counts only what matches",
Method: http.MethodHead,
URL: "/v1/rest/demo2?title=eq.test2",
Headers: map[string]string{
"Prefer": "count=exact",
},
ExpectedStatus: 200,
AfterTestFunc: wantRange("0-0/1"),
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
// `not.` is a PREFIX, not an operator — it wraps whatever follows. The Table
// Editor offers "is not null" as a filter, and this is how the wire spells it,
// so rejecting the prefix would leave a filter the grid can build and the server
// cannot answer. demo2 has three rows, one of them active=false.
func TestRestApiNotPrefix(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "not.eq excludes the matching row",
Method: http.MethodGet,
URL: "/v1/rest/demo2?title=not.eq.test1",
ExpectedStatus: 200,
ExpectedContent: []string{`"id":"achvryl401bhse3"`, `"id":"0yxhwia2amd8gec"`},
NotExpectedContent: []string{`"id":"llvuca81nly1qls"`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 2,
},
},
{
Name: "not.in excludes every named value",
Method: http.MethodGet,
URL: "/v1/rest/demo2?title=not.in.(test1,test2)",
ExpectedStatus: 200,
ExpectedContent: []string{`"id":"0yxhwia2amd8gec"`},
NotExpectedContent: []string{`"id":"llvuca81nly1qls"`, `"id":"achvryl401bhse3"`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 1,
},
},
{
// The one the grid actually offers.
Name: "not.is.null is how the wire spells IS NOT NULL",
Method: http.MethodGet,
URL: "/v1/rest/demo2?title=not.is.null",
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"llvuca81nly1qls"`,
`"id":"achvryl401bhse3"`,
`"id":"0yxhwia2amd8gec"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 3,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}