Merged in feature/mutable-metadata1 (pull request #221)
M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
This commit is contained in:
+928
-301
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import (
|
||||
clientupdate "queryorchestration/internal/client/update"
|
||||
"queryorchestration/internal/collector"
|
||||
collectorset "queryorchestration/internal/collector/set"
|
||||
"queryorchestration/internal/customschema"
|
||||
"queryorchestration/internal/document"
|
||||
documentbatch "queryorchestration/internal/document/batch"
|
||||
documentdownload "queryorchestration/internal/document/download"
|
||||
@@ -36,6 +37,7 @@ type Services struct {
|
||||
DocumentBatch *documentbatch.Service
|
||||
UISettings *uisettings.Service
|
||||
FieldExtraction *fieldextraction.Service
|
||||
CustomSchema *customschema.Service
|
||||
Folder *folder.Service
|
||||
Label *label.Service
|
||||
Eula *eula.Service
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// Package queryapi: handler implementations for the CustomMetadataService
|
||||
// routes (POST /custom-metadata, GET /custom-metadata,
|
||||
// GET /custom-metadata/version, GET /custom-metadata/history) plus the
|
||||
// super-admin PATCH /super-admin/documents/{id}/schema route.
|
||||
//
|
||||
// Every mutating handler derives the actor from the JWT sub claim via
|
||||
// cognitoauth.GetUserSubject and never reads a createdBy field from the
|
||||
// request body — the generated request types do not even declare one.
|
||||
package queryapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"queryorchestration/internal/cognitoauth"
|
||||
"queryorchestration/internal/customschema"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
// AssignDocumentSchema is PATCH /super-admin/documents/{id}/schema. The
|
||||
// customSchemaId field may be null (clear the binding) or a UUID (bind).
|
||||
func (s *Controllers) AssignDocumentSchema(ctx echo.Context, id openapi_types.UUID) error {
|
||||
actor, ok := cognitoauth.GetUserSubject(ctx)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
||||
}
|
||||
|
||||
var req DocumentSchemaAssignRequest
|
||||
if err := ctx.Bind(&req); err != nil {
|
||||
slog.Error("failed to parse assign schema request", "error", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
// CustomSchemaId is a Nullable[UUID]: the caller can omit it
|
||||
// (unspecified), explicitly set it to null (clear the binding), or
|
||||
// provide a UUID (bind). Unspecified is treated as a 400 because the
|
||||
// admin must make an explicit choice.
|
||||
var schemaID *uuid.UUID
|
||||
switch {
|
||||
case req.CustomSchemaId.IsSpecified() && !req.CustomSchemaId.IsNull():
|
||||
sid, err := req.CustomSchemaId.Get()
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid customSchemaId")
|
||||
}
|
||||
u := uuid.UUID(sid)
|
||||
schemaID = &u
|
||||
case req.CustomSchemaId.IsSpecified() && req.CustomSchemaId.IsNull():
|
||||
// Explicit null → clear binding. schemaID stays nil.
|
||||
default:
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "customSchemaId is required (use null to clear)")
|
||||
}
|
||||
|
||||
result, err := s.svc.CustomSchema.AssignSchema(ctx.Request().Context(), uuid.UUID(id), schemaID, actor)
|
||||
if err != nil {
|
||||
return mapCustomMetadataError(err)
|
||||
}
|
||||
|
||||
resp := DocumentSchemaAssignResponse{
|
||||
DocumentId: openapi_types.UUID(result.DocumentID),
|
||||
}
|
||||
if result.CustomSchemaID != nil {
|
||||
resp.CustomSchemaId = nullable.NewNullableWithValue(openapi_types.UUID(*result.CustomSchemaID))
|
||||
} else {
|
||||
resp.CustomSchemaId = nullable.NewNullNullable[openapi_types.UUID]()
|
||||
}
|
||||
if result.SchemaName != nil {
|
||||
resp.SchemaName = nullable.NewNullableWithValue(*result.SchemaName)
|
||||
} else {
|
||||
resp.SchemaName = nullable.NewNullNullable[string]()
|
||||
}
|
||||
if result.SchemaVersion != nil {
|
||||
v := int32(*result.SchemaVersion) //nolint:gosec // per-lineage monotonic counter
|
||||
resp.SchemaVersion = nullable.NewNullableWithValue(v)
|
||||
} else {
|
||||
resp.SchemaVersion = nullable.NewNullNullable[int32]()
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// SetCustomMetadata is POST /custom-metadata.
|
||||
func (s *Controllers) SetCustomMetadata(ctx echo.Context) error {
|
||||
actor, ok := cognitoauth.GetUserSubject(ctx)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
||||
}
|
||||
|
||||
var req CustomMetadataRequest
|
||||
if err := ctx.Bind(&req); err != nil {
|
||||
slog.Error("failed to parse custom metadata request", "error", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
metadataBytes, err := json.Marshal(req.Metadata)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid metadata JSON")
|
||||
}
|
||||
|
||||
input := customschema.SetMetadataInput{
|
||||
DocumentID: uuid.UUID(req.DocumentId),
|
||||
Metadata: metadataBytes,
|
||||
}
|
||||
got, err := s.svc.CustomSchema.SetDocumentMetadata(ctx.Request().Context(), input, actor)
|
||||
if err != nil {
|
||||
return mapCustomMetadataError(err)
|
||||
}
|
||||
resp, err := metadataToResponse(got)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode metadata response")
|
||||
}
|
||||
return ctx.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// GetCurrentCustomMetadata is GET /custom-metadata?documentId=....
|
||||
func (s *Controllers) GetCurrentCustomMetadata(ctx echo.Context, params GetCurrentCustomMetadataParams) error {
|
||||
if _, ok := cognitoauth.GetUserSubject(ctx); !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
||||
}
|
||||
got, err := s.svc.CustomSchema.GetCurrentMetadata(ctx.Request().Context(), uuid.UUID(params.DocumentId))
|
||||
if err != nil {
|
||||
return mapCustomMetadataError(err)
|
||||
}
|
||||
resp, err := metadataToResponse(got)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode metadata response")
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetCustomMetadataByVersion is GET /custom-metadata/version?documentId=...&version=N.
|
||||
func (s *Controllers) GetCustomMetadataByVersion(ctx echo.Context, params GetCustomMetadataByVersionParams) error {
|
||||
if _, ok := cognitoauth.GetUserSubject(ctx); !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
||||
}
|
||||
got, err := s.svc.CustomSchema.GetMetadataByVersion(ctx.Request().Context(), uuid.UUID(params.DocumentId), int(params.Version))
|
||||
if err != nil {
|
||||
return mapCustomMetadataError(err)
|
||||
}
|
||||
resp, err := metadataToResponse(got)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode metadata response")
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// customMetadataHistoryRow is a type alias onto the anonymous element
|
||||
// type inside the generated CustomMetadataHistoryResponse.Versions slice.
|
||||
// Structural identity with the generated type lets us append directly.
|
||||
type customMetadataHistoryRow = struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
Version int32 `json:"version"`
|
||||
}
|
||||
|
||||
// GetCustomMetadataHistory is GET /custom-metadata/history?documentId=...&limit=N&offset=M.
|
||||
func (s *Controllers) GetCustomMetadataHistory(ctx echo.Context, params GetCustomMetadataHistoryParams) error {
|
||||
if _, ok := cognitoauth.GetUserSubject(ctx); !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
||||
}
|
||||
limit := 50
|
||||
if params.Limit != nil {
|
||||
limit = int(*params.Limit)
|
||||
}
|
||||
offset := 0
|
||||
if params.Offset != nil {
|
||||
offset = int(*params.Offset)
|
||||
}
|
||||
got, err := s.svc.CustomSchema.GetMetadataHistory(ctx.Request().Context(), uuid.UUID(params.DocumentId), limit, offset)
|
||||
if err != nil {
|
||||
return mapCustomMetadataError(err)
|
||||
}
|
||||
out := CustomMetadataHistoryResponse{
|
||||
Versions: make([]customMetadataHistoryRow, 0, len(got)),
|
||||
}
|
||||
for _, v := range got {
|
||||
out.Versions = append(out.Versions, customMetadataHistoryRow{
|
||||
Version: int32(v.Version), //nolint:gosec // metadata version is a per-doc monotonic counter, far below int32 ceiling
|
||||
CreatedAt: v.CreatedAt,
|
||||
CreatedBy: v.CreatedBy,
|
||||
})
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// mapCustomMetadataError translates a customschema.Service error coming out
|
||||
// of the metadata/assign-schema paths into an echo HTTP error with the
|
||||
// correct status code.
|
||||
func mapCustomMetadataError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, customschema.ErrDocumentNotFound):
|
||||
return echo.NewHTTPError(http.StatusNotFound, "document not found")
|
||||
case errors.Is(err, customschema.ErrMetadataVersionNotFound):
|
||||
return echo.NewHTTPError(http.StatusNotFound, "metadata version not found")
|
||||
case errors.Is(err, customschema.ErrDocumentNotBoundToSchema):
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "document is not bound to a custom schema")
|
||||
case errors.Is(err, customschema.ErrMetadataPayloadTooLarge):
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "metadata payload exceeds maximum size")
|
||||
case errors.Is(err, customschema.ErrSchemaNotFound):
|
||||
return echo.NewHTTPError(http.StatusNotFound, "schema not found")
|
||||
case errors.Is(err, customschema.ErrSchemaNotActive):
|
||||
return echo.NewHTTPError(http.StatusConflict, "schema is not active")
|
||||
case errors.Is(err, customschema.ErrSchemaClientMismatch):
|
||||
return echo.NewHTTPError(http.StatusConflict, "schema and document belong to different clients")
|
||||
case errors.Is(err, customschema.ErrDocumentHasCustomMetadata):
|
||||
return echo.NewHTTPError(http.StatusConflict, "document already has custom metadata")
|
||||
case errors.Is(err, customschema.ErrDocumentHasLegacyExtractions):
|
||||
return echo.NewHTTPError(http.StatusConflict, "document has legacy field extractions")
|
||||
}
|
||||
// Validator failures wrap "validate metadata". The history service
|
||||
// method surfaces limit/offset errors as plain strings.
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "validate metadata") ||
|
||||
strings.Contains(msg, "limit must be") ||
|
||||
strings.Contains(msg, "offset must be") ||
|
||||
strings.Contains(msg, "document id is required") ||
|
||||
strings.Contains(msg, "version must be") {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, msg)
|
||||
}
|
||||
slog.Error("custom metadata handler internal error", "error", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
|
||||
}
|
||||
|
||||
// metadataToResponse copies a customschema.CustomMetadata into the
|
||||
// generated CustomMetadataResponse.
|
||||
func metadataToResponse(m *customschema.CustomMetadata) (CustomMetadataResponse, error) {
|
||||
var metaMap map[string]interface{}
|
||||
if len(m.Metadata) > 0 {
|
||||
if err := json.Unmarshal(m.Metadata, &metaMap); err != nil {
|
||||
return CustomMetadataResponse{}, err
|
||||
}
|
||||
}
|
||||
resp := CustomMetadataResponse{
|
||||
Id: openapi_types.UUID(m.ID),
|
||||
DocumentId: openapi_types.UUID(m.DocumentID),
|
||||
Metadata: metaMap,
|
||||
Version: int32(m.Version), //nolint:gosec // per-doc monotonic counter
|
||||
CreatedAt: m.CreatedAt,
|
||||
CreatedBy: m.CreatedBy,
|
||||
}
|
||||
if m.SchemaID != nil {
|
||||
resp.SchemaId = nullable.NewNullableWithValue(openapi_types.UUID(*m.SchemaID))
|
||||
} else {
|
||||
resp.SchemaId = nullable.NewNullNullable[openapi_types.UUID]()
|
||||
}
|
||||
if m.SchemaName != nil {
|
||||
resp.SchemaName = nullable.NewNullableWithValue(*m.SchemaName)
|
||||
} else {
|
||||
resp.SchemaName = nullable.NewNullNullable[string]()
|
||||
}
|
||||
if m.SchemaVersion != nil {
|
||||
v := int32(*m.SchemaVersion) //nolint:gosec // per-lineage monotonic counter
|
||||
resp.SchemaVersion = nullable.NewNullableWithValue(v)
|
||||
} else {
|
||||
resp.SchemaVersion = nullable.NewNullNullable[int32]()
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package queryapi — white-box unit tests for mapCustomMetadataError.
|
||||
// These tests live in package queryapi (not package queryapi_test) to access
|
||||
// the unexported function directly without requiring a real DB or echo context.
|
||||
package queryapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"queryorchestration/internal/customschema"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func httpCode(t *testing.T, err error) int {
|
||||
t.Helper()
|
||||
var httpErr *echo.HTTPError
|
||||
require.True(t, errors.As(err, &httpErr), "expected echo.HTTPError, got %T: %v", err, err)
|
||||
return httpErr.Code
|
||||
}
|
||||
|
||||
func TestMapCustomMetadataError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input error
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "document_not_found",
|
||||
input: customschema.ErrDocumentNotFound,
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "metadata_version_not_found",
|
||||
input: customschema.ErrMetadataVersionNotFound,
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "not_bound_to_schema",
|
||||
input: customschema.ErrDocumentNotBoundToSchema,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "payload_too_large",
|
||||
input: customschema.ErrMetadataPayloadTooLarge,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "schema_not_found",
|
||||
input: customschema.ErrSchemaNotFound,
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "schema_not_active",
|
||||
input: customschema.ErrSchemaNotActive,
|
||||
wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "schema_client_mismatch",
|
||||
input: customschema.ErrSchemaClientMismatch,
|
||||
wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "has_custom_metadata",
|
||||
input: customschema.ErrDocumentHasCustomMetadata,
|
||||
wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "has_legacy_extractions",
|
||||
input: customschema.ErrDocumentHasLegacyExtractions,
|
||||
wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "validate_metadata_string",
|
||||
input: fmt.Errorf("customschema: validate metadata: payload error"),
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "limit_must_be_string",
|
||||
input: fmt.Errorf("customschema: limit must be between 1 and 200"),
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "offset_must_be_string",
|
||||
input: fmt.Errorf("customschema: offset must be >= 0"),
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "document_id_required_string",
|
||||
input: fmt.Errorf("customschema: document id is required"),
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "version_must_be_string",
|
||||
input: fmt.Errorf("customschema: version must be >= 1"),
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "unknown_error_returns_500",
|
||||
input: fmt.Errorf("unexpected internal error"),
|
||||
wantCode: http.StatusInternalServerError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := mapCustomMetadataError(tc.input)
|
||||
assert.Equal(t, tc.wantCode, httpCode(t, got), "wrong status for %v", tc.input)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapResetError covers all three branches of mapResetError declared in
|
||||
// customschemas_reset.go. The function is unexported so the test lives in the
|
||||
// same package (package queryapi, not package queryapi_test).
|
||||
func TestMapResetError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input error
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "document_not_found",
|
||||
input: customschema.ErrDocumentNotFound,
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "mutual_exclusivity_violation",
|
||||
input: customschema.ErrMutualExclusivityViolation,
|
||||
wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "unknown_error_returns_500",
|
||||
input: fmt.Errorf("unexpected reset failure"),
|
||||
wantCode: http.StatusInternalServerError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := mapResetError(tc.input)
|
||||
assert.Equal(t, tc.wantCode, httpCode(t, got), "wrong status for %v", tc.input)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
// Package queryapi_test — Milestone 2 §2.8 end-to-end integration tests.
|
||||
//
|
||||
// These tests compose the Milestone 2 HTTP handlers, the customschema
|
||||
// service, the fieldextraction service, and the real Postgres testcontainer
|
||||
// produced by internal/test.CreateDB to prove the mutual-exclusivity
|
||||
// invariant, the full happy-path round-trip, and the edge cases the plan
|
||||
// calls out in §2.8.
|
||||
//
|
||||
// No mocks. Every request goes through a real handler receiver. The
|
||||
// helpers below reuse the Milestone 1 testutils (newCustomSchemaContext,
|
||||
// seedCustomSchema, resetClientForSchemaTests) so the two files share the
|
||||
// same fixture shape.
|
||||
package queryapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
queryapi "queryorchestration/api/queryAPI"
|
||||
"queryorchestration/internal/cognitoauth"
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/fieldextraction"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newCustomMetadataContext is the Milestone 2 sibling of
|
||||
// newCustomSchemaContext — it exists only so the tests in this file can
|
||||
// read clearly at a glance; the implementation is identical.
|
||||
func newCustomMetadataContext(t testing.TB, method string, body interface{}) (echo.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
e := echo.New()
|
||||
var reqBody []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
reqBody, err = json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
req := httptest.NewRequest(method, "/", bytes.NewReader(reqBody))
|
||||
if reqBody != nil {
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := e.NewContext(req, rec)
|
||||
ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject})
|
||||
ctx.Set("user_info", cognitoauth.UserInfo{Username: "admin"})
|
||||
return ctx, rec
|
||||
}
|
||||
|
||||
// setupM2Fixture boots a fresh DB, wipes any rows belonging to clientID,
|
||||
// creates the client and returns the controllers. Mirrors the Milestone 1
|
||||
// pattern but in one helper so the §2.8 tests stay short.
|
||||
func setupM2Fixture(t *testing.T, clientID string) (*queryapi.Controllers, *ControllerConfig) {
|
||||
t.Helper()
|
||||
cfg := &ControllerConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
initializeTestConfig(t, cfg)
|
||||
svc := createControllerServices(cfg)
|
||||
cons := queryapi.NewControllers(svc, cfg)
|
||||
|
||||
resetClientForSchemaTests(t, cfg, clientID)
|
||||
test.CreateTestClient(t, cfg, clientID, "M2 integration "+clientID)
|
||||
return cons, cfg
|
||||
}
|
||||
|
||||
// seedDoc creates a document for clientID with a unique hash.
|
||||
func seedDoc(t *testing.T, cfg *ControllerConfig, clientID, hash string) uuid.UUID {
|
||||
t.Helper()
|
||||
id, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: hash,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
// assignSchemaForTest wraps AssignDocumentSchema against a freshly built
|
||||
// echo context so the §2.8 tests can bind a document in one line.
|
||||
func assignSchemaForTest(t *testing.T, cons *queryapi.Controllers, docID, schemaID uuid.UUID) {
|
||||
t.Helper()
|
||||
body := queryapi.DocumentSchemaAssignRequest{
|
||||
CustomSchemaId: nullable.NewNullableWithValue(openapi_types.UUID(schemaID)),
|
||||
}
|
||||
ctx, rec := newCustomMetadataContext(t, http.MethodPatch, body)
|
||||
require.NoError(t, cons.AssignDocumentSchema(ctx, openapi_types.UUID(docID)))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "assign must return 200")
|
||||
}
|
||||
|
||||
// ---------- end-to-end happy round-trip ----------
|
||||
|
||||
// TestM2_HappyRoundTrip covers the §2.8 full round-trip:
|
||||
// create schema → assign to document → POST valid metadata → GET returns
|
||||
// it with populated schemaId / schemaName / schemaVersion / createdBy.
|
||||
func TestM2_HappyRoundTrip(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M2_HAPPY"
|
||||
cons, cfg := setupM2Fixture(t, clientID)
|
||||
|
||||
schema := seedCustomSchema(t, cons, clientID, "m2-happy")
|
||||
docID := seedDoc(t, cfg, clientID, "m2-happy-doc")
|
||||
assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id))
|
||||
|
||||
// POST valid metadata.
|
||||
postBody := queryapi.CustomMetadataRequest{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Metadata: map[string]interface{}{"name": "contract-42"},
|
||||
}
|
||||
ctxPost, recPost := newCustomMetadataContext(t, http.MethodPost, postBody)
|
||||
require.NoError(t, cons.SetCustomMetadata(ctxPost))
|
||||
require.Equal(t, http.StatusCreated, recPost.Code)
|
||||
|
||||
var posted queryapi.CustomMetadataResponse
|
||||
require.NoError(t, json.Unmarshal(recPost.Body.Bytes(), &posted))
|
||||
assert.Equal(t, int32(1), posted.Version)
|
||||
assert.Equal(t, testActorSubject, posted.CreatedBy)
|
||||
|
||||
// GET /custom-metadata?documentId=...
|
||||
ctxGet, recGet := newCustomMetadataContext(t, http.MethodGet, nil)
|
||||
err := cons.GetCurrentCustomMetadata(ctxGet, queryapi.GetCurrentCustomMetadataParams{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, recGet.Code)
|
||||
|
||||
var got queryapi.CustomMetadataResponse
|
||||
require.NoError(t, json.Unmarshal(recGet.Body.Bytes(), &got))
|
||||
assert.Equal(t, posted.Version, got.Version)
|
||||
assert.Equal(t, posted.Id, got.Id)
|
||||
require.True(t, got.SchemaId.IsSpecified() && !got.SchemaId.IsNull())
|
||||
sid, _ := got.SchemaId.Get()
|
||||
assert.Equal(t, schema.Id, sid)
|
||||
schemaName, _ := got.SchemaName.Get()
|
||||
assert.Equal(t, "m2-happy", schemaName)
|
||||
schemaVersion, _ := got.SchemaVersion.Get()
|
||||
assert.Equal(t, int32(1), schemaVersion)
|
||||
}
|
||||
|
||||
// TestM2_PostWithoutSchemaReturns400 proves POST /custom-metadata refuses
|
||||
// writes to documents that have no schema binding.
|
||||
func TestM2_PostWithoutSchemaReturns400(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M2_NOSCH"
|
||||
cons, cfg := setupM2Fixture(t, clientID)
|
||||
|
||||
docID := seedDoc(t, cfg, clientID, "m2-nosch-doc")
|
||||
|
||||
postBody := queryapi.CustomMetadataRequest{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Metadata: map[string]interface{}{"name": "nope"},
|
||||
}
|
||||
ctxPost, recPost := newCustomMetadataContext(t, http.MethodPost, postBody)
|
||||
err := cons.SetCustomMetadata(ctxPost)
|
||||
// The handler propagates echo.HTTPError for mapped sentinels; the
|
||||
// real HTTP server flushes this as a response. For the handler unit
|
||||
// test we inspect the error directly.
|
||||
requireHTTPStatus(t, err, recPost, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// TestM2_LegacyWriteOnCustomDoc covers the §2.8 mutual-exclusivity end:
|
||||
// schema bound → legacy field extraction write → 409.
|
||||
func TestM2_LegacyWriteOnCustomDoc(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M2_LEGACY"
|
||||
cons, cfg := setupM2Fixture(t, clientID)
|
||||
|
||||
schema := seedCustomSchema(t, cons, clientID, "m2-legacy")
|
||||
docID := seedDoc(t, cfg, clientID, "m2-legacy-doc")
|
||||
assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id))
|
||||
|
||||
// Attempt legacy write via the fieldextraction service directly
|
||||
// (the REST handler would do the same mapping — we test service +
|
||||
// handler mapping separately in the fieldextraction package).
|
||||
svc := createControllerServices(cfg)
|
||||
_, err := svc.FieldExtraction.CreateFieldExtraction(t.Context(), &fieldextraction.CreateFieldExtractionInput{
|
||||
DocumentID: docID,
|
||||
SingleFields: &repository.AddFieldExtractionParams{
|
||||
Documentid: docID,
|
||||
Createdby: "tester",
|
||||
},
|
||||
ArrayFields: nil,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, fieldextraction.ErrMutualExclusivityViolation)
|
||||
}
|
||||
|
||||
// TestM2_GetByVersion covers §2.8 GET /custom-metadata/version happy
|
||||
// path: post 3 versions, retrieve v2 exactly.
|
||||
func TestM2_GetByVersion(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M2_VERSION"
|
||||
cons, cfg := setupM2Fixture(t, clientID)
|
||||
|
||||
schema := seedCustomSchema(t, cons, clientID, "m2-version")
|
||||
docID := seedDoc(t, cfg, clientID, "m2-version-doc")
|
||||
assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id))
|
||||
|
||||
for _, payload := range []string{`{"name":"v1"}`, `{"name":"v2"}`, `{"name":"v3"}`} {
|
||||
var m map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(payload), &m))
|
||||
body := queryapi.CustomMetadataRequest{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Metadata: m,
|
||||
}
|
||||
ctx, rec := newCustomMetadataContext(t, http.MethodPost, body)
|
||||
require.NoError(t, cons.SetCustomMetadata(ctx))
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
}
|
||||
|
||||
ctxVer, recVer := newCustomMetadataContext(t, http.MethodGet, nil)
|
||||
err := cons.GetCustomMetadataByVersion(ctxVer, queryapi.GetCustomMetadataByVersionParams{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Version: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, recVer.Code)
|
||||
|
||||
var got queryapi.CustomMetadataResponse
|
||||
require.NoError(t, json.Unmarshal(recVer.Body.Bytes(), &got))
|
||||
assert.Equal(t, int32(2), got.Version)
|
||||
// metadata must be the v2 payload, not v1 or v3
|
||||
v, ok := got.Metadata["name"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "v2", v)
|
||||
}
|
||||
|
||||
// TestM2_History covers the §2.8 history GET happy path with pagination.
|
||||
func TestM2_History(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M2_HIST"
|
||||
cons, cfg := setupM2Fixture(t, clientID)
|
||||
|
||||
schema := seedCustomSchema(t, cons, clientID, "m2-hist")
|
||||
docID := seedDoc(t, cfg, clientID, "m2-hist-doc")
|
||||
assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id))
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
body := queryapi.CustomMetadataRequest{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Metadata: map[string]interface{}{"name": "x"},
|
||||
}
|
||||
ctx, rec := newCustomMetadataContext(t, http.MethodPost, body)
|
||||
require.NoError(t, cons.SetCustomMetadata(ctx))
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
}
|
||||
|
||||
limit := int32(2)
|
||||
offset := int32(0)
|
||||
ctx1, rec1 := newCustomMetadataContext(t, http.MethodGet, nil)
|
||||
require.NoError(t, cons.GetCustomMetadataHistory(ctx1, queryapi.GetCustomMetadataHistoryParams{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Limit: &limit,
|
||||
Offset: &offset,
|
||||
}))
|
||||
require.Equal(t, http.StatusOK, rec1.Code)
|
||||
var page1 queryapi.CustomMetadataHistoryResponse
|
||||
require.NoError(t, json.Unmarshal(rec1.Body.Bytes(), &page1))
|
||||
require.Len(t, page1.Versions, 2)
|
||||
assert.Equal(t, int32(5), page1.Versions[0].Version)
|
||||
assert.Equal(t, int32(4), page1.Versions[1].Version)
|
||||
|
||||
offset2 := int32(2)
|
||||
ctx2, rec2 := newCustomMetadataContext(t, http.MethodGet, nil)
|
||||
require.NoError(t, cons.GetCustomMetadataHistory(ctx2, queryapi.GetCustomMetadataHistoryParams{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Limit: &limit,
|
||||
Offset: &offset2,
|
||||
}))
|
||||
var page2 queryapi.CustomMetadataHistoryResponse
|
||||
require.NoError(t, json.Unmarshal(rec2.Body.Bytes(), &page2))
|
||||
require.Len(t, page2.Versions, 2)
|
||||
assert.Equal(t, int32(3), page2.Versions[0].Version)
|
||||
assert.Equal(t, int32(2), page2.Versions[1].Version)
|
||||
}
|
||||
|
||||
// TestM2_AssignToDocWithLegacyExtractionsReturns409 covers the reverse
|
||||
// direction of the mutual-exclusivity invariant: a doc with legacy
|
||||
// extractions cannot be bound to a schema.
|
||||
func TestM2_AssignToDocWithLegacyExtractionsReturns409(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M2_LEGACYASSIGN"
|
||||
cons, cfg := setupM2Fixture(t, clientID)
|
||||
|
||||
// Create a document and seed a legacy extraction via the service.
|
||||
svc := createControllerServices(cfg)
|
||||
docID := seedDoc(t, cfg, clientID, "m2-legacy-assign-doc")
|
||||
_, err := svc.FieldExtraction.CreateFieldExtraction(t.Context(), &fieldextraction.CreateFieldExtractionInput{
|
||||
DocumentID: docID,
|
||||
SingleFields: &repository.AddFieldExtractionParams{
|
||||
Documentid: docID,
|
||||
Createdby: "tester",
|
||||
},
|
||||
ArrayFields: nil,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
schema := seedCustomSchema(t, cons, clientID, "m2-legacy-assign")
|
||||
|
||||
// Attempt to bind — must 409.
|
||||
body := queryapi.DocumentSchemaAssignRequest{
|
||||
CustomSchemaId: nullable.NewNullableWithValue(schema.Id),
|
||||
}
|
||||
ctx, rec := newCustomMetadataContext(t, http.MethodPatch, body)
|
||||
err = cons.AssignDocumentSchema(ctx, openapi_types.UUID(docID))
|
||||
requireHTTPStatus(t, err, rec, http.StatusConflict)
|
||||
}
|
||||
|
||||
// TestM2_FKCascadeDeletesMetadata proves the FK cascade from documents
|
||||
// removes document_custom_metadata rows on document delete, covering the
|
||||
// §2.8 "FK-cascade delete" row.
|
||||
func TestM2_FKCascadeDeletesMetadata(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M2_FKCASCADE"
|
||||
cons, cfg := setupM2Fixture(t, clientID)
|
||||
|
||||
schema := seedCustomSchema(t, cons, clientID, "m2-fkcascade")
|
||||
docID := seedDoc(t, cfg, clientID, "m2-fkcascade-doc")
|
||||
assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id))
|
||||
|
||||
// Write two metadata versions via the handler.
|
||||
for i := 0; i < 2; i++ {
|
||||
body := queryapi.CustomMetadataRequest{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Metadata: map[string]interface{}{"name": "x"},
|
||||
}
|
||||
ctx, rec := newCustomMetadataContext(t, http.MethodPost, body)
|
||||
require.NoError(t, cons.SetCustomMetadata(ctx))
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
}
|
||||
|
||||
var before int
|
||||
require.NoError(t, cfg.GetDBPool().QueryRow(t.Context(),
|
||||
`SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`,
|
||||
docID).Scan(&before))
|
||||
require.Equal(t, 2, before)
|
||||
|
||||
// Delete the document row directly (the feature relies on the FK
|
||||
// cascade, not on an application-level delete path).
|
||||
_, err := cfg.GetDBPool().Exec(t.Context(),
|
||||
`DELETE FROM documents WHERE id = $1`, docID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var after int
|
||||
require.NoError(t, cfg.GetDBPool().QueryRow(t.Context(),
|
||||
`SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`,
|
||||
docID).Scan(&after))
|
||||
assert.Equal(t, 0, after, "FK cascade must remove every metadata row")
|
||||
}
|
||||
|
||||
// TestM2_GetByVersionNotFound exercises the ErrMetadataVersionNotFound (404)
|
||||
// branch in mapCustomMetadataError by requesting a version that does not exist.
|
||||
func TestM2_GetByVersionNotFound(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M2_VERNOTFOUND"
|
||||
cons, cfg := setupM2Fixture(t, clientID)
|
||||
|
||||
schema := seedCustomSchema(t, cons, clientID, "m2-ver-notfound")
|
||||
docID := seedDoc(t, cfg, clientID, "m2-ver-notfound-doc")
|
||||
assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id))
|
||||
|
||||
// POST one version so the document exists and has metadata.
|
||||
body := queryapi.CustomMetadataRequest{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Metadata: map[string]interface{}{"name": "x"},
|
||||
}
|
||||
ctx, rec := newCustomMetadataContext(t, http.MethodPost, body)
|
||||
require.NoError(t, cons.SetCustomMetadata(ctx))
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
|
||||
// Request a version that does not exist → 404.
|
||||
ctxVer, recVer := newCustomMetadataContext(t, http.MethodGet, nil)
|
||||
err := cons.GetCustomMetadataByVersion(ctxVer, queryapi.GetCustomMetadataByVersionParams{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Version: 99,
|
||||
})
|
||||
requireHTTPStatus(t, err, recVer, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// TestM2_CreateFieldExtractionOnCustomDocReturns409 exercises the
|
||||
// ErrMutualExclusivityViolation branch in the CreateFieldExtraction handler.
|
||||
// A document bound to a custom schema must reject legacy field extraction writes
|
||||
// with 409.
|
||||
func TestM2_CreateFieldExtractionOnCustomDocReturns409(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M2_LEGFX409"
|
||||
cons, cfg := setupM2Fixture(t, clientID)
|
||||
|
||||
schema := seedCustomSchema(t, cons, clientID, "m2-legfx-409")
|
||||
docID := seedDoc(t, cfg, clientID, "m2-legfx-409-doc")
|
||||
assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id))
|
||||
|
||||
// Attempt a legacy field extraction write — must 409.
|
||||
reqBody := queryapi.FieldExtractionRequest{
|
||||
DocumentId: queryapi.DocumentID(docID),
|
||||
CreatedBy: "test@example.com",
|
||||
SingleFields: queryapi.SingleFields{
|
||||
FileName: func() *string { s := "test.pdf"; return &s }(),
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(reqBody)
|
||||
require.NoError(t, err)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/field-extractions", bytes.NewReader(b))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := e.NewContext(req, rec)
|
||||
ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject})
|
||||
|
||||
fxErr := cons.CreateFieldExtraction(ctx)
|
||||
requireHTTPStatus(t, fxErr, rec, http.StatusConflict)
|
||||
}
|
||||
|
||||
// TestM2_CreateFieldExtractionDocNotFound exercises the ErrDocumentNotFound (404)
|
||||
// branch in the CreateFieldExtraction handler when the document does not exist.
|
||||
func TestM2_CreateFieldExtractionDocNotFound(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M2_FXDOCNOTFOUND"
|
||||
cons, _ := setupM2Fixture(t, clientID)
|
||||
|
||||
// Use a UUID that will not match any document.
|
||||
nonExistentDocID := uuid.New()
|
||||
reqBody := queryapi.FieldExtractionRequest{
|
||||
DocumentId: queryapi.DocumentID(nonExistentDocID),
|
||||
CreatedBy: "test@example.com",
|
||||
SingleFields: queryapi.SingleFields{
|
||||
FileName: func() *string { s := "test.pdf"; return &s }(),
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(reqBody)
|
||||
require.NoError(t, err)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/field-extractions", bytes.NewReader(b))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := e.NewContext(req, rec)
|
||||
ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject})
|
||||
|
||||
fxErr := cons.CreateFieldExtraction(ctx)
|
||||
requireHTTPStatus(t, fxErr, rec, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// requireHTTPStatus asserts the handler produced the expected HTTP code,
|
||||
// handling both the "echo.HTTPError returned" and "recorder has code
|
||||
// already" shapes the handlers can emit. Milestone 2 handlers always
|
||||
// return an echo.HTTPError for mapped sentinels rather than writing to
|
||||
// the recorder, so the error branch is the common path.
|
||||
func requireHTTPStatus(t *testing.T, err error, rec *httptest.ResponseRecorder, want int) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
var httpErr *echo.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
assert.Equalf(t, want, httpErr.Code, "expected status %d, got %d: %v", want, httpErr.Code, httpErr.Message)
|
||||
return
|
||||
}
|
||||
t.Fatalf("expected echo.HTTPError with status %d, got %T: %v", want, err, err)
|
||||
}
|
||||
assert.Equal(t, want, rec.Code)
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// Package queryapi: real handler implementations for the
|
||||
// SuperAdminSchemaService routes declared in the OpenAPI spec.
|
||||
//
|
||||
// Each handler:
|
||||
// 1. Extracts the authenticated Cognito subject via cognitoauth.GetUserSubject.
|
||||
// The handler never trusts a body-supplied "createdBy" — the request schema
|
||||
// does not even expose the field, and the service takes the actor as a
|
||||
// separate argument.
|
||||
// 2. Binds the request body (for POST) into the generated request type.
|
||||
// 3. Delegates to customschema.Service.
|
||||
// 4. Maps customschema sentinel errors and validator errors onto HTTP codes.
|
||||
// 5. Serializes the returned model into the generated response type.
|
||||
package queryapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"queryorchestration/internal/cognitoauth"
|
||||
"queryorchestration/internal/customschema"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
// validatorErrorMarker is the prefix customschema.Service wraps around every
|
||||
// validator failure it surfaces from CreateSchema / CreateSchemaVersion. The
|
||||
// handler layer uses it (via strings.Contains) to distinguish a 400-class
|
||||
// validation problem from a 500-class infrastructure error after sentinel
|
||||
// checks have already ruled out ErrSchemaNotFound, ErrSchemaInUse, etc.
|
||||
const validatorErrorMarker = "validate schema definition"
|
||||
|
||||
// customSchemaListRow is a type alias onto the anonymous element type that
|
||||
// oapi-codegen produced inside CustomSchemaListResponse.Schemas. Using an
|
||||
// alias (not a new type) preserves Go's structural identity so we can append
|
||||
// directly into the generated slice.
|
||||
type customSchemaListRow = struct {
|
||||
// CanDelete True when documentCount is zero.
|
||||
CanDelete bool `json:"canDelete"`
|
||||
|
||||
// ClientId The client that owns this schema.
|
||||
ClientId string `json:"clientId"`
|
||||
|
||||
// CreatedAt Timestamp when this schema row was created.
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
// CreatedBy Cognito subject ID (JWT sub claim) of the caller. Opaque UUID, not an email.
|
||||
CreatedBy string `json:"createdBy"`
|
||||
|
||||
// Description Optional human-readable description of the schema.
|
||||
Description *string `json:"description,omitempty"`
|
||||
|
||||
// DocumentCount Number of documents referencing this schema version.
|
||||
DocumentCount int32 `json:"documentCount"`
|
||||
|
||||
// Id The schema row ID.
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
|
||||
// Name The schema name (shared across versions in a lineage).
|
||||
Name string `json:"name"`
|
||||
|
||||
// Status Lifecycle state of a client_metadata_schemas row. `active` is
|
||||
// the version currently assignable to new documents. `superseded`
|
||||
// is automatically set when a newer version of the same (clientId,
|
||||
// name) lineage is created via POST /versions. `retired` is set
|
||||
// when a super_admin explicitly deletes an unused schema.
|
||||
Status SchemaStatus `json:"status"`
|
||||
|
||||
// Version Monotonic version number within the lineage.
|
||||
Version int32 `json:"version"`
|
||||
}
|
||||
|
||||
// CreateCustomSchema is POST /super-admin/custom-schemas.
|
||||
//
|
||||
// Body: CustomSchemaRequest. Actor: JWT subject. Returns 201 with the full
|
||||
// CustomSchemaResponse on success.
|
||||
func (s *Controllers) CreateCustomSchema(ctx echo.Context) error {
|
||||
actor, ok := cognitoauth.GetUserSubject(ctx)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
||||
}
|
||||
|
||||
var req CustomSchemaRequest
|
||||
if err := ctx.Bind(&req); err != nil {
|
||||
slog.Error("failed to parse custom schema request", "error", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
schemaBytes, err := json.Marshal(req.Schema)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid schema JSON")
|
||||
}
|
||||
|
||||
description := ""
|
||||
if req.Description != nil {
|
||||
description = *req.Description
|
||||
}
|
||||
|
||||
input := customschema.CreateSchemaInput{
|
||||
ClientID: req.ClientId,
|
||||
Name: req.Name,
|
||||
Description: description,
|
||||
SchemaDef: schemaBytes,
|
||||
}
|
||||
|
||||
created, err := s.svc.CustomSchema.CreateSchema(ctx.Request().Context(), input, actor)
|
||||
if err != nil {
|
||||
return mapCustomSchemaError(err)
|
||||
}
|
||||
|
||||
resp, err := schemaToResponse(created, 0)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode schema response")
|
||||
}
|
||||
return ctx.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// ListCustomSchemas is GET /super-admin/custom-schemas. The required
|
||||
// clientId query parameter is enforced by the generated wrapper; the
|
||||
// handler still guards for an empty string to fail fast with a clear 400.
|
||||
func (s *Controllers) ListCustomSchemas(ctx echo.Context, params ListCustomSchemasParams) error {
|
||||
if _, ok := cognitoauth.GetUserSubject(ctx); !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(params.ClientId) == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "clientId is required")
|
||||
}
|
||||
|
||||
filters := customschema.ListFilters{
|
||||
Limit: 50,
|
||||
Offset: 0,
|
||||
}
|
||||
if params.Name != nil {
|
||||
filters.Name = *params.Name
|
||||
}
|
||||
if params.IncludeAllVersions != nil {
|
||||
filters.IncludeAllVersions = *params.IncludeAllVersions
|
||||
}
|
||||
if params.Limit != nil {
|
||||
if *params.Limit < 1 || *params.Limit > 200 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "limit must be between 1 and 200")
|
||||
}
|
||||
filters.Limit = *params.Limit
|
||||
}
|
||||
if params.Offset != nil {
|
||||
if *params.Offset < 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "offset must be >= 0")
|
||||
}
|
||||
filters.Offset = *params.Offset
|
||||
}
|
||||
if params.Status != nil {
|
||||
if *params.Status == ListCustomSchemasParamsStatusAny {
|
||||
filters.StatusAny = true
|
||||
} else {
|
||||
filters.Status = string(*params.Status)
|
||||
}
|
||||
}
|
||||
|
||||
summaries, err := s.svc.CustomSchema.ListSchemas(ctx.Request().Context(), params.ClientId, filters)
|
||||
if err != nil {
|
||||
return mapCustomSchemaError(err)
|
||||
}
|
||||
|
||||
resp := CustomSchemaListResponse{
|
||||
Schemas: make([]customSchemaListRow, 0, len(summaries)),
|
||||
}
|
||||
for _, sum := range summaries {
|
||||
resp.Schemas = append(resp.Schemas, summaryToListRow(sum))
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetCustomSchema is GET /super-admin/custom-schemas/{schemaId}.
|
||||
func (s *Controllers) GetCustomSchema(ctx echo.Context, schemaId openapi_types.UUID) error {
|
||||
if _, ok := cognitoauth.GetUserSubject(ctx); !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
||||
}
|
||||
|
||||
summary, err := s.svc.CustomSchema.GetSchema(ctx.Request().Context(), uuid.UUID(schemaId))
|
||||
if err != nil {
|
||||
return mapCustomSchemaError(err)
|
||||
}
|
||||
|
||||
resp, err := schemaToResponse(&summary.Schema, summary.DocumentCount)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode schema response")
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// CreateCustomSchemaVersion is POST /super-admin/custom-schemas/{schemaId}/versions.
|
||||
func (s *Controllers) CreateCustomSchemaVersion(ctx echo.Context, schemaId openapi_types.UUID) error {
|
||||
actor, ok := cognitoauth.GetUserSubject(ctx)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
||||
}
|
||||
|
||||
var req CustomSchemaVersionRequest
|
||||
if err := ctx.Bind(&req); err != nil {
|
||||
slog.Error("failed to parse custom schema version request", "error", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
schemaBytes, err := json.Marshal(req.Schema)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid schema JSON")
|
||||
}
|
||||
|
||||
description := ""
|
||||
if req.Description != nil {
|
||||
description = *req.Description
|
||||
}
|
||||
|
||||
input := customschema.CreateSchemaVersionInput{
|
||||
Description: description,
|
||||
SchemaDef: schemaBytes,
|
||||
}
|
||||
|
||||
created, err := s.svc.CustomSchema.CreateSchemaVersion(ctx.Request().Context(), uuid.UUID(schemaId), input, actor)
|
||||
if err != nil {
|
||||
return mapCustomSchemaError(err)
|
||||
}
|
||||
|
||||
resp, err := schemaToResponse(created, 0)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode schema response")
|
||||
}
|
||||
return ctx.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// DeleteCustomSchema is DELETE /super-admin/custom-schemas/{schemaId}.
|
||||
func (s *Controllers) DeleteCustomSchema(ctx echo.Context, schemaId openapi_types.UUID) error {
|
||||
actor, ok := cognitoauth.GetUserSubject(ctx)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
||||
}
|
||||
|
||||
if err := s.svc.CustomSchema.DeleteSchema(ctx.Request().Context(), uuid.UUID(schemaId), actor); err != nil {
|
||||
return mapCustomSchemaError(err)
|
||||
}
|
||||
return ctx.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// mapCustomSchemaError translates a customschema.Service error into an
|
||||
// echo.HTTPError with the correct status code and message. Sentinel checks
|
||||
// run first; anything that looks like a validator failure maps to 400;
|
||||
// everything else is a 500.
|
||||
func mapCustomSchemaError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, customschema.ErrSchemaNotFound):
|
||||
return echo.NewHTTPError(http.StatusNotFound, "schema not found")
|
||||
case errors.Is(err, customschema.ErrSchemaInUse):
|
||||
return echo.NewHTTPError(http.StatusConflict, "schema is in use by one or more documents and cannot be deleted")
|
||||
case errors.Is(err, customschema.ErrSchemaNameConflict):
|
||||
return echo.NewHTTPError(http.StatusConflict, "schema with this duplicate name already exists for client")
|
||||
case errors.Is(err, customschema.ErrSchemaParentRetired):
|
||||
return echo.NewHTTPError(http.StatusConflict, "parent schema is retired; cannot create a new version from a retired parent")
|
||||
case errors.Is(err, customschema.ErrSchemaParentNotActive):
|
||||
return echo.NewHTTPError(http.StatusConflict, "parent schema is not the active version in its lineage")
|
||||
}
|
||||
if strings.Contains(err.Error(), validatorErrorMarker) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
slog.Error("custom schema handler internal error", "error", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
|
||||
}
|
||||
|
||||
// schemaToResponse copies a customschema.Schema into the generated
|
||||
// CustomSchemaResponse. documentCount is the live document binding count;
|
||||
// CanDelete is derived from it (true iff count == 0). The model's SchemaDef
|
||||
// is json.RawMessage and the generated type expects map[string]interface{},
|
||||
// so we round-trip through encoding/json to reshape without losing structure.
|
||||
func schemaToResponse(schema *customschema.Schema, documentCount int64) (CustomSchemaResponse, error) {
|
||||
var schemaMap map[string]interface{}
|
||||
if err := json.Unmarshal(schema.SchemaDef, &schemaMap); err != nil {
|
||||
return CustomSchemaResponse{}, err
|
||||
}
|
||||
var descriptionPtr *string
|
||||
if schema.Description != "" {
|
||||
d := schema.Description
|
||||
descriptionPtr = &d
|
||||
}
|
||||
return CustomSchemaResponse{
|
||||
Id: openapi_types.UUID(schema.ID),
|
||||
ClientId: schema.ClientID,
|
||||
Name: schema.Name,
|
||||
Description: descriptionPtr,
|
||||
Schema: schemaMap,
|
||||
Version: int32(schema.Version), //nolint:gosec // schema.Version is the per-lineage monotonic counter; an int32 ceiling of ~2 billion is trivially safe for schema versions
|
||||
Status: SchemaStatus(schema.Status),
|
||||
CreatedAt: schema.CreatedAt,
|
||||
CreatedBy: schema.CreatedBy,
|
||||
DocumentCount: int32(documentCount), //nolint:gosec // document count per schema is bounded by total documents; int32 max (~2B) is safe
|
||||
CanDelete: documentCount == 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// summaryToListRow translates a customschema.SchemaSummary into the anonymous
|
||||
// row struct declared inside CustomSchemaListResponse. The alias defined
|
||||
// above preserves structural identity with the generated type.
|
||||
func summaryToListRow(summary customschema.SchemaSummary) customSchemaListRow {
|
||||
var descriptionPtr *string
|
||||
if summary.Schema.Description != "" {
|
||||
d := summary.Schema.Description
|
||||
descriptionPtr = &d
|
||||
}
|
||||
return customSchemaListRow{
|
||||
CanDelete: summary.CanDelete,
|
||||
ClientId: summary.Schema.ClientID,
|
||||
CreatedAt: summary.Schema.CreatedAt,
|
||||
CreatedBy: summary.Schema.CreatedBy,
|
||||
Description: descriptionPtr,
|
||||
DocumentCount: int32(summary.DocumentCount), //nolint:gosec // documentCount is COUNT(*) of documents bound to this schema; an int32 ceiling of ~2 billion is far above any realistic per-client document count
|
||||
Id: openapi_types.UUID(summary.Schema.ID),
|
||||
Name: summary.Schema.Name,
|
||||
Status: SchemaStatus(summary.Schema.Status),
|
||||
Version: int32(summary.Schema.Version), //nolint:gosec // see schemaToResponse; per-lineage monotonic version counter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package queryapi: Milestone 4 (bulk assign) pre-partitioning stub.
|
||||
// This file is intentionally empty so Milestone 4 owns a file disjoint
|
||||
// from customschemas.go. See tracking §2.9.
|
||||
package queryapi
|
||||
@@ -0,0 +1,709 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package queryapi: Milestone 3 handler for
|
||||
// POST /super-admin/documents/{id}/reset-metadata.
|
||||
//
|
||||
// This file is the M3-owned partition (see tracking §2.9). It must not
|
||||
// introduce new types into models.go or customschemas.go.
|
||||
package queryapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"queryorchestration/internal/cognitoauth"
|
||||
"queryorchestration/internal/customschema"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
// ResetDocumentMetadata is POST /super-admin/documents/{id}/reset-metadata.
|
||||
// It wipes the document's custom metadata history and clears the schema
|
||||
// binding in a single atomic transaction. The operation is idempotent;
|
||||
// already-clean documents return 200 with metadataVersionsDeleted: 0.
|
||||
// Restricted to super_admin (enforced by Permit.io middleware upstream).
|
||||
func (s *Controllers) ResetDocumentMetadata(ctx echo.Context, id openapi_types.UUID) error {
|
||||
actor, ok := cognitoauth.GetUserSubject(ctx)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
||||
}
|
||||
|
||||
result, err := s.svc.CustomSchema.ResetDocumentMetadata(ctx.Request().Context(), uuid.UUID(id), actor)
|
||||
if err != nil {
|
||||
return mapResetError(err)
|
||||
}
|
||||
|
||||
resp := DocumentMetadataResetResponse{
|
||||
DocumentId: openapi_types.UUID(result.DocumentID),
|
||||
MetadataVersionsDeleted: int32(result.MetadataVersionsDeleted), //nolint:gosec // bounded by row count
|
||||
ResetAt: result.ResetAt,
|
||||
ResetBy: result.ResetBy,
|
||||
}
|
||||
if result.PreviousSchemaID != nil {
|
||||
resp.PreviousSchemaId = nullable.NewNullableWithValue(openapi_types.UUID(*result.PreviousSchemaID))
|
||||
} else {
|
||||
resp.PreviousSchemaId = nullable.NewNullNullable[openapi_types.UUID]()
|
||||
}
|
||||
if result.PreviousSchemaName != nil {
|
||||
resp.PreviousSchemaName = nullable.NewNullableWithValue(*result.PreviousSchemaName)
|
||||
} else {
|
||||
resp.PreviousSchemaName = nullable.NewNullNullable[string]()
|
||||
}
|
||||
if result.PreviousSchemaVersion != nil {
|
||||
resp.PreviousSchemaVersion = nullable.NewNullableWithValue(int32(*result.PreviousSchemaVersion)) //nolint:gosec // schema version is a small counter
|
||||
} else {
|
||||
resp.PreviousSchemaVersion = nullable.NewNullNullable[int32]()
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// mapResetError converts reset-specific service sentinels to HTTP errors.
|
||||
// Errors not recognized here are treated as unexpected server failures.
|
||||
func mapResetError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, customschema.ErrDocumentNotFound):
|
||||
return echo.NewHTTPError(http.StatusNotFound, "document not found")
|
||||
case errors.Is(err, customschema.ErrMutualExclusivityViolation):
|
||||
return echo.NewHTTPError(http.StatusConflict, err.Error())
|
||||
default:
|
||||
slog.Error("reset document metadata: unexpected error", "error", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
// Package queryapi_test — Milestone 3 §3.3 and §3.4 integration tests
|
||||
// for POST /super-admin/documents/{id}/reset-metadata.
|
||||
//
|
||||
// These tests exercise the ResetDocumentMetadata handler end-to-end against
|
||||
// the real Postgres testcontainer, covering all observable status codes:
|
||||
// 401 (missing subject), 200 (happy path with prior schema), 200 (idempotent
|
||||
// clean doc), 404 (no such document), 409 (legacy field extractions present).
|
||||
package queryapi_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
queryapi "queryorchestration/api/queryAPI"
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/fieldextraction"
|
||||
"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"
|
||||
)
|
||||
|
||||
// setupM3Fixture boots a fresh DB, wipes rows belonging to clientID, creates
|
||||
// the client, and returns controllers + cfg. Mirrors setupM2Fixture from the
|
||||
// Milestone 2 integration tests.
|
||||
func setupM3Fixture(t *testing.T, clientID string) (*queryapi.Controllers, *ControllerConfig) {
|
||||
t.Helper()
|
||||
cfg := &ControllerConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
initializeTestConfig(t, cfg)
|
||||
svc := createControllerServices(cfg)
|
||||
cons := queryapi.NewControllers(svc, cfg)
|
||||
|
||||
resetClientForSchemaTests(t, cfg, clientID)
|
||||
test.CreateTestClient(t, cfg, clientID, "M3 integration "+clientID)
|
||||
return cons, cfg
|
||||
}
|
||||
|
||||
// callReset dispatches POST /super-admin/documents/{id}/reset-metadata via the
|
||||
// handler using the standard test actor subject. Returns the handler error and
|
||||
// the response recorder so callers can inspect both status code and body.
|
||||
func callReset(t *testing.T, cons *queryapi.Controllers, docID uuid.UUID) (error, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
ctx, rec := newCustomSchemaContext(t, http.MethodPost, nil)
|
||||
err := cons.ResetDocumentMetadata(ctx, openapi_types.UUID(docID))
|
||||
return err, rec
|
||||
}
|
||||
|
||||
// TestResetDocumentMetadata_MissingUserSubject verifies the 401 branch: when
|
||||
// the echo context carries no user_claims the handler short-circuits before
|
||||
// calling the service.
|
||||
func TestResetDocumentMetadata_MissingUserSubject(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)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := e.NewContext(req, rec)
|
||||
// intentionally no ctx.Set("user_claims", ...) — simulates unauthenticated call
|
||||
|
||||
err := cons.ResetDocumentMetadata(ctx, openapi_types.UUID(uuid.New()))
|
||||
requireHTTPStatus(t, err, rec, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// TestM3_ResetDocumentMetadata_DocumentNotFound exercises the 404 branch: when
|
||||
// the document UUID does not exist the service returns ErrDocumentNotFound which
|
||||
// mapResetError converts to 404.
|
||||
func TestM3_ResetDocumentMetadata_DocumentNotFound(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M3_RST_NOTFOUND"
|
||||
cons, _ := setupM3Fixture(t, clientID)
|
||||
|
||||
err, rec := callReset(t, cons, uuid.New())
|
||||
requireHTTPStatus(t, err, rec, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// TestM3_ResetDocumentMetadata_LegacyExtractionsConflict exercises the 409
|
||||
// branch: a document carrying legacy field extractions returns
|
||||
// ErrMutualExclusivityViolation which mapResetError converts to 409.
|
||||
func TestM3_ResetDocumentMetadata_LegacyExtractionsConflict(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M3_RST_LEGACY"
|
||||
cons, cfg := setupM3Fixture(t, clientID)
|
||||
|
||||
docID := seedDoc(t, cfg, clientID, "m3-reset-legacy-doc")
|
||||
|
||||
svc := createControllerServices(cfg)
|
||||
_, err := svc.FieldExtraction.CreateFieldExtraction(t.Context(), &fieldextraction.CreateFieldExtractionInput{
|
||||
DocumentID: docID,
|
||||
SingleFields: &repository.AddFieldExtractionParams{
|
||||
Documentid: docID,
|
||||
Createdby: "tester",
|
||||
},
|
||||
ArrayFields: nil,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resetErr, rec := callReset(t, cons, docID)
|
||||
requireHTTPStatus(t, resetErr, rec, http.StatusConflict)
|
||||
}
|
||||
|
||||
// TestM3_ResetDocumentMetadata_HappyPath exercises the 200 success path: a
|
||||
// document with a bound schema and two metadata versions is reset. The
|
||||
// response must carry the previous schema fields, metadataVersionsDeleted == 2,
|
||||
// and the DB must show zero metadata rows and custom_schema_id == NULL.
|
||||
func TestM3_ResetDocumentMetadata_HappyPath(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M3_RST_HAPPY"
|
||||
cons, cfg := setupM3Fixture(t, clientID)
|
||||
|
||||
schema := seedCustomSchema(t, cons, clientID, "m3-reset-happy-schema")
|
||||
docID := seedDoc(t, cfg, clientID, "m3-reset-happy-doc")
|
||||
assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id))
|
||||
|
||||
// Write two metadata versions so the deleted count is verifiable.
|
||||
for range 2 {
|
||||
metaBody := queryapi.CustomMetadataRequest{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Metadata: map[string]interface{}{"name": "v"},
|
||||
}
|
||||
ctx, rec := newCustomMetadataContext(t, http.MethodPost, metaBody)
|
||||
require.NoError(t, cons.SetCustomMetadata(ctx))
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
}
|
||||
|
||||
// Call reset.
|
||||
resetErr, rec := callReset(t, cons, docID)
|
||||
requireHTTPStatus(t, resetErr, rec, http.StatusOK)
|
||||
|
||||
var resp queryapi.DocumentMetadataResetResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.Equal(t, openapi_types.UUID(docID), resp.DocumentId)
|
||||
assert.Equal(t, int32(2), resp.MetadataVersionsDeleted)
|
||||
assert.Equal(t, testActorSubject, resp.ResetBy)
|
||||
assert.False(t, resp.ResetAt.IsZero(), "resetAt must be populated")
|
||||
|
||||
// Previous schema fields must reflect the just-cleared binding.
|
||||
prevID, err := resp.PreviousSchemaId.Get()
|
||||
require.NoError(t, err, "previousSchemaId must be non-null after a bound reset")
|
||||
assert.Equal(t, schema.Id, prevID)
|
||||
|
||||
prevName, err := resp.PreviousSchemaName.Get()
|
||||
require.NoError(t, err, "previousSchemaName must be non-null after a bound reset")
|
||||
assert.Equal(t, "m3-reset-happy-schema", prevName)
|
||||
|
||||
_, err = resp.PreviousSchemaVersion.Get()
|
||||
assert.NoError(t, err, "previousSchemaVersion must be non-null after a bound reset")
|
||||
|
||||
// DB state: all metadata rows deleted, custom_schema_id cleared.
|
||||
var metaCount int
|
||||
err = cfg.GetDBPool().QueryRow(t.Context(),
|
||||
`SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, docID).
|
||||
Scan(&metaCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, metaCount, "reset must delete all metadata rows")
|
||||
|
||||
var customSchemaID *uuid.UUID
|
||||
err = cfg.GetDBPool().QueryRow(t.Context(),
|
||||
`SELECT custom_schema_id FROM documents WHERE id = $1`, docID).
|
||||
Scan(&customSchemaID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, customSchemaID, "reset must set custom_schema_id to NULL")
|
||||
}
|
||||
|
||||
// TestM3_ResetDocumentMetadata_IdempotentOnCleanDoc exercises the idempotent
|
||||
// path: resetting an already-clean document (no schema bound, no metadata)
|
||||
// returns 200 with metadataVersionsDeleted == 0 and null previous schema fields.
|
||||
func TestM3_ResetDocumentMetadata_IdempotentOnCleanDoc(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M3_RST_IDEM"
|
||||
cons, cfg := setupM3Fixture(t, clientID)
|
||||
|
||||
docID := seedDoc(t, cfg, clientID, "m3-reset-idem-doc")
|
||||
|
||||
resetErr, rec := callReset(t, cons, docID)
|
||||
requireHTTPStatus(t, resetErr, rec, http.StatusOK)
|
||||
|
||||
var resp queryapi.DocumentMetadataResetResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.Equal(t, int32(0), resp.MetadataVersionsDeleted)
|
||||
|
||||
_, err := resp.PreviousSchemaId.Get()
|
||||
assert.Error(t, err, "clean doc must report null previousSchemaId")
|
||||
|
||||
_, err = resp.PreviousSchemaName.Get()
|
||||
assert.Error(t, err, "clean doc must report null previousSchemaName")
|
||||
}
|
||||
|
||||
// TestM3_FullUpgradeFlow exercises the complete schema upgrade story:
|
||||
//
|
||||
// 1. Create schema v1
|
||||
// 2. Bind document to v1
|
||||
// 3. POST metadata against v1
|
||||
// 4. Create schema v2 from v1 (POST .../versions with an added optional field)
|
||||
// 5. Reset-metadata (must return 200, metadataVersionsDeleted=1, previousSchemaId=v1.id)
|
||||
// 6. Assign v2 to the document
|
||||
// 7. POST metadata with v2 shape (include the new optional field)
|
||||
// 8. Assert final DB state: exactly 1 metadata row, doc's custom_schema_id = v2.id
|
||||
func TestM3_FullUpgradeFlow(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M3_UPGRADE"
|
||||
cons, cfg := setupM3Fixture(t, clientID)
|
||||
|
||||
// Step 1: Create schema v1 via the handler.
|
||||
v1 := seedCustomSchema(t, cons, clientID, "m3-upgrade-lineage")
|
||||
|
||||
// Step 2: Create a document and bind it to v1.
|
||||
docID := seedDoc(t, cfg, clientID, "m3-upgrade-doc")
|
||||
assignSchemaForTest(t, cons, docID, uuid.UUID(v1.Id))
|
||||
|
||||
// Step 3: POST metadata against the v1 binding.
|
||||
metaV1Body := queryapi.CustomMetadataRequest{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Metadata: map[string]interface{}{"name": "original-contract"},
|
||||
}
|
||||
ctxMeta1, recMeta1 := newCustomMetadataContext(t, http.MethodPost, metaV1Body)
|
||||
require.NoError(t, cons.SetCustomMetadata(ctxMeta1))
|
||||
require.Equal(t, http.StatusCreated, recMeta1.Code)
|
||||
|
||||
// Step 4: Create v2 from v1 — the schema definition adds an optional "notes" field.
|
||||
v2SchemaDef := map[string]interface{}{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
"notes": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
v2Body := queryapi.CustomSchemaVersionRequest{Schema: v2SchemaDef}
|
||||
ctxV2, recV2 := newCustomSchemaContext(t, http.MethodPost, v2Body)
|
||||
require.NoError(t, cons.CreateCustomSchemaVersion(ctxV2, v1.Id))
|
||||
require.Equal(t, http.StatusCreated, recV2.Code)
|
||||
|
||||
var v2 queryapi.CustomSchemaResponse
|
||||
require.NoError(t, json.Unmarshal(recV2.Body.Bytes(), &v2))
|
||||
require.Equal(t, int32(2), v2.Version)
|
||||
require.Equal(t, queryapi.SchemaStatusActive, v2.Status)
|
||||
|
||||
// Step 5: Reset-metadata — must wipe the v1 metadata and clear the schema binding.
|
||||
resetErr, recReset := callReset(t, cons, docID)
|
||||
requireHTTPStatus(t, resetErr, recReset, http.StatusOK)
|
||||
|
||||
var resetResp queryapi.DocumentMetadataResetResponse
|
||||
require.NoError(t, json.Unmarshal(recReset.Body.Bytes(), &resetResp))
|
||||
assert.Equal(t, int32(1), resetResp.MetadataVersionsDeleted,
|
||||
"reset must report exactly 1 metadata version deleted")
|
||||
|
||||
prevID, err := resetResp.PreviousSchemaId.Get()
|
||||
require.NoError(t, err, "previousSchemaId must be non-null")
|
||||
assert.Equal(t, v1.Id, prevID, "previousSchemaId must be the v1 schema")
|
||||
|
||||
// Step 6: Assign v2 to the document.
|
||||
assignSchemaForTest(t, cons, docID, uuid.UUID(v2.Id))
|
||||
|
||||
// Step 7: POST metadata with v2 shape (includes the new optional "notes" field).
|
||||
metaV2Body := queryapi.CustomMetadataRequest{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Metadata: map[string]interface{}{"name": "upgraded-contract", "notes": "added in v2"},
|
||||
}
|
||||
ctxMeta2, recMeta2 := newCustomMetadataContext(t, http.MethodPost, metaV2Body)
|
||||
require.NoError(t, cons.SetCustomMetadata(ctxMeta2))
|
||||
require.Equal(t, http.StatusCreated, recMeta2.Code)
|
||||
|
||||
// Step 8: Assert final DB state.
|
||||
// 8a: Exactly 1 metadata row for this document (the v2 write; v1 was deleted by reset).
|
||||
var metaCount int
|
||||
err = cfg.GetDBPool().QueryRow(t.Context(),
|
||||
`SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, docID).
|
||||
Scan(&metaCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, metaCount, "after upgrade there must be exactly 1 metadata row (the v2 write)")
|
||||
|
||||
// 8b: Document's custom_schema_id must be v2's ID.
|
||||
var boundSchemaID *uuid.UUID
|
||||
err = cfg.GetDBPool().QueryRow(t.Context(),
|
||||
`SELECT custom_schema_id FROM documents WHERE id = $1`, docID).
|
||||
Scan(&boundSchemaID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, boundSchemaID, "document must have a schema binding after reassignment")
|
||||
assert.Equal(t, uuid.UUID(v2.Id), *boundSchemaID, "document must be bound to v2")
|
||||
}
|
||||
|
||||
// TestM3_AuthMatrix_Reset exercises the authorization matrix for
|
||||
// POST /super-admin/documents/{id}/reset-metadata:
|
||||
//
|
||||
// super_admin -> 200
|
||||
// user_admin -> 403
|
||||
// client_user -> 403
|
||||
// auditor -> 403
|
||||
//
|
||||
// For denied roles: assert the DB has zero metadata rows changed (nothing
|
||||
// was written or deleted).
|
||||
// For the allowed role (super_admin): need a document with a schema binding
|
||||
// and at least 1 metadata row, call reset, assert 200.
|
||||
func TestM3_AuthMatrix_Reset(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
const clientID = "M3_AUTHZ_RST"
|
||||
cons, cfg := setupM3Fixture(t, clientID)
|
||||
|
||||
// Seed a document with a schema + metadata so the super_admin positive
|
||||
// case has something to reset and the denial cases can verify the DB
|
||||
// is unchanged.
|
||||
schema := seedCustomSchema(t, cons, clientID, "m3-authz-reset-schema")
|
||||
docID := seedDoc(t, cfg, clientID, "m3-authz-reset-doc")
|
||||
assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id))
|
||||
|
||||
metaBody := queryapi.CustomMetadataRequest{
|
||||
DocumentId: openapi_types.UUID(docID),
|
||||
Metadata: map[string]interface{}{"name": "authz-test"},
|
||||
}
|
||||
ctxMeta, recMeta := newCustomMetadataContext(t, http.MethodPost, metaBody)
|
||||
require.NoError(t, cons.SetCustomMetadata(ctxMeta))
|
||||
require.Equal(t, http.StatusCreated, recMeta.Code)
|
||||
|
||||
resetPath := "/super-admin/documents/" + docID.String() + "/reset-metadata"
|
||||
|
||||
rolesUnderTest := []struct {
|
||||
role string
|
||||
expectAllow bool
|
||||
}{
|
||||
{role: "super_admin", expectAllow: true},
|
||||
{role: "user_admin", expectAllow: false},
|
||||
{role: "client_user", expectAllow: false},
|
||||
{role: "auditor", expectAllow: false},
|
||||
}
|
||||
|
||||
countMeta := func(t *testing.T) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
require.NoError(t,
|
||||
cfg.GetDBPool().QueryRow(t.Context(),
|
||||
`SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`,
|
||||
docID).Scan(&n))
|
||||
return n
|
||||
}
|
||||
|
||||
for _, rt := range rolesUnderTest {
|
||||
rt := rt
|
||||
t.Run(rt.role, func(t *testing.T) {
|
||||
beforeCount := countMeta(t)
|
||||
|
||||
// Build the echo context for the reset handler.
|
||||
ctx, rec := newCustomSchemaContext(t, http.MethodPost, nil)
|
||||
ctx.Request().URL.Path = resetPath
|
||||
|
||||
handlerCalled := false
|
||||
wrappedHandler := func(ec echo.Context) error {
|
||||
handlerCalled = true
|
||||
return cons.ResetDocumentMetadata(ec, openapi_types.UUID(docID))
|
||||
}
|
||||
checker := &roleAwarePermitChecker{role: rt.role}
|
||||
|
||||
err := runPermitGate(ctx, resetPath, checker, wrappedHandler)
|
||||
|
||||
if rt.expectAllow {
|
||||
require.NoError(t, err, "super_admin must be allowed")
|
||||
assert.True(t, handlerCalled, "handler must be called for super_admin")
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "reset must return 200 for super_admin")
|
||||
} else {
|
||||
assert.False(t, handlerCalled,
|
||||
"handler must NOT be called for role %s", rt.role)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code,
|
||||
"reset must return 403 for role %s", rt.role)
|
||||
|
||||
// DB unchanged: no metadata rows were deleted.
|
||||
afterCount := countMeta(t)
|
||||
assert.Equal(t, beforeCount, afterCount,
|
||||
"denied request must leave metadata row count unchanged for role %s", rt.role)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
package queryapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
queryapi "queryorchestration/api/queryAPI"
|
||||
"queryorchestration/internal/cognitoauth"
|
||||
"queryorchestration/internal/customschema"
|
||||
"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"
|
||||
)
|
||||
|
||||
// testActorSubject is the fake JWT subject the custom schema handler tests
|
||||
// inject into the echo context via ctx.Set("user_claims", ...). The value
|
||||
// is an opaque UUID to match the plan's "createdBy is the Cognito subject,
|
||||
// not an email" rule.
|
||||
const testActorSubject = "b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d"
|
||||
|
||||
// trivialValidSchemaJSON is a minimal JSON Schema document that passes every
|
||||
// rule in customschema.SchemaValidator: draft 2020-12, root type=object,
|
||||
// additionalProperties explicitly declared.
|
||||
const trivialValidSchemaJSON = `{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"additionalProperties": false
|
||||
}`
|
||||
|
||||
// newCustomSchemaContext creates an echo context with the given method,
|
||||
// optional JSON body, and the standard test subject injected as
|
||||
// user_claims["sub"]. It is the equivalent of the eula test harness's
|
||||
// ctx.Set("user_claims", ...) pattern — there is no package-level
|
||||
// SetUserSubject helper, so we mirror the same approach used by
|
||||
// eulaHandlers_test.go.
|
||||
func newCustomSchemaContext(t testing.TB, method string, body interface{}) (echo.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
e := echo.New()
|
||||
|
||||
var reqBody []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
reqBody, err = json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(method, "/", bytes.NewReader(reqBody))
|
||||
if reqBody != nil {
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := e.NewContext(req, rec)
|
||||
ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject})
|
||||
ctx.Set("user_info", cognitoauth.UserInfo{Username: "admin"})
|
||||
return ctx, rec
|
||||
}
|
||||
|
||||
// newCustomSchemaContextRaw is identical to newCustomSchemaContext but
|
||||
// accepts a pre-marshaled raw body so tests can send arbitrary JSON with
|
||||
// extra fields (e.g., the injected createdBy attack vector).
|
||||
func newCustomSchemaContextRaw(t testing.TB, method string, rawBody []byte) (echo.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(method, "/", bytes.NewReader(rawBody))
|
||||
if rawBody != nil {
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := e.NewContext(req, rec)
|
||||
ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject})
|
||||
ctx.Set("user_info", cognitoauth.UserInfo{Username: "admin"})
|
||||
return ctx, rec
|
||||
}
|
||||
|
||||
// resetClientForSchemaTests cascades a client and every FK-bound child
|
||||
// row out of the database so reruns of the same test start from a clean
|
||||
// slate even when the testcontainer Postgres volume is reused across
|
||||
// invocations. The delete order follows the FK graph:
|
||||
// - documents.custom_schema_id must be NULL'd before we drop schemas
|
||||
// - client_metadata_schemas rows must go before documents (audit lineage
|
||||
// is independent, so this ordering is driven by the FKs alone)
|
||||
// - documents before folders (documents.folderId)
|
||||
// - folders before clients (folders.clientid)
|
||||
// - clients last
|
||||
//
|
||||
// We do not use client.Service.HardDelete here because it collects S3
|
||||
// paths and runs a full document-cascade pipeline that is unnecessary for
|
||||
// schema-handler tests — we just need the rows gone.
|
||||
func resetClientForSchemaTests(t testing.TB, cfg *ControllerConfig, clientID string) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
pool := cfg.GetDBPool()
|
||||
|
||||
stmts := []string{
|
||||
// Delete custom metadata rows first. Trigger 1 (migration 130) blocks
|
||||
// UPDATE documents SET custom_schema_id = NULL when metadata rows exist,
|
||||
// so we must remove them before clearing the FK column.
|
||||
`DELETE FROM document_custom_metadata WHERE document_id IN (SELECT id FROM documents WHERE clientId = $1)`,
|
||||
`UPDATE documents SET custom_schema_id = NULL WHERE clientId = $1`,
|
||||
`DELETE FROM client_metadata_schemas WHERE client_id = $1`,
|
||||
`DELETE FROM documentEntries WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`,
|
||||
// documentFieldExtractionVersions has a FK referencing documentFieldExtractions;
|
||||
// delete versions first, then extractions, then documents.
|
||||
`DELETE FROM documentFieldExtractionVersions WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`,
|
||||
`DELETE FROM documentFieldExtractions WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`,
|
||||
`DELETE FROM documents WHERE clientId = $1`,
|
||||
`DELETE FROM folders WHERE clientId = $1`,
|
||||
`DELETE FROM clientCanSync WHERE clientId = $1`,
|
||||
`DELETE FROM clients WHERE clientId = $1`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
_, err := pool.Exec(ctx, stmt, clientID)
|
||||
require.NoErrorf(t, err, "reset stmt failed: %s", stmt)
|
||||
}
|
||||
}
|
||||
|
||||
// mustValidSchema returns a map[string]interface{} that decodes the
|
||||
// trivialValidSchemaJSON bytes, so test request bodies can reuse it without
|
||||
// repeating the literal.
|
||||
func mustValidSchema(t testing.TB) map[string]interface{} {
|
||||
t.Helper()
|
||||
var m map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(trivialValidSchemaJSON), &m))
|
||||
return m
|
||||
}
|
||||
|
||||
// seedCustomSchema creates a fresh schema row through the real handler so
|
||||
// downstream tests get a realistic active v1 to work with. It returns the
|
||||
// parsed CustomSchemaResponse.
|
||||
func seedCustomSchema(t *testing.T, cons *queryapi.Controllers, clientID, name string) queryapi.CustomSchemaResponse {
|
||||
t.Helper()
|
||||
descr := "seeded schema " + name
|
||||
body := queryapi.CustomSchemaRequest{
|
||||
ClientId: clientID,
|
||||
Name: name,
|
||||
Description: &descr,
|
||||
Schema: mustValidSchema(t),
|
||||
}
|
||||
ctx, rec := newCustomSchemaContext(t, http.MethodPost, body)
|
||||
require.NoError(t, cons.CreateCustomSchema(ctx))
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
|
||||
var resp queryapi.CustomSchemaResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
return resp
|
||||
}
|
||||
|
||||
// ---------- CreateCustomSchema ----------
|
||||
|
||||
func TestCreateCustomSchema_Handler(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 = "HAND_CS_C1"
|
||||
resetClientForSchemaTests(t, cfg, clientID)
|
||||
test.CreateTestClient(t, cfg, clientID, "Hand CS Client 1")
|
||||
|
||||
t.Run("happy_path", func(t *testing.T) {
|
||||
descr := "aircraft engineering fields"
|
||||
body := queryapi.CustomSchemaRequest{
|
||||
ClientId: clientID,
|
||||
Name: "aircraft-engineering",
|
||||
Description: &descr,
|
||||
Schema: mustValidSchema(t),
|
||||
}
|
||||
ctx, rec := newCustomSchemaContext(t, http.MethodPost, body)
|
||||
|
||||
require.NoError(t, cons.CreateCustomSchema(ctx))
|
||||
assert.Equal(t, http.StatusCreated, rec.Code)
|
||||
|
||||
var resp queryapi.CustomSchemaResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.Equal(t, clientID, resp.ClientId)
|
||||
assert.Equal(t, "aircraft-engineering", resp.Name)
|
||||
assert.Equal(t, int32(1), resp.Version)
|
||||
assert.Equal(t, queryapi.SchemaStatusActive, resp.Status)
|
||||
assert.Equal(t, testActorSubject, resp.CreatedBy)
|
||||
assert.NotEqual(t, uuid.Nil, uuid.UUID(resp.Id))
|
||||
|
||||
// DB round-trip: the row must exist.
|
||||
row, err := cfg.GetDBQueries().GetClientMetadataSchema(t.Context(), uuid.UUID(resp.Id))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, clientID, row.ClientID)
|
||||
assert.Equal(t, "aircraft-engineering", row.Name)
|
||||
assert.Equal(t, testActorSubject, row.CreatedBy)
|
||||
})
|
||||
|
||||
t.Run("body_created_by_is_ignored", func(t *testing.T) {
|
||||
// Attacker sends a createdBy key in the raw body. The generated
|
||||
// CustomSchemaRequest type has no CreatedBy field, so c.Bind will
|
||||
// silently drop it. The handler must still produce the JWT subject.
|
||||
rawBody := []byte(fmt.Sprintf(`{
|
||||
"clientId": %q,
|
||||
"name": "attacker-created",
|
||||
"description": "ignored",
|
||||
"schema": %s,
|
||||
"createdBy": "attacker-uuid-00000000-0000-0000-0000-000000000000"
|
||||
}`, clientID, trivialValidSchemaJSON))
|
||||
ctx, rec := newCustomSchemaContextRaw(t, http.MethodPost, rawBody)
|
||||
|
||||
require.NoError(t, cons.CreateCustomSchema(ctx))
|
||||
assert.Equal(t, http.StatusCreated, rec.Code)
|
||||
|
||||
var resp queryapi.CustomSchemaResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.Equal(t, testActorSubject, resp.CreatedBy,
|
||||
"createdBy must be the JWT subject, never a body-supplied value")
|
||||
})
|
||||
|
||||
t.Run("invalid_schema_rejected", func(t *testing.T) {
|
||||
// Missing additionalProperties — validator rule 5.
|
||||
invalid := map[string]interface{}{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
}
|
||||
body := queryapi.CustomSchemaRequest{
|
||||
ClientId: clientID,
|
||||
Name: "invalid-schema-case",
|
||||
Schema: invalid,
|
||||
}
|
||||
ctx, rec := newCustomSchemaContext(t, http.MethodPost, body)
|
||||
|
||||
err := cons.CreateCustomSchema(ctx)
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, http.StatusBadRequest, httpErr.Code)
|
||||
_ = rec // unused for error-path assertions
|
||||
})
|
||||
|
||||
t.Run("duplicate_name_conflict", func(t *testing.T) {
|
||||
// Insert one with a specific name, then try again.
|
||||
descr := "first"
|
||||
firstBody := queryapi.CustomSchemaRequest{
|
||||
ClientId: clientID,
|
||||
Name: "duplicate-name-case",
|
||||
Description: &descr,
|
||||
Schema: mustValidSchema(t),
|
||||
}
|
||||
ctx1, rec1 := newCustomSchemaContext(t, http.MethodPost, firstBody)
|
||||
require.NoError(t, cons.CreateCustomSchema(ctx1))
|
||||
require.Equal(t, http.StatusCreated, rec1.Code)
|
||||
|
||||
ctx2, _ := newCustomSchemaContext(t, http.MethodPost, firstBody)
|
||||
err := cons.CreateCustomSchema(ctx2)
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, http.StatusConflict, httpErr.Code)
|
||||
})
|
||||
|
||||
t.Run("oversize_schema_rejected", func(t *testing.T) {
|
||||
// Build a valid schema and pad it past the 64 KB ceiling enforced
|
||||
// by MaxSchemaDefinitionBytes. We stuff a large description into
|
||||
// the properties map so the JSON is still well-formed.
|
||||
bigPad := strings.Repeat("x", customschema.MaxSchemaDefinitionBytes)
|
||||
oversize := map[string]interface{}{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": bigPad,
|
||||
}
|
||||
body := queryapi.CustomSchemaRequest{
|
||||
ClientId: clientID,
|
||||
Name: "oversize-case",
|
||||
Schema: oversize,
|
||||
}
|
||||
ctx, _ := newCustomSchemaContext(t, http.MethodPost, body)
|
||||
err := cons.CreateCustomSchema(ctx)
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, http.StatusBadRequest, httpErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- ListCustomSchemas ----------
|
||||
|
||||
func TestListCustomSchemas_Handler(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 = "HAND_CS_C2"
|
||||
resetClientForSchemaTests(t, cfg, clientID)
|
||||
test.CreateTestClient(t, cfg, clientID, "Hand CS Client 2")
|
||||
|
||||
// Seed three schemas: two independent lineages plus a second version of
|
||||
// one of them, so default-filter results should return 2 rows (active
|
||||
// latest per lineage) and includeAllVersions+status=any should return 3.
|
||||
schemaA := seedCustomSchema(t, cons, clientID, "alpha")
|
||||
_ = seedCustomSchema(t, cons, clientID, "beta")
|
||||
|
||||
// Bump alpha to v2 — supersedes v1.
|
||||
bumpBody := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)}
|
||||
ctxBump, recBump := newCustomSchemaContext(t, http.MethodPost, bumpBody)
|
||||
require.NoError(t, cons.CreateCustomSchemaVersion(ctxBump, schemaA.Id))
|
||||
require.Equal(t, http.StatusCreated, recBump.Code)
|
||||
|
||||
t.Run("default_active_latest_only", func(t *testing.T) {
|
||||
ctx, rec := newCustomSchemaContext(t, http.MethodGet, nil)
|
||||
params := queryapi.ListCustomSchemasParams{ClientId: clientID}
|
||||
|
||||
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, "default filter should return 2 active-latest rows")
|
||||
names := []string{resp.Schemas[0].Name, resp.Schemas[1].Name}
|
||||
assert.Contains(t, names, "alpha")
|
||||
assert.Contains(t, names, "beta")
|
||||
for _, row := range resp.Schemas {
|
||||
assert.Equal(t, queryapi.SchemaStatusActive, row.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status_any_include_all_versions", func(t *testing.T) {
|
||||
ctx, rec := newCustomSchemaContext(t, http.MethodGet, nil)
|
||||
status := queryapi.ListCustomSchemasParamsStatusAny
|
||||
includeAll := true
|
||||
params := queryapi.ListCustomSchemasParams{
|
||||
ClientId: clientID,
|
||||
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, 3,
|
||||
"status=any + includeAllVersions should return alpha v1, alpha v2, and beta v1")
|
||||
})
|
||||
|
||||
t.Run("pagination_limit_one", func(t *testing.T) {
|
||||
ctx, rec := newCustomSchemaContext(t, http.MethodGet, nil)
|
||||
limit := int32(1)
|
||||
offset := int32(0)
|
||||
params := queryapi.ListCustomSchemasParams{
|
||||
ClientId: clientID,
|
||||
Limit: &limit,
|
||||
Offset: &offset,
|
||||
}
|
||||
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)
|
||||
})
|
||||
|
||||
t.Run("limit_zero_rejected", func(t *testing.T) {
|
||||
ctx, _ := newCustomSchemaContext(t, http.MethodGet, nil)
|
||||
limit := int32(0)
|
||||
params := queryapi.ListCustomSchemasParams{ClientId: clientID, Limit: &limit}
|
||||
err := cons.ListCustomSchemas(ctx, params)
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, http.StatusBadRequest, httpErr.Code)
|
||||
})
|
||||
|
||||
t.Run("limit_over_max_rejected", func(t *testing.T) {
|
||||
ctx, _ := newCustomSchemaContext(t, http.MethodGet, nil)
|
||||
limit := int32(201)
|
||||
params := queryapi.ListCustomSchemasParams{ClientId: clientID, Limit: &limit}
|
||||
err := cons.ListCustomSchemas(ctx, params)
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, http.StatusBadRequest, httpErr.Code)
|
||||
})
|
||||
|
||||
t.Run("negative_offset_rejected", func(t *testing.T) {
|
||||
ctx, _ := newCustomSchemaContext(t, http.MethodGet, nil)
|
||||
offset := int32(-1)
|
||||
params := queryapi.ListCustomSchemasParams{ClientId: clientID, Offset: &offset}
|
||||
err := cons.ListCustomSchemas(ctx, params)
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, http.StatusBadRequest, httpErr.Code)
|
||||
})
|
||||
|
||||
t.Run("missing_client_id_rejected", func(t *testing.T) {
|
||||
ctx, _ := newCustomSchemaContext(t, http.MethodGet, nil)
|
||||
params := queryapi.ListCustomSchemasParams{ClientId: ""}
|
||||
err := cons.ListCustomSchemas(ctx, params)
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, http.StatusBadRequest, httpErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- GetCustomSchema ----------
|
||||
|
||||
func TestGetCustomSchema_Handler(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 = "HAND_CS_C3"
|
||||
resetClientForSchemaTests(t, cfg, clientID)
|
||||
test.CreateTestClient(t, cfg, clientID, "Hand CS Client 3")
|
||||
|
||||
seeded := seedCustomSchema(t, cons, clientID, "get-happy")
|
||||
|
||||
t.Run("happy_path", func(t *testing.T) {
|
||||
ctx, rec := newCustomSchemaContext(t, http.MethodGet, nil)
|
||||
require.NoError(t, cons.GetCustomSchema(ctx, seeded.Id))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp queryapi.CustomSchemaResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.Equal(t, seeded.Id, resp.Id)
|
||||
assert.Equal(t, "get-happy", resp.Name)
|
||||
assert.NotNil(t, resp.Schema)
|
||||
// No documents bound — count must be live 0, canDelete must be true.
|
||||
assert.Equal(t, int32(0), resp.DocumentCount)
|
||||
assert.True(t, resp.CanDelete)
|
||||
})
|
||||
|
||||
t.Run("missing_id_returns_404", func(t *testing.T) {
|
||||
missingID := openapi_types.UUID(uuid.New())
|
||||
ctx, _ := newCustomSchemaContext(t, http.MethodGet, nil)
|
||||
err := cons.GetCustomSchema(ctx, missingID)
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, http.StatusNotFound, httpErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- CreateCustomSchemaVersion ----------
|
||||
|
||||
func TestCreateCustomSchemaVersion_Handler(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 = "HAND_CS_C4"
|
||||
resetClientForSchemaTests(t, cfg, clientID)
|
||||
test.CreateTestClient(t, cfg, clientID, "Hand CS Client 4")
|
||||
|
||||
t.Run("happy_path_bumps_to_v2_and_supersedes_parent", func(t *testing.T) {
|
||||
parent := seedCustomSchema(t, cons, clientID, "version-happy")
|
||||
|
||||
body := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)}
|
||||
ctx, rec := newCustomSchemaContext(t, http.MethodPost, body)
|
||||
require.NoError(t, cons.CreateCustomSchemaVersion(ctx, parent.Id))
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
|
||||
var resp queryapi.CustomSchemaResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.Equal(t, int32(2), resp.Version)
|
||||
assert.Equal(t, queryapi.SchemaStatusActive, resp.Status)
|
||||
assert.NotEqual(t, parent.Id, resp.Id, "version bump must produce a new row id")
|
||||
|
||||
// Parent must now be superseded in the DB.
|
||||
parentRow, err := cfg.GetDBQueries().GetClientMetadataSchema(t.Context(), uuid.UUID(parent.Id))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, repository.SchemaStatusTypeSuperseded, parentRow.Status)
|
||||
})
|
||||
|
||||
t.Run("missing_parent_returns_404", func(t *testing.T) {
|
||||
body := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)}
|
||||
ctx, _ := newCustomSchemaContext(t, http.MethodPost, body)
|
||||
err := cons.CreateCustomSchemaVersion(ctx, openapi_types.UUID(uuid.New()))
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, http.StatusNotFound, httpErr.Code)
|
||||
})
|
||||
|
||||
t.Run("non_active_parent_returns_409", func(t *testing.T) {
|
||||
// Seed v1, bump to v2 (making v1 superseded), then try to version
|
||||
// off v1 again.
|
||||
parent := seedCustomSchema(t, cons, clientID, "version-nonactive")
|
||||
|
||||
bumpBody := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)}
|
||||
ctxBump, recBump := newCustomSchemaContext(t, http.MethodPost, bumpBody)
|
||||
require.NoError(t, cons.CreateCustomSchemaVersion(ctxBump, parent.Id))
|
||||
require.Equal(t, http.StatusCreated, recBump.Code)
|
||||
|
||||
ctxTry, _ := newCustomSchemaContext(t, http.MethodPost, bumpBody)
|
||||
err := cons.CreateCustomSchemaVersion(ctxTry, parent.Id)
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, http.StatusConflict, httpErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- DeleteCustomSchema ----------
|
||||
|
||||
func TestDeleteCustomSchema_Handler(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 = "HAND_CS_C5"
|
||||
resetClientForSchemaTests(t, cfg, clientID)
|
||||
test.CreateTestClient(t, cfg, clientID, "Hand CS Client 5")
|
||||
|
||||
t.Run("happy_path_unused_schema", func(t *testing.T) {
|
||||
seeded := seedCustomSchema(t, cons, clientID, "delete-happy")
|
||||
ctx, rec := newCustomSchemaContext(t, http.MethodDelete, nil)
|
||||
require.NoError(t, cons.DeleteCustomSchema(ctx, seeded.Id))
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
// DB: row must now be retired.
|
||||
row, err := cfg.GetDBQueries().GetClientMetadataSchema(t.Context(), uuid.UUID(seeded.Id))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, repository.SchemaStatusTypeRetired, row.Status)
|
||||
})
|
||||
|
||||
t.Run("bound_to_document_returns_409", func(t *testing.T) {
|
||||
seeded := seedCustomSchema(t, cons, clientID, "delete-bound")
|
||||
|
||||
// Bind a document to the seeded schema.
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "delete-bound-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)
|
||||
|
||||
ctx, _ := newCustomSchemaContext(t, http.MethodDelete, nil)
|
||||
err = cons.DeleteCustomSchema(ctx, seeded.Id)
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, http.StatusConflict, httpErr.Code)
|
||||
|
||||
// Unbind so subsequent subtests / reruns don't see leftover FK state.
|
||||
_, err = cfg.GetDBPool().Exec(t.Context(),
|
||||
`UPDATE documents SET custom_schema_id = NULL WHERE id = $1`, docID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("missing_returns_404", func(t *testing.T) {
|
||||
ctx, _ := newCustomSchemaContext(t, http.MethodDelete, nil)
|
||||
err := cons.DeleteCustomSchema(ctx, openapi_types.UUID(uuid.New()))
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, http.StatusNotFound, httpErr.Code)
|
||||
})
|
||||
}
|
||||
@@ -126,6 +126,16 @@ func (s *Controllers) GetDocument(ctx echo.Context, id DocumentID, params GetDoc
|
||||
if document.FileSizeBytes != nil {
|
||||
response.FileSizeBytes = document.FileSizeBytes
|
||||
}
|
||||
// Mutable-metadata feature (Milestone 2.3a/2.7): surface the two
|
||||
// new fields directly from the DocumentEnriched service struct.
|
||||
// No follow-up DB call here — the extended GetDocumentEnriched SQL
|
||||
// query already populated both values in the single round-trip.
|
||||
if document.CustomSchemaID != nil {
|
||||
csid := openapi_types.UUID(*document.CustomSchemaID)
|
||||
response.CustomSchemaId = &csid
|
||||
}
|
||||
hasCustomMetadata := document.HasCustomMetadata
|
||||
response.HasCustomMetadata = &hasCustomMetadata
|
||||
|
||||
// Include text record if requested and available
|
||||
if document.TextRecord != nil {
|
||||
|
||||
@@ -113,12 +113,18 @@ func TestGetDocument(t *testing.T) {
|
||||
err = cons.GetDocument(ctx, docId, queryapi.GetDocumentParams{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
// Milestone 2.3a adds two new fields to DocumentEnriched. They are
|
||||
// populated unconditionally (HasCustomMetadata as a *bool pointing to
|
||||
// false when the document has no metadata; CustomSchemaId stays nil
|
||||
// when no schema is bound).
|
||||
falseVal := false
|
||||
assertBody(t, rec, queryapi.DocumentEnriched{
|
||||
Id: docId,
|
||||
ClientId: "client_id",
|
||||
Hash: "hash",
|
||||
HasTextRecord: false,
|
||||
Labels: []queryapi.LabelRecord{},
|
||||
Id: docId,
|
||||
ClientId: "client_id",
|
||||
Hash: "hash",
|
||||
HasTextRecord: false,
|
||||
Labels: []queryapi.LabelRecord{},
|
||||
HasCustomMetadata: &falseVal,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package queryapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"queryorchestration/internal/fieldextraction"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
@@ -45,6 +47,24 @@ func (s *Controllers) CreateFieldExtraction(ctx echo.Context) error {
|
||||
// Create field extraction via service
|
||||
extraction, err := s.svc.FieldExtraction.CreateFieldExtraction(ctx.Request().Context(), input)
|
||||
if err != nil {
|
||||
// Milestone 2.5: map the new typed sentinels from the service
|
||||
// layer plus the Postgres check_violation (Trigger 2 backstop)
|
||||
// onto clean HTTP status codes before falling through to 500.
|
||||
if errors.Is(err, fieldextraction.ErrDocumentNotFound) {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "document not found")
|
||||
}
|
||||
if errors.Is(err, fieldextraction.ErrMutualExclusivityViolation) {
|
||||
return echo.NewHTTPError(http.StatusConflict,
|
||||
"document is bound to a custom schema and cannot accept legacy field extractions")
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23514" {
|
||||
// Trigger 2 fired. Defense in depth — service-layer guard
|
||||
// already should have caught this, but if the lock path
|
||||
// was bypassed (regression) the DB enforces the same 409.
|
||||
return echo.NewHTTPError(http.StatusConflict,
|
||||
"document is bound to a custom schema and cannot accept legacy field extractions")
|
||||
}
|
||||
slog.Error("failed to create field extraction", "error", err, "document_id", documentID)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to create field extraction")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
clientupdate "queryorchestration/internal/client/update"
|
||||
"queryorchestration/internal/collector"
|
||||
collectorset "queryorchestration/internal/collector/set"
|
||||
"queryorchestration/internal/customschema"
|
||||
"queryorchestration/internal/document"
|
||||
documentbatch "queryorchestration/internal/document/batch"
|
||||
documentdownload "queryorchestration/internal/document/download"
|
||||
@@ -55,6 +56,7 @@ func createControllerServices(cfg *ControllerConfig) *queryapi.Services {
|
||||
lbl := label.New(cfg)
|
||||
eul := eula.New(cfg)
|
||||
uiSvc := uisettings.New(cfg)
|
||||
customSchema := customschema.New(cfg, &customschema.SchemaValidator{}, customschema.NewSlogAuditSink(cfg.GetLogger()))
|
||||
|
||||
return &queryapi.Services{
|
||||
Export: exp,
|
||||
@@ -68,6 +70,7 @@ func createControllerServices(cfg *ControllerConfig) *queryapi.Services {
|
||||
DocumentBatch: docbatch,
|
||||
UISettings: uiSvc,
|
||||
FieldExtraction: fieldext,
|
||||
CustomSchema: customSchema,
|
||||
Folder: fld,
|
||||
Label: lbl,
|
||||
Eula: eul,
|
||||
|
||||
Reference in New Issue
Block a user