Files
base/apis/record_history_test.go
zeekay a9d2f1ff46 An aggregate reads no private email, and history no removed field
Red's review. The aggregate route reads columns directly, so it gated
only hidden fields: grouping an auth collection by email, or counting
its unique emails, answered addresses the records keep private, which
the list masks and the filter resolver will not compare. core.EmailGated
is now the one rule both ask: an auth collection's email, which a caller
who is not a superuser reads only where emailVisibility is set. The
aggregate route refuses it as it refuses a hidden field.
TestAPrivateEmailIsNotAFigure is red's proof inverted: the group and the
count are refused, and fail without the change.

A record's history showed the changes of a field the collection had
since removed. An org admin who removes a field means its values to be
gone from view, so a caller who is not a superuser no longer reads
them; _history keeps the row for a superuser.

The composite uniqueness check is a read before the write, which
PostgreSQL can race; that is now said where it is done and in LLM.md,
with the fix (a unique expression index from the dialect).
2026-10-19 09:24:43 -07:00

137 lines
4.1 KiB
Go

package apis_test
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tests"
"github.com/hanzoai/base/tools/types"
)
// iamCaller is a person of org with role there, as IAM signs one.
func iamCaller(t *testing.T, org, role string) (token, jwksURL string) {
t.Helper()
return mintIAMToken(t, jwt.MapClaims{
"sub": org + "/ann", "email": "ann@" + org + ".test", "name": "ann",
"orgs": []any{map[string]any{"org": org, "role": role}},
})
}
// A record's history is read by whoever may view the record, and nobody else.
func TestRecordHistoryRoute(t *testing.T) {
t.Parallel()
const id = "deal00000000001"
ready := func(t testing.TB, app *tests.TestApp, jwksURL string, viewRule *string, deleted bool) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
app.Store().Set(apis.StoreKeyJWKSURL, jwksURL)
c := core.NewBaseCollection("deals")
c.History = true
c.ViewRule = viewRule
c.Fields.Add(&core.TextField{Name: "title"}, &core.TextField{Name: "note"}, &core.TextField{Name: "stage"})
if err := app.Save(c); err != nil {
t.Fatal(err)
}
ann := core.WithActor(context.Background(), types.Actor{Source: core.ActorManual, Id: "ann", Name: "Ann"})
bob := core.WithActor(context.Background(), types.Actor{Source: core.ActorImport, Id: "bob", Name: "Bob"})
r := core.NewRecord(c)
r.Id = id
r.Set("title", "Acme")
r.Set("note", "call back")
if err := app.SaveWithContext(ann, r); err != nil {
t.Fatal(err)
}
r, _ = app.FindRecordById(c, id)
r.Set("title", "Acme Inc")
r.Set("note", "called")
r.Set("stage", "negotiating")
if err := app.SaveWithContext(bob, r); err != nil {
t.Fatal(err)
}
// the note is hidden after it changed, and the stage removed: neither
// change is any reader's now
c.Fields.GetByName("note").SetHidden(true)
c.Fields.RemoveByName("stage")
if err := app.Save(c); err != nil {
t.Fatal(err)
}
if deleted {
if err := app.DeleteWithContext(ann, r); err != nil {
t.Fatal(err)
}
}
}
adminOnly := types.Pointer(`@request.auth.role = "admin"`)
url := "/v1/collections/deals/records/" + id + "/history"
scenarios := []struct {
name string
role string
rule *string
deleted bool
status int
content []string
excluded []string
order []string
}{
{
name: "a viewer reads it, newest first", role: "admin", rule: adminOnly, status: 200,
content: []string{
`"totalItems":2`, `"action":"update"`, `"action":"create"`, `"name":"Bob"`, `"source":"import"`,
`"title":{"from":"Acme","to":"Acme Inc"}`, `"title":{"to":"Acme"}`,
},
excluded: []string{"call back", "called", "negotiating", `"stage"`},
order: []string{`"action":"update"`, `"action":"create"`},
},
{name: "a caller the view rule refuses", role: "member", rule: adminOnly, status: 404, content: []string{`"status":404`}},
{name: "a collection only superusers view", role: "admin", rule: nil, status: 403, content: []string{`"status":403`}},
{name: "a deleted record", role: "admin", rule: adminOnly, deleted: true, status: 404, content: []string{`"status":404`}},
}
for _, s := range scenarios {
token, jwksURL := iamCaller(t, "acme", s.role)
scenario := tests.ApiScenario{
Name: s.name,
Method: http.MethodGet,
URL: url,
Headers: map[string]string{"Authorization": "Bearer " + token},
ExpectedStatus: s.status,
ExpectedContent: s.content,
NotExpectedContent: s.excluded,
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
ready(t, app, jwksURL, s.rule, s.deleted)
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if len(s.order) == 0 {
return
}
raw, _ := io.ReadAll(res.Body)
body := string(raw)
last := -1
for _, want := range s.order {
at := strings.Index(body, want)
if at <= last {
t.Fatalf("expected %v in that order in %s", s.order, body)
}
last = at
}
},
}
scenario.Test(t)
}
}