mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
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).
137 lines
4.1 KiB
Go
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)
|
|
}
|
|
}
|