Files
query-orchestration/api/queryAPI/customschemas_integration_test.go
Jay Brown 17fc813823 Merged in feature/mutable-metadata1 (pull request #221)
M1, M2 and M3 complete

* M1, M2 and M3 complete

* review changes

* docs

* docs
2026-04-16 23:11:26 +00:00

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")
}