Files
Jay Brown 17fc813823 Merged in feature/mutable-metadata1 (pull request #221)
M1, M2 and M3 complete

* M1, M2 and M3 complete

* review changes

* docs

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

596 lines
22 KiB
Go

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