17fc813823
M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
398 lines
15 KiB
Go
398 lines
15 KiB
Go
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|