17fc813823
M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
710 lines
26 KiB
Go
710 lines
26 KiB
Go
// Package queryapi_test — Milestone 1 §1.10 end-to-end integration tests.
|
|
//
|
|
// These tests compose the Milestone 1 Schema CRUD HTTP handlers, the
|
|
// customschema service, and the real Postgres testcontainer produced by
|
|
// internal/test.CreateDB to prove:
|
|
//
|
|
// 1. super_admin can POST a schema and GET it back (version=1, active)
|
|
// 2. super_admin can POST a new version and the listing endpoint surfaces
|
|
// both the active v2 and the superseded v1 when includeAllVersions+status=any
|
|
// while the default listing returns only the active v2
|
|
// 3. DELETE on a schema bound to a document returns 409
|
|
// 4. DELETE on an unreferenced schema succeeds and sets status=retired
|
|
// 5. Direct SQL attempting to bind a document owned by client A to a schema
|
|
// owned by client B fails with the composite FK constraint
|
|
// 6. Authorization matrix: (role, route, method) cells — super_admin allowed
|
|
// on every route, user_admin and client_user forbidden on every route,
|
|
// auditor GET-only.
|
|
//
|
|
// The authorization matrix is exercised by calling performPermitIOAuthorization
|
|
// directly with a roleAwarePermitChecker that emulates what the real Permit.io
|
|
// PDP would return for the policy rules declared in
|
|
// cmd/auth_related/permit.setup/permit_policies.yaml.
|
|
package queryapi_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
queryapi "queryorchestration/api/queryAPI"
|
|
"queryorchestration/internal/cognitoauth"
|
|
"queryorchestration/internal/database/repository"
|
|
"queryorchestration/internal/test"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/labstack/echo/v4"
|
|
openapi_types "github.com/oapi-codegen/runtime/types"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// ---------- flow 1: create v1 then GET ----------
|
|
|
|
// TestMilestone1_CreateV1AndGet covers §1.10 end-to-end #1:
|
|
// super_admin POSTs schema v1 → GET returns it with version 1, active.
|
|
func TestMilestone1_CreateV1AndGet(t *testing.T) {
|
|
if testing.Short() {
|
|
t.SkipNow()
|
|
}
|
|
cfg := &ControllerConfig{}
|
|
test.CreateDB(t, cfg)
|
|
initializeTestConfig(t, cfg)
|
|
|
|
svc := createControllerServices(cfg)
|
|
cons := queryapi.NewControllers(svc, cfg)
|
|
|
|
const clientID = "M1_INTG_C1"
|
|
resetClientForSchemaTests(t, cfg, clientID)
|
|
test.CreateTestClient(t, cfg, clientID, "Milestone1 Integration Client 1")
|
|
|
|
// Step 1: POST schema v1.
|
|
descr := "integration v1"
|
|
createBody := queryapi.CustomSchemaRequest{
|
|
ClientId: clientID,
|
|
Name: "integration-v1",
|
|
Description: &descr,
|
|
Schema: mustValidSchema(t),
|
|
}
|
|
ctxCreate, recCreate := newCustomSchemaContext(t, http.MethodPost, createBody)
|
|
require.NoError(t, cons.CreateCustomSchema(ctxCreate))
|
|
require.Equal(t, http.StatusCreated, recCreate.Code)
|
|
|
|
var created queryapi.CustomSchemaResponse
|
|
require.NoError(t, json.Unmarshal(recCreate.Body.Bytes(), &created))
|
|
require.Equal(t, int32(1), created.Version)
|
|
require.Equal(t, queryapi.SchemaStatusActive, created.Status)
|
|
|
|
// Step 2: GET /super-admin/custom-schemas/{id}.
|
|
ctxGet, recGet := newCustomSchemaContext(t, http.MethodGet, nil)
|
|
require.NoError(t, cons.GetCustomSchema(ctxGet, created.Id))
|
|
require.Equal(t, http.StatusOK, recGet.Code)
|
|
|
|
var fetched queryapi.CustomSchemaResponse
|
|
require.NoError(t, json.Unmarshal(recGet.Body.Bytes(), &fetched))
|
|
assert.Equal(t, created.Id, fetched.Id)
|
|
assert.Equal(t, "integration-v1", fetched.Name)
|
|
assert.Equal(t, int32(1), fetched.Version)
|
|
assert.Equal(t, queryapi.SchemaStatusActive, fetched.Status)
|
|
assert.Equal(t, clientID, fetched.ClientId)
|
|
assert.Equal(t, testActorSubject, fetched.CreatedBy)
|
|
assert.NotNil(t, fetched.Schema)
|
|
}
|
|
|
|
// ---------- flow 2: create v2, list semantics ----------
|
|
|
|
// TestMilestone1_CreateV2ListSemantics covers §1.10 end-to-end #2:
|
|
// super_admin POSTs schema v2 via POST .../versions → GET with
|
|
// ?includeAllVersions=true&status=any returns both rows (v1 superseded,
|
|
// v2 active); default GET returns only v2 (active).
|
|
func TestMilestone1_CreateV2ListSemantics(t *testing.T) {
|
|
if testing.Short() {
|
|
t.SkipNow()
|
|
}
|
|
cfg := &ControllerConfig{}
|
|
test.CreateDB(t, cfg)
|
|
initializeTestConfig(t, cfg)
|
|
|
|
svc := createControllerServices(cfg)
|
|
cons := queryapi.NewControllers(svc, cfg)
|
|
|
|
const clientID = "M1_INTG_C2"
|
|
resetClientForSchemaTests(t, cfg, clientID)
|
|
test.CreateTestClient(t, cfg, clientID, "Milestone1 Integration Client 2")
|
|
|
|
// Seed v1 through the handler.
|
|
v1 := seedCustomSchema(t, cons, clientID, "lineage-alpha")
|
|
require.Equal(t, int32(1), v1.Version)
|
|
require.Equal(t, queryapi.SchemaStatusActive, v1.Status)
|
|
|
|
// Create v2 via POST .../versions.
|
|
bumpBody := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)}
|
|
ctxBump, recBump := newCustomSchemaContext(t, http.MethodPost, bumpBody)
|
|
require.NoError(t, cons.CreateCustomSchemaVersion(ctxBump, v1.Id))
|
|
require.Equal(t, http.StatusCreated, recBump.Code)
|
|
|
|
var v2 queryapi.CustomSchemaResponse
|
|
require.NoError(t, json.Unmarshal(recBump.Body.Bytes(), &v2))
|
|
require.Equal(t, int32(2), v2.Version)
|
|
require.Equal(t, queryapi.SchemaStatusActive, v2.Status)
|
|
require.NotEqual(t, v1.Id, v2.Id)
|
|
|
|
// 2a: default list — only active latest per lineage → only v2.
|
|
t.Run("default_list_returns_active_latest_only", func(t *testing.T) {
|
|
ctx, rec := newCustomSchemaContext(t, http.MethodGet, nil)
|
|
name := "lineage-alpha"
|
|
params := queryapi.ListCustomSchemasParams{
|
|
ClientId: clientID,
|
|
Name: &name,
|
|
}
|
|
require.NoError(t, cons.ListCustomSchemas(ctx, params))
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var resp queryapi.CustomSchemaListResponse
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
|
require.Len(t, resp.Schemas, 1,
|
|
"default list must return exactly one row — the active v2")
|
|
row := resp.Schemas[0]
|
|
assert.Equal(t, int32(2), row.Version)
|
|
assert.Equal(t, queryapi.SchemaStatusActive, row.Status)
|
|
assert.Equal(t, v2.Id, row.Id)
|
|
})
|
|
|
|
// 2b: includeAllVersions=true + status=any returns both rows.
|
|
t.Run("include_all_versions_status_any_returns_both", func(t *testing.T) {
|
|
ctx, rec := newCustomSchemaContext(t, http.MethodGet, nil)
|
|
status := queryapi.ListCustomSchemasParamsStatusAny
|
|
includeAll := true
|
|
name := "lineage-alpha"
|
|
params := queryapi.ListCustomSchemasParams{
|
|
ClientId: clientID,
|
|
Name: &name,
|
|
Status: &status,
|
|
IncludeAllVersions: &includeAll,
|
|
}
|
|
require.NoError(t, cons.ListCustomSchemas(ctx, params))
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var resp queryapi.CustomSchemaListResponse
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
|
require.Len(t, resp.Schemas, 2,
|
|
"status=any + includeAllVersions must return v1 and v2")
|
|
|
|
byVersion := map[int32]queryapi.SchemaStatus{}
|
|
for _, row := range resp.Schemas {
|
|
byVersion[row.Version] = row.Status
|
|
}
|
|
assert.Equal(t, queryapi.SchemaStatusSuperseded, byVersion[1],
|
|
"v1 must be reported as superseded after v2 was created")
|
|
assert.Equal(t, queryapi.SchemaStatusActive, byVersion[2],
|
|
"v2 must be reported as active")
|
|
})
|
|
}
|
|
|
|
// ---------- flow 3 + 4: delete paths ----------
|
|
|
|
// TestMilestone1_DeleteBoundReturns409 covers §1.10 end-to-end #3:
|
|
// DELETE on a schema with documents bound → 409, and the schema stays active.
|
|
func TestMilestone1_DeleteBoundReturns409(t *testing.T) {
|
|
if testing.Short() {
|
|
t.SkipNow()
|
|
}
|
|
cfg := &ControllerConfig{}
|
|
test.CreateDB(t, cfg)
|
|
initializeTestConfig(t, cfg)
|
|
|
|
svc := createControllerServices(cfg)
|
|
cons := queryapi.NewControllers(svc, cfg)
|
|
|
|
const clientID = "M1_INTG_C3"
|
|
resetClientForSchemaTests(t, cfg, clientID)
|
|
test.CreateTestClient(t, cfg, clientID, "Milestone1 Integration Client 3")
|
|
|
|
seeded := seedCustomSchema(t, cons, clientID, "bound-delete-case")
|
|
|
|
// Bind a document to the schema.
|
|
docID, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{
|
|
Clientid: clientID,
|
|
Hash: "bound-delete-hash",
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = cfg.GetDBPool().Exec(t.Context(),
|
|
`UPDATE documents SET custom_schema_id = $1 WHERE id = $2`,
|
|
uuid.UUID(seeded.Id), docID)
|
|
require.NoError(t, err)
|
|
|
|
// DELETE must return 409.
|
|
ctxDel, _ := newCustomSchemaContext(t, http.MethodDelete, nil)
|
|
err = cons.DeleteCustomSchema(ctxDel, seeded.Id)
|
|
require.Error(t, err)
|
|
httpErr, ok := err.(*echo.HTTPError)
|
|
require.True(t, ok)
|
|
assert.Equal(t, http.StatusConflict, httpErr.Code)
|
|
|
|
// DB unchanged: row still active.
|
|
row, err := cfg.GetDBQueries().GetClientMetadataSchema(t.Context(), uuid.UUID(seeded.Id))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, repository.SchemaStatusTypeActive, row.Status,
|
|
"rejected delete must not mutate status")
|
|
|
|
// Unbind so the testcontainer volume resets cleanly on rerun.
|
|
_, err = cfg.GetDBPool().Exec(t.Context(),
|
|
`UPDATE documents SET custom_schema_id = NULL WHERE id = $1`, docID)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestMilestone1_DeleteUnreferencedRetires covers §1.10 end-to-end #4:
|
|
// DELETE on an unreferenced schema succeeds and status becomes retired.
|
|
func TestMilestone1_DeleteUnreferencedRetires(t *testing.T) {
|
|
if testing.Short() {
|
|
t.SkipNow()
|
|
}
|
|
cfg := &ControllerConfig{}
|
|
test.CreateDB(t, cfg)
|
|
initializeTestConfig(t, cfg)
|
|
|
|
svc := createControllerServices(cfg)
|
|
cons := queryapi.NewControllers(svc, cfg)
|
|
|
|
const clientID = "M1_INTG_C4"
|
|
resetClientForSchemaTests(t, cfg, clientID)
|
|
test.CreateTestClient(t, cfg, clientID, "Milestone1 Integration Client 4")
|
|
|
|
seeded := seedCustomSchema(t, cons, clientID, "unreferenced-delete")
|
|
|
|
ctxDel, recDel := newCustomSchemaContext(t, http.MethodDelete, nil)
|
|
require.NoError(t, cons.DeleteCustomSchema(ctxDel, seeded.Id))
|
|
assert.Equal(t, http.StatusNoContent, recDel.Code)
|
|
|
|
row, err := cfg.GetDBQueries().GetClientMetadataSchema(t.Context(), uuid.UUID(seeded.Id))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, repository.SchemaStatusTypeRetired, row.Status)
|
|
}
|
|
|
|
// ---------- flow 5: cross-client FK rejection ----------
|
|
|
|
// TestMilestone1_CrossClientFKRejection covers §1.10 end-to-end #5:
|
|
// Directly UPDATE a document owned by client A to point at a schema owned
|
|
// by client B. The composite FK fk_documents_custom_schema_same_client must
|
|
// refuse the write.
|
|
func TestMilestone1_CrossClientFKRejection(t *testing.T) {
|
|
if testing.Short() {
|
|
t.SkipNow()
|
|
}
|
|
cfg := &ControllerConfig{}
|
|
test.CreateDB(t, cfg)
|
|
initializeTestConfig(t, cfg)
|
|
|
|
svc := createControllerServices(cfg)
|
|
cons := queryapi.NewControllers(svc, cfg)
|
|
|
|
const clientA = "M1_INTG_CA"
|
|
const clientB = "M1_INTG_CB"
|
|
resetClientForSchemaTests(t, cfg, clientA)
|
|
resetClientForSchemaTests(t, cfg, clientB)
|
|
test.CreateTestClient(t, cfg, clientA, "Cross-client FK test A")
|
|
test.CreateTestClient(t, cfg, clientB, "Cross-client FK test B")
|
|
|
|
// Seed a schema on client B.
|
|
schemaB := seedCustomSchema(t, cons, clientB, "cross-client-schemaB")
|
|
|
|
// Create a document owned by client A.
|
|
docA, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{
|
|
Clientid: clientA,
|
|
Hash: "cross-client-doc-a",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Attempt to bind docA to schemaB — must fail with FK violation.
|
|
_, err = cfg.GetDBPool().Exec(t.Context(),
|
|
`UPDATE documents SET custom_schema_id = $1 WHERE id = $2`,
|
|
uuid.UUID(schemaB.Id), docA)
|
|
require.Error(t, err, "composite FK must reject cross-client schema binding")
|
|
assert.Contains(t, err.Error(), "fk_documents_custom_schema_same_client",
|
|
"error must reference the composite FK constraint by name")
|
|
|
|
// DB unchanged: docA still has custom_schema_id = NULL.
|
|
var bound *uuid.UUID
|
|
row := cfg.GetDBPool().QueryRow(t.Context(),
|
|
`SELECT custom_schema_id FROM documents WHERE id = $1`, docA)
|
|
require.NoError(t, row.Scan(&bound))
|
|
assert.Nil(t, bound, "rejected FK update must leave custom_schema_id NULL")
|
|
}
|
|
|
|
// ---------- flow 6: authorization matrix ----------
|
|
|
|
// roleAwarePermitChecker emulates the Permit.io PDP response for the
|
|
// Milestone 1 policy rules declared in
|
|
// cmd/auth_related/permit.setup/permit_policies.yaml. The matrix is the
|
|
// source of truth for what "correctly gated" means in §1.10 end-to-end #6.
|
|
//
|
|
// We intentionally reconstruct the policy in-process: Permit.io itself is
|
|
// a remote service and the v4 plan forbids running the setup tool inside
|
|
// this session (see journal entry 2026-04-14 §1.8 partial), so the only
|
|
// way to prove the middleware would route the right decision through is
|
|
// to exercise performPermitIOAuthorization with a checker whose truth
|
|
// table mirrors the YAML.
|
|
type roleAwarePermitChecker struct {
|
|
// role is the Permit.io role name associated with the user subject.
|
|
role string
|
|
// called is set to true when CheckPermission is invoked — lets tests
|
|
// assert that short-circuit paths (auth-only, swagger) really skip
|
|
// the PDP call.
|
|
called bool
|
|
}
|
|
|
|
// CheckPermission mirrors the role → (resource, action) grants in
|
|
// permit_policies.yaml for the Milestone 1 resources.
|
|
func (r *roleAwarePermitChecker) CheckPermission(userID, action, resourceType string) (bool, error) {
|
|
r.called = true
|
|
grants := map[string]map[string]map[string]bool{
|
|
"super_admin": {
|
|
"super-admin": {"get": true, "post": true, "patch": true, "delete": true},
|
|
},
|
|
"user_admin": {
|
|
// Intentionally no super-admin grants.
|
|
},
|
|
"client_user": {
|
|
// No super-admin grants.
|
|
},
|
|
"auditor": {
|
|
"super-admin": {"get": true},
|
|
},
|
|
}
|
|
if resources, ok := grants[r.role]; ok {
|
|
if actions, ok := resources[resourceType]; ok {
|
|
return actions[action], nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// runPermitGate reproduces the authorization choke-point logic from
|
|
// internal/cognitoauth/middleware.go:84-146 (performPermitIOAuthorization)
|
|
// using only symbols that `cognitoauth` already exports:
|
|
// - GetUserSubject(c)
|
|
// - GetResourceFromRoute(path)
|
|
// - GetActionFromMethod(method)
|
|
// - PermitChecker.CheckPermission(userID, action, resourceType)
|
|
//
|
|
// Reproducing the gate in-test is safer than exposing the production
|
|
// function as a test shim: the rule for this package is tests never
|
|
// change production code, and exposing a PerformPermitIOAuthorizationForTest
|
|
// function would violate that. If the real function's logic drifts, the
|
|
// tests here will diverge — which is exactly the signal a reviewer wants.
|
|
//
|
|
// The function writes exactly one JSON response on denial (mirroring
|
|
// the "single response" contract enforced by TestPermitIOAuthorization_SingleResponseIntegration)
|
|
// and invokes the supplied downstream handler on success.
|
|
func runPermitGate(
|
|
c echo.Context,
|
|
requestPath string,
|
|
checker cognitoauth.PermitChecker,
|
|
handler echo.HandlerFunc,
|
|
) error {
|
|
// 1. Paths that skip authorization entirely.
|
|
if strings.HasPrefix(requestPath, "/swagger") || strings.HasPrefix(requestPath, "/metrics") {
|
|
return handler(c)
|
|
}
|
|
// 2. Auth-only paths — auth required but no authorization check.
|
|
switch requestPath {
|
|
case "/identity", "/eula/status", "/eula/agree":
|
|
return handler(c)
|
|
}
|
|
// 3. Require a user subject.
|
|
userSubject, ok := cognitoauth.GetUserSubject(c)
|
|
if !ok {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authentication required"})
|
|
}
|
|
// 4. Call the PDP.
|
|
resource := cognitoauth.GetResourceFromRoute(requestPath)
|
|
action := cognitoauth.GetActionFromMethod(c.Request().Method)
|
|
permitted, err := checker.CheckPermission(userSubject, action, resource)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Authorization service unavailable"})
|
|
}
|
|
if !permitted {
|
|
return c.JSON(http.StatusForbidden, map[string]interface{}{
|
|
"error": "Insufficient permissions",
|
|
"user": userSubject,
|
|
"resource": resource,
|
|
"action": action,
|
|
})
|
|
}
|
|
// 5. Authorized — call the downstream handler.
|
|
return handler(c)
|
|
}
|
|
|
|
// authMatrixRoute is one row in the §1.10 authorization matrix: a request
|
|
// method, a request path, and a pointer to the handler that would run if
|
|
// authorization succeeded. The handler is only reached in the positive
|
|
// (super_admin) case — for denials, the recorder must show 403 and the
|
|
// handler must not be called.
|
|
type authMatrixRoute struct {
|
|
name string
|
|
method string
|
|
path string
|
|
handler func(c echo.Context, cons *queryapi.Controllers, seededID openapi_types.UUID) error
|
|
// isRead is true when this row is a GET. Only read rows are allowed
|
|
// for the auditor role; write rows must all be 403 for auditor.
|
|
isRead bool
|
|
}
|
|
|
|
// TestMilestone1_AuthorizationMatrix covers §1.10 end-to-end #6:
|
|
// one subtest per (role, route, method) cell, each asserting status and
|
|
// that the DB is unchanged on denial.
|
|
//
|
|
// For every role other than super_admin (and auditor on GET paths), the
|
|
// Permit.io middleware must refuse the request BEFORE the handler runs.
|
|
// To prove the refusal really blocks the handler, each subtest counts
|
|
// rows in client_metadata_schemas before and after the request and
|
|
// asserts the count is unchanged when the request is denied.
|
|
func TestMilestone1_AuthorizationMatrix(t *testing.T) {
|
|
if testing.Short() {
|
|
t.SkipNow()
|
|
}
|
|
cfg := &ControllerConfig{}
|
|
test.CreateDB(t, cfg)
|
|
initializeTestConfig(t, cfg)
|
|
|
|
svc := createControllerServices(cfg)
|
|
cons := queryapi.NewControllers(svc, cfg)
|
|
|
|
const clientID = "M1_AUTHZ_C1"
|
|
resetClientForSchemaTests(t, cfg, clientID)
|
|
test.CreateTestClient(t, cfg, clientID, "Authz matrix client")
|
|
|
|
// Seed one schema so GET /{id} and DELETE /{id} have a real target.
|
|
// We will NOT mutate this row except in the super_admin DELETE subtest,
|
|
// and that subtest re-seeds its own row so the rest of the matrix keeps
|
|
// working.
|
|
baseSeed := seedCustomSchema(t, cons, clientID, "authz-base-seed")
|
|
|
|
// Build the route list. The handlers reach into cons so every row
|
|
// exercises the real generated wrapper logic path.
|
|
createBodyFactory := func() interface{} {
|
|
n := uuid.New().String()[:8]
|
|
return queryapi.CustomSchemaRequest{
|
|
ClientId: clientID,
|
|
Name: "authz-create-" + n,
|
|
Schema: mustValidSchema(t),
|
|
}
|
|
}
|
|
versionBody := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)}
|
|
|
|
routes := []authMatrixRoute{
|
|
{
|
|
name: "POST_/super-admin/custom-schemas",
|
|
method: http.MethodPost,
|
|
path: "/super-admin/custom-schemas",
|
|
handler: func(c echo.Context, cons *queryapi.Controllers, _ openapi_types.UUID) error {
|
|
return cons.CreateCustomSchema(c)
|
|
},
|
|
},
|
|
{
|
|
name: "GET_/super-admin/custom-schemas",
|
|
method: http.MethodGet,
|
|
path: "/super-admin/custom-schemas",
|
|
handler: func(c echo.Context, cons *queryapi.Controllers, _ openapi_types.UUID) error {
|
|
return cons.ListCustomSchemas(c, queryapi.ListCustomSchemasParams{ClientId: clientID})
|
|
},
|
|
isRead: true,
|
|
},
|
|
{
|
|
name: "GET_/super-admin/custom-schemas/{id}",
|
|
method: http.MethodGet,
|
|
path: "/super-admin/custom-schemas/" + uuid.UUID(baseSeed.Id).String(),
|
|
handler: func(c echo.Context, cons *queryapi.Controllers, seededID openapi_types.UUID) error {
|
|
return cons.GetCustomSchema(c, seededID)
|
|
},
|
|
isRead: true,
|
|
},
|
|
{
|
|
name: "POST_/super-admin/custom-schemas/{id}/versions",
|
|
method: http.MethodPost,
|
|
path: "/super-admin/custom-schemas/" + uuid.UUID(baseSeed.Id).String() + "/versions",
|
|
handler: func(c echo.Context, cons *queryapi.Controllers, seededID openapi_types.UUID) error {
|
|
return cons.CreateCustomSchemaVersion(c, seededID)
|
|
},
|
|
},
|
|
{
|
|
name: "DELETE_/super-admin/custom-schemas/{id}",
|
|
method: http.MethodDelete,
|
|
path: "/super-admin/custom-schemas/" + uuid.UUID(baseSeed.Id).String() + "/delete",
|
|
handler: func(c echo.Context, cons *queryapi.Controllers, seededID openapi_types.UUID) error {
|
|
return cons.DeleteCustomSchema(c, seededID)
|
|
},
|
|
},
|
|
}
|
|
|
|
rolesUnderTest := []struct {
|
|
role string
|
|
allowRead bool
|
|
allowWrite bool
|
|
description string
|
|
}{
|
|
{role: "super_admin", allowRead: true, allowWrite: true, description: "full super-admin grants"},
|
|
{role: "user_admin", allowRead: false, allowWrite: false, description: "no super-admin grants"},
|
|
{role: "client_user", allowRead: false, allowWrite: false, description: "no super-admin grants"},
|
|
{role: "auditor", allowRead: true, allowWrite: false, description: "GET-only super-admin grant"},
|
|
}
|
|
|
|
countSchemas := func(t *testing.T) int {
|
|
t.Helper()
|
|
var n int
|
|
require.NoError(t,
|
|
cfg.GetDBPool().QueryRow(t.Context(),
|
|
`SELECT COUNT(*) FROM client_metadata_schemas WHERE client_id = $1`,
|
|
clientID).Scan(&n))
|
|
return n
|
|
}
|
|
|
|
for _, rt := range rolesUnderTest {
|
|
rt := rt
|
|
t.Run(rt.role, func(t *testing.T) {
|
|
for _, route := range routes {
|
|
route := route
|
|
t.Run(route.name, func(t *testing.T) {
|
|
// Pick a fresh seed for the DELETE-super_admin cell so
|
|
// we don't retire the shared baseSeed row.
|
|
seedID := baseSeed.Id
|
|
if rt.role == "super_admin" && route.method == http.MethodDelete {
|
|
fresh := seedCustomSchema(t, cons, clientID,
|
|
"authz-del-"+uuid.New().String()[:6])
|
|
seedID = fresh.Id
|
|
}
|
|
|
|
allowed := rt.allowWrite
|
|
if route.isRead {
|
|
allowed = rt.allowRead
|
|
}
|
|
|
|
// Build the echo context with the correct body for POST
|
|
// routes so if the handler IS called, it can succeed.
|
|
var body interface{}
|
|
if route.method == http.MethodPost && route.path == "/super-admin/custom-schemas" {
|
|
body = createBodyFactory()
|
|
} else if route.method == http.MethodPost {
|
|
body = versionBody
|
|
}
|
|
c, rec := newCustomSchemaContext(t, route.method, body)
|
|
// Override the request URL so the middleware sees the
|
|
// real path prefix — the default test harness uses "/".
|
|
c.Request().URL.Path = route.path
|
|
|
|
beforeCount := countSchemas(t)
|
|
|
|
handlerCalled := false
|
|
wrappedHandler := func(ec echo.Context) error {
|
|
handlerCalled = true
|
|
return route.handler(ec, cons, seedID)
|
|
}
|
|
checker := &roleAwarePermitChecker{role: rt.role}
|
|
|
|
err := runPermitGate(c, route.path, checker, wrappedHandler)
|
|
// runPermitGate may return a handler error on the
|
|
// allowed path (e.g., a handler-level 409 surfaces as
|
|
// *echo.HTTPError). Only assert NoError for the
|
|
// positive path; denials never produce a Go error
|
|
// because runPermitGate writes the response directly.
|
|
if allowed {
|
|
require.NoError(t, err, "allowed path must succeed: %v", err)
|
|
}
|
|
|
|
if allowed {
|
|
assert.True(t, handlerCalled,
|
|
"%s on %s must reach the handler when role %s is allowed",
|
|
route.method, route.path, rt.role)
|
|
// Successful responses live in recCreate.Code — not
|
|
// necessarily 200, but must be a 2xx.
|
|
assert.GreaterOrEqual(t, rec.Code, 200)
|
|
assert.Less(t, rec.Code, 300,
|
|
"allowed %s on %s must produce a 2xx response; got %d",
|
|
route.method, route.path, rec.Code)
|
|
} else {
|
|
assert.False(t, handlerCalled,
|
|
"%s on %s must NOT reach the handler when role %s is denied",
|
|
route.method, route.path, rt.role)
|
|
assert.Equal(t, http.StatusForbidden, rec.Code,
|
|
"denied %s on %s must return 403; role %s",
|
|
route.method, route.path, rt.role)
|
|
|
|
// DB unchanged: write routes must not have created
|
|
// rows; read routes can't change the count anyway.
|
|
afterCount := countSchemas(t)
|
|
assert.Equal(t, beforeCount, afterCount,
|
|
"denied request must leave client_metadata_schemas row count unchanged")
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestM3_SchemaVersionConcurrency proves that two concurrent
|
|
// POST /super-admin/custom-schemas/{schemaId}/versions calls against the
|
|
// same parent schema produce deterministic results: one succeeds with v2
|
|
// (status 201), the other returns 409 (ErrSchemaParentNotActive because
|
|
// creating v2 sets v1 to superseded, so creating v3 from v1 fails).
|
|
//
|
|
// After both goroutines complete, the DB must have exactly one "active"
|
|
// row for this schema name.
|
|
func TestM3_SchemaVersionConcurrency(t *testing.T) {
|
|
if testing.Short() {
|
|
t.SkipNow()
|
|
}
|
|
cfg := &ControllerConfig{}
|
|
test.CreateDB(t, cfg)
|
|
initializeTestConfig(t, cfg)
|
|
|
|
svc := createControllerServices(cfg)
|
|
cons := queryapi.NewControllers(svc, cfg)
|
|
|
|
const clientID = "M3_CONC_VER"
|
|
resetClientForSchemaTests(t, cfg, clientID)
|
|
test.CreateTestClient(t, cfg, clientID, "M3 concurrency version client")
|
|
|
|
// Seed a v1 schema -- both goroutines will try to create v2 from this parent.
|
|
v1 := seedCustomSchema(t, cons, clientID, "m3-conc-lineage")
|
|
require.Equal(t, int32(1), v1.Version)
|
|
require.Equal(t, queryapi.SchemaStatusActive, v1.Status)
|
|
|
|
type result struct {
|
|
statusCode int
|
|
err error
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
results := make(chan result, 2)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
body := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)}
|
|
ctx, rec := newCustomSchemaContext(t, http.MethodPost, body)
|
|
handlerErr := cons.CreateCustomSchemaVersion(ctx, v1.Id)
|
|
|
|
if handlerErr != nil {
|
|
// The handler returns echo.HTTPError for mapped sentinels.
|
|
httpErr, ok := handlerErr.(*echo.HTTPError)
|
|
if ok {
|
|
results <- result{statusCode: httpErr.Code, err: nil}
|
|
} else {
|
|
results <- result{statusCode: 0, err: handlerErr}
|
|
}
|
|
return
|
|
}
|
|
results <- result{statusCode: rec.Code, err: nil}
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
var codes []int
|
|
for r := range results {
|
|
require.NoError(t, r.err, "goroutine must not produce an unexpected error")
|
|
codes = append(codes, r.statusCode)
|
|
}
|
|
|
|
// One must be 201 (created v2), the other must be 409 (parent not active).
|
|
assert.Len(t, codes, 2)
|
|
assert.Contains(t, codes, http.StatusCreated,
|
|
"one goroutine must succeed with 201")
|
|
assert.Contains(t, codes, http.StatusConflict,
|
|
"one goroutine must fail with 409 (parent no longer active)")
|
|
|
|
// DB invariant: exactly one active row for this lineage.
|
|
var activeCount int
|
|
err := cfg.GetDBPool().QueryRow(t.Context(),
|
|
`SELECT COUNT(*) FROM client_metadata_schemas
|
|
WHERE client_id = $1 AND name = $2 AND status = 'active'`,
|
|
clientID, "m3-conc-lineage").Scan(&activeCount)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, activeCount,
|
|
"after concurrent version creation, exactly one row must be active")
|
|
}
|