Files
base/apis/middlewares_test.go
zeekay ab9da76ea8 A Base's records are a typed GraphQL schema, answered through its own router
graph.New(app, router) renders the Base's collections as GraphQL and
answers every field with a request on the Base's own router, made with
the caller's headers, so the list and view rules, tenancy, rate limits
and expand's view checks are the REST path's and there is no second
authorization. zip's engine executes it: a host mounts g.Schema and
g.Resolve with zip.MountGraphFor, so zip is bumped to v1.37.43.

- A type per base and view collection, named from the collection
  (people: BasePerson, person, people, peopleGroupBy, createPerson,
  updatePerson, upsertPerson, deletePerson), with its fields, relations
  as objects, the records pointing at it as connections
  (opportunities_via_company) and its history.
- A filter input per collection whose operators per field type are the
  where clause's: each is one leaf of core.CompileWhere, so and, or,
  not, @me, relative dates, back-relations and inSegment mean what they
  mean there, every value bound.
- orderBy, cursor connections paged through Base's pages, a totalCount
  asked only when selected, and aggregate and GroupBy from the
  aggregate route.
- Bounds: 100 a page, four relations deep refused before any request,
  50 requests a root field, and zip's 10,000 resolutions.

graph_test drives it through zip against a CRM Base: every filter type,
paging both ways, the figures, the writes as REST reads them back, the
refusals in REST's own words (a rule, a superuser-only collection,
another org's token), the budget, and type names on every object.
tests.IAM signs IAM tokens for any test, and apis' helper uses it.
2026-10-19 09:31:00 -07:00

990 lines
36 KiB
Go

