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