package apis_test
import (
"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"
)
func TestPanicRecover(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "panic from route",
Method: http.MethodGet,
URL: "/my/test",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
panic("123")
})
},
ExpectedStatus: 500,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "panic from middleware",
Method: http.MethodGet,
URL: "/my/test",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(http.StatusOK, "test")
}).BindFunc(func(e *core.RequestEvent) error {
panic(123)
})
},
ExpectedStatus: 500,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRequireGuestOnly(t *testing.T) {
t.Parallel()
beforeTestFunc := func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireGuestOnly())
}
scenarios := []tests.ApiScenario{
{
Name: "valid regular user token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid superuser auth token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio",
},
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "expired/invalid token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjE3Njg3MzA2NzIsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.9iYiBgJP1HRJzqY1WDZxtI9e2CUSkAKNlZmJGiPYQio",
},
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "guest",
Method: http.MethodGet,
URL: "/my/test",
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRequireAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodGet,
URL: "/my/test",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireAuth())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "expired token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjE3Njg3MzA2NzIsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.9iYiBgJP1HRJzqY1WDZxtI9e2CUSkAKNlZmJGiPYQio",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireAuth())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "invalid token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6NDkyMjQxNjYwNCwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJ0eXBlIjoiZmlsZSJ9.OuICrgrFGLFWo3zuC0-_Lm3VO3N8U03mOYoDZB7vQp8",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireAuth())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid record auth token with no collection restrictions",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
// regular user
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireAuth())
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid record static auth token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
// regular user
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOmZhbHNlLCJ0eXBlIjoiYXV0aCJ9.8LJt8Jz66YTdn5bm_PalFa66lhgdZINXn5EDiVZNV7M",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireAuth())
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid record auth token with collection not in the restricted list",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
// superuser
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireAuth("users", "demo1"))
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid record auth token with collection in the restricted list",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
// superuser
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireAuth("users", core.CollectionNameSuperusers))
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid record auth token with Bearer case-insensitive prefix",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
// regular user
"Authorization": "BeArEr eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireAuth())
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRequireSuperuserAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodGet,
URL: "/my/test",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuth())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "expired/invalid token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc2ODczMDM5MywiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.qP2DRKrbLsxafLNzO7ywT4GfykiwCLmDEf5_RmUF99o",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuth())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid regular user auth token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuth())
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid superuser auth token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuth())
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRequireSuperuserOrOwnerAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodGet,
URL: "/my/test/4q1xlclmfloku33",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserOrOwnerAuth(""))
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "expired/invalid token",
Method: http.MethodGet,
URL: "/my/test/4q1xlclmfloku33",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc2ODczMDM5MywiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.qP2DRKrbLsxafLNzO7ywT4GfykiwCLmDEf5_RmUF99o",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserOrOwnerAuth(""))
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid record auth token (different user)",
Method: http.MethodGet,
URL: "/my/test/oap640cot4yru2s",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserOrOwnerAuth(""))
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid record auth token (owner)",
Method: http.MethodGet,
URL: "/my/test/4q1xlclmfloku33",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserOrOwnerAuth(""))
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid record auth token (owner + non-matching custom owner param)",
Method: http.MethodGet,
URL: "/my/test/4q1xlclmfloku33",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserOrOwnerAuth("test"))
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid record auth token (owner + matching custom owner param)",
Method: http.MethodGet,
URL: "/my/test/4q1xlclmfloku33",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{test}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserOrOwnerAuth("test"))
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid superuser auth token",
Method: http.MethodGet,
URL: "/my/test/4q1xlclmfloku33",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserOrOwnerAuth(""))
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRequireSameCollectionContextAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodGet,
URL: "/my/test/_users_auth_",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{collection}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSameCollectionContextAuth(""))
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "expired/invalid token",
Method: http.MethodGet,
URL: "/my/test/_users_auth_",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjE3Njg3MzA2NzIsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.9iYiBgJP1HRJzqY1WDZxtI9e2CUSkAKNlZmJGiPYQio",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{collection}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSameCollectionContextAuth(""))
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid record auth token (different collection)",
Method: http.MethodGet,
URL: "/my/test/clients",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{collection}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSameCollectionContextAuth(""))
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid record auth token (same collection)",
Method: http.MethodGet,
URL: "/my/test/_users_auth_",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{collection}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSameCollectionContextAuth(""))
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid record auth token (non-matching/missing collection param)",
Method: http.MethodGet,
URL: "/my/test/_users_auth_",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserOrOwnerAuth(""))
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid record auth token (matching custom collection param)",
Method: http.MethodGet,
URL: "/my/test/_users_auth_",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{test}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserOrOwnerAuth("test"))
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superuser no exception check",
Method: http.MethodGet,
URL: "/my/test/_users_auth_",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{collection}", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSameCollectionContextAuth(""))
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
// TestExternalAuthGuard verifies that every retired Base auth surface is
// gone from the router (404) regardless of collection or external-only
// mode, and that auth-methods serves the generic IAM provider entry when
// external-only mode is active.
//
// The original handlers (auth-with-password, auth-with-oauth2,
// auth-with-otp, oauth2-redirect, all request/confirm flows, impersonate)
// were deleted in the IAM-native rip. The externalAuthGuard helper
// remains in apis for callers who want to attach the 410-Gone-with-
// Location behavior to their own routes, but nothing in core Base
// binds it any more.
func TestExternalAuthGuard(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "auth-with-password is gone (router 404) for users",
Method: http.MethodPost,
URL: "/v1/collections/users/auth-with-password",
Body: strings.NewReader(`{"identity":"[email protected]","password":"1234567890"}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
},
ExpectedStatus: 404,
NotExpectedContent: []string{`This endpoint is retired`},
ExpectedEvents: map[string]int{"*": 0},
},
{
// _superusers used to be the last collection with a local-
// auth carve-out. It is no longer: the admin panel logs in
// via the IAM PKCE flow proxied at /v1/iam/oauth/authorize.
Name: "auth-with-password is gone (router 404) for _superusers",
Method: http.MethodPost,
URL: "/v1/collections/_superusers/auth-with-password",
Body: strings.NewReader(`{"identity":"[email protected]","password":"1234567890"}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
},
ExpectedStatus: 404,
NotExpectedContent: []string{`This endpoint is retired`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth-with-oauth2 is gone (router 404)",
Method: http.MethodPost,
URL: "/v1/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{"provider":"google","code":"x","redirectURL":"http://localhost"}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
},
ExpectedStatus: 404,
NotExpectedContent: []string{`This endpoint is retired`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth-with-otp is gone (router 404)",
Method: http.MethodPost,
URL: "/v1/collections/users/auth-with-otp",
Body: strings.NewReader(`{"otpId":"x","password":"y"}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
},
ExpectedStatus: 404,
NotExpectedContent: []string{`This endpoint is retired`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "oauth2-redirect is gone (router 404)",
Method: http.MethodGet,
URL: "/v1/oauth2-redirect?state=x&code=y",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
},
ExpectedStatus: 404,
ExpectedContent: []string{`"status":404`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "request-otp is gone (router 404)",
Method: http.MethodPost,
URL: "/v1/collections/users/request-otp",
Body: strings.NewReader(`{"email":"[email protected]"}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
},
ExpectedStatus: 404,
NotExpectedContent: []string{`This endpoint is retired`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "request-password-reset is gone (router 404)",
Method: http.MethodPost,
URL: "/v1/collections/users/request-password-reset",
Body: strings.NewReader(`{"email":"[email protected]"}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
},
ExpectedStatus: 404,
NotExpectedContent: []string{`This endpoint is retired`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth-methods returns a single generic 'iam' oauth2 entry when external auth is on",
Method: http.MethodGet,
URL: "/v1/collections/users/auth-methods",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
app.Store().Set(apis.StoreKeyJWKSURL, "https://iam.example.com/v1/iam/.well-known/jwks")
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"iam"`,
`"oauth2":{"providers":[{"name":"iam"`,
`https://iam.example.com/v1/iam/oauth/authorize?response_type=code`,
},
// Base must not bake any specific brand name into the protocol layer.
NotExpectedContent: []string{`"hanzo"`, `"google"`, `"github"`},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
// TestExternalAuthOnlyHasNoFallback pins that IAM is the only auth when the
// platform plugin is registered — for _superusers as much as for anybody, and
// whether or not JWKS is configured or reachable. A local Base token is refused
// in every one of those cases.
func TestExternalAuthOnlyHasNoFallback(t *testing.T) {
t.Parallel()
// a local superuser token is refused like any other local token
t.Run("superuser token refused", func(t *testing.T) {
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
defer app.Cleanup()
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
token, err := tests.GetSuperuserAuthToken(app, tests.TestSuperuserID1)
if err != nil {
t.Fatalf("failed to get superuser token: %v", err)
}
scenario := tests.ApiScenario{
Name: "superuser token in external mode",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": token,
},
TestAppFactory: func(t testing.TB) *tests.TestApp {
return app
},
DisableTestAppCleanup: true,
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
if e.Auth == nil {
return e.String(401, "no auth")
}
return e.String(200, "auth:"+e.Auth.Collection().Name+":"+e.Auth.Id)
})
},
ExpectedStatus: 401,
ExpectedContent: []string{"no auth"},
}
scenario.Test(t)
})
// regular user token rejected in external-only mode
t.Run("regular user token rejected", func(t *testing.T) {
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
defer app.Cleanup()
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
token, err := tests.GetUserAuthToken(app, "users", tests.TestUserID1)
if err != nil {
t.Fatalf("failed to get user token: %v", err)
}
scenario := tests.ApiScenario{
Name: "regular user token in external mode",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": token,
},
TestAppFactory: func(t testing.TB) *tests.TestApp {
return app
},
DisableTestAppCleanup: true,
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
if e.Auth == nil {
return e.String(401, "no auth")
}
return e.String(200, "auth:"+e.Auth.Collection().Name+":"+e.Auth.Id)
})
},
ExpectedStatus: 401,
ExpectedContent: []string{"no auth"},
}
scenario.Test(t)
})
}
// mintIAMToken signs an RS256 JWT over claims and serves the matching JWKS,
// which is the shape Hanzo IAM puts on the wire.
func mintIAMToken(t testing.TB, claims jwt.MapClaims) (token, jwksURL string) {
t.Helper()
iam := tests.NewIAM(t)
return iam.Token(t, claims), iam.URL
}
// TestTheUsernameIsNotADisplayName pins which claim the account's USERNAME is
// read from.
//
// The username is the <name> half of the <owner>/<name> IAM files a person
// under, and a per-org API composes an address out of it. A display name is
// free-form text the account holder writes, so a username taken from one names
// whatever its holder typed — and base validates against whichever identity
// provider a deployment points at, so the claim it insists on has to be one an
// account is FILED under.
func TestTheUsernameIsNotADisplayName(t *testing.T) {
t.Parallel()
member := []any{map[string]any{"org": "acme", "role": "member"}}
scenarios := []struct {
name string
claims jwt.MapClaims
want string
}{
{
name: "the name claim, which is where Hanzo IAM puts the username",
claims: jwt.MapClaims{
"sub": "acme/ann", "owner": "acme", "name": "ann", "orgs": member,
},
want: `"name":"ann"`,
},
{
name: "preferred_username, where the name claim holds a display name",
claims: jwt.MapClaims{
"sub": "acme/ann", "owner": "acme",
"preferred_username": "ann", "name": "Ann Example", "orgs": member,
},
want: `"name":"ann"`,
},
{
name: "a display name and no username at all",
claims: jwt.MapClaims{
"sub": "acme/ann", "owner": "acme", "displayName": "ceo", "orgs": member,
},
want: `"name":""`,
},
}
for _, s := range scenarios {
token, jwksURL := mintIAMToken(t, s.claims)
scenario := tests.ApiScenario{
Name: s.name,
Method: http.MethodGet,
URL: "/username",
Headers: map[string]string{"Authorization": "Bearer " + token},
ExpectedStatus: 200,
ExpectedContent: []string{s.want},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
app.Store().Set(apis.StoreKeyJWKSURL, jwksURL)
e.Router.GET("/username", func(re *core.RequestEvent) error {
name, _ := re.Get(apis.RequestEventKeyName).(string)
return re.JSON(http.StatusOK, map[string]string{"name": name})
})
},
}
scenario.Test(t)
}
}
func TestIAMSuperuserMirror(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
claims jwt.MapClaims
status int
content []string
}{
{
name: "a person whose own org is the reserved admin org",
claims: jwt.MapClaims{
"sub": "admin/z", "owner": "hanzo", "email": "[email protected]",
"orgs": []any{
map[string]any{"org": "admin", "role": "owner"},
map[string]any{"org": "hanzo", "role": "admin"},
},
},
status: 200,
content: []string{`"meta"`, `"rateLimits"`},
},
{
// SuperAdmin is the person's own org. A brand org's admin added to the
// admin org is not one, wherever the membership sits in the set.
name: "a membership of the reserved admin org held from a brand org",
claims: jwt.MapClaims{
"sub": "hanzo/z", "owner": "hanzo", "email": "[email protected]",
"orgs": []any{
map[string]any{"org": "hanzo", "role": "admin"},
map[string]any{"org": "admin", "role": "admin"},
map[string]any{"org": "zoo", "role": "admin"},
},
},
status: 403,
content: []string{`"status":403`},
},
{
// `owner` is the org of the APPLICATION a token was minted through,
// so honoring it would hand platform authority to everyone who
// signed in through an admin-org app.
name: "an app owned by the admin org, signed in to by an ordinary user",
claims: jwt.MapClaims{
"sub": "acme/dev", "owner": "admin", "email": "[email protected]",
"orgs": []any{map[string]any{"org": "acme", "role": "admin"}},
},
status: 403,
content: []string{`"status":403`},
},
{
// A client_credentials token carries no membership set at all,
// which is how a machine is told apart from a person.
name: "a machine token minted by an admin-org client",
claims: jwt.MapClaims{
"sub": "admin/kms", "owner": "admin", "token_type": "access-token",
},
status: 403,
content: []string{`"status":403`},
},
{
name: "admin of an ordinary org, over every org it administers",
claims: jwt.MapClaims{
"sub": "hanzo/dev", "owner": "hanzo", "email": "[email protected]",
"orgs": []any{
map[string]any{"org": "hanzo", "role": "owner"},
map[string]any{"org": "zoo", "role": "admin"},
},
},
status: 403,
content: []string{`"status":403`},
},
{
name: "the org-scoped isAdmin flag",
claims: jwt.MapClaims{
"sub": "hanzo/dev", "owner": "hanzo", "email": "[email protected]",
"isAdmin": true, "isGlobalAdmin": true,
},
status: 403,
content: []string{`"status":403`},
},
{
name: "the reserved signing-cert owner, which is not the admin org",
claims: jwt.MapClaims{
"sub": "built-in/svc", "owner": "built-in", "email": "[email protected]",
},
status: 403,
content: []string{`"status":403`},
},
{
name: "an org name that merely contains admin",
claims: jwt.MapClaims{
"sub": "acme/dev", "owner": "acme", "email": "[email protected]",
"orgs": []any{map[string]any{"org": "administrators", "role": "admin"}},
},
status: 403,
content: []string{`"status":403`},
},
{
name: "no membership at all",
claims: jwt.MapClaims{
"sub": "hanzo/anon", "owner": "hanzo", "email": "[email protected]",
},
status: 403,
content: []string{`"status":403`},
},
}
// The probe is a route only a superuser reaches. /v1/collections was one,
// until an org admin could change its own Base's schema there.
for _, s := range scenarios {
token, jwksURL := mintIAMToken(t, s.claims)
scenario := tests.ApiScenario{
Name: s.name,
Method: http.MethodGet,
URL: "/v1/settings",
Headers: map[string]string{"Authorization": "Bearer " + token},
ExpectedStatus: s.status,
ExpectedContent: s.content,
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
app.Store().Set(apis.StoreKeyJWKSURL, jwksURL)
},
}
scenario.Test(t)
}
guest := tests.ApiScenario{
Name: "no token at all",
Method: http.MethodGet,
URL: "/v1/collections",
ExpectedStatus: 401,
ExpectedContent: []string{`"status":401`},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
app.Store().Set(apis.StoreKeyJWKSURL, "https://iam.example.test/v1/iam/.well-known/jwks")
},
}
guest.Test(t)
}