Files
krow_backend/go-api/internal/httpserver/definitions_api_test.go
2026-08-24 13:06:29 +05:30

849 lines
26 KiB
Go

package httpserver_test
import (
"fmt"
"net/http"
"testing"
"time"
)
// Phase 4E — Backend CRUD APIs for authored Agent and Skill definitions.
const validAgentMD = `---
id: test-agent
name: Test Agent
description: An authored agent for testing
status: draft
version: 1
pages:
- candidates
---
## Instructions
Execute testing tasks carefully.
`
const validSkillMD = `---
id: test-skill
name: Test Skill
description: An authored skill for testing
status: active
pages:
- candidates
---
# Test Skill
Skill body instructions.
`
/* ── 1. Agent Create Tests ────────────────────────────────────────────────── */
func TestAgentCreate(t *testing.T) {
r := newRBAC(t)
// 1. Valid personal agent -> 201
res := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": validAgentMD,
"visibility": "personal",
})
if res.code != http.StatusCreated {
t.Fatalf("create personal agent: got status %d (%v)", res.code, res.body)
}
rec := res.record(t)
if rec["definition_id"] != "test-agent" {
t.Errorf("definition_id = %v, want test-agent", rec["definition_id"])
}
if rec["name"] != "Test Agent" {
t.Errorf("name = %v, want Test Agent", rec["name"])
}
if rec["status"] != "draft" {
t.Errorf("status = %v, want draft", rec["status"])
}
if fmt.Sprint(rec["version"]) != "1" {
t.Errorf("version = %v, want 1", rec["version"])
}
if rec["visibility"] != "personal" {
t.Errorf("visibility = %v, want personal", rec["visibility"])
}
// 3. Personal fields derived from authenticated identity
if rec["owner_user_id"] != r.talA.id {
t.Errorf("owner_user_id = %v, want %s", rec["owner_user_id"], r.talA.id)
}
if rec["created_by"] != r.talA.id {
t.Errorf("created_by = %v, want %s", rec["created_by"], r.talA.id)
}
if rec["org_id"] != r.orgID {
t.Errorf("org_id = %v, want %s", rec["org_id"], r.orgID)
}
// 2. Valid organization agent -> 201 (by admin)
orgAgentMD := `---
id: shared-agent
name: Shared Agent
pages:
- candidates
---
## Instructions
Shared instructions.
`
resOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": orgAgentMD,
"visibility": "organization",
})
if resOrg.code != http.StatusCreated {
t.Fatalf("create org agent: got status %d (%v)", resOrg.code, resOrg.body)
}
orgRec := resOrg.record(t)
// 4. Organization fields derived from authenticated identity
if orgRec["visibility"] != "organization" {
t.Errorf("visibility = %v, want organization", orgRec["visibility"])
}
if orgRec["owner_user_id"] != nil {
t.Errorf("owner_user_id = %v, want nil for organization tier", orgRec["owner_user_id"])
}
if orgRec["created_by"] != r.admin.id {
t.Errorf("created_by = %v, want %s", orgRec["created_by"], r.admin.id)
}
// 5, 6, 7. Client-supplied org_id, owner_user_id, created_by cannot override session
manipulatedMD := `---
id: spoof-agent
name: Spoof Agent
pages:
- candidates
---
`
resSpoof := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": manipulatedMD,
"visibility": "personal",
"org_id": r.otherOrgID,
"owner_user_id": r.talB.id,
"created_by": r.admin.id,
})
if resSpoof.code != http.StatusCreated {
t.Fatalf("create spoofed agent: status %d", resSpoof.code)
}
spoofRec := resSpoof.record(t)
if spoofRec["org_id"] != r.orgID {
t.Errorf("org_id spoofed: got %v, want %s", spoofRec["org_id"], r.orgID)
}
if spoofRec["owner_user_id"] != r.talA.id {
t.Errorf("owner_user_id spoofed: got %v, want %s", spoofRec["owner_user_id"], r.talA.id)
}
if spoofRec["created_by"] != r.talA.id {
t.Errorf("created_by spoofed: got %v, want %s", spoofRec["created_by"], r.talA.id)
}
// 8. Invalid Markdown -> 422
resEmpty := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": "",
})
if resEmpty.code != http.StatusUnprocessableEntity {
t.Errorf("empty markdown: got %d, want 422", resEmpty.code)
}
// 9. Invalid definition_id -> 422
badIDMD := `---
id: Bad_ID!
name: Bad ID Agent
pages:
- candidates
---
`
resBadID := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": badIDMD,
})
if resBadID.code != http.StatusUnprocessableEntity {
t.Errorf("bad definition_id: got %d, want 422 (%v)", resBadID.code, resBadID.body)
}
// 10. Missing name -> 422
noNameMD := `---
id: no-name-agent
pages:
- candidates
---
`
resNoName := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": noNameMD,
})
if resNoName.code != http.StatusUnprocessableEntity {
t.Errorf("missing name: got %d, want 422 (%v)", resNoName.code, resNoName.body)
}
// 11. Version > MaxVersion -> 422
hugeVersionMD := `---
id: huge-v
name: Huge Version
version: 999999999999999
pages:
- candidates
---
`
resHugeV := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": hugeVersionMD,
})
if resHugeV.code != http.StatusUnprocessableEntity {
t.Errorf("huge version: got %d, want 422 (%v)", resHugeV.code, resHugeV.body)
}
// 12. Duplicate personal definition -> 409
resDupPersonal := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": validAgentMD,
"visibility": "personal",
})
if resDupPersonal.code != http.StatusConflict {
t.Errorf("duplicate personal agent: got %d, want 409 (%v)", resDupPersonal.code, resDupPersonal.body)
}
// 13. Duplicate organization definition -> 409
resDupOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": orgAgentMD,
"visibility": "organization",
})
if resDupOrg.code != http.StatusConflict {
t.Errorf("duplicate org agent: got %d, want 409 (%v)", resDupOrg.code, resDupOrg.body)
}
// Shadow-by-id: personal agent with SAME id as organization agent succeeds!
resShadow := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": orgAgentMD,
"visibility": "personal",
})
if resShadow.code != http.StatusCreated {
t.Errorf("shadow personal agent: got %d, want 201 (%v)", resShadow.code, resShadow.body)
}
// Talent cannot create organization definition -> 403
talOrgMD := `---
id: tal-org
name: Tal Org
pages:
- candidates
---
`
resTalOrg := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": talOrgMD,
"visibility": "organization",
})
if resTalOrg.code != http.StatusForbidden {
t.Errorf("talent create org agent: got %d, want 403 (%v)", resTalOrg.code, resTalOrg.body)
}
}
/* ── 2. Skill Create Tests ────────────────────────────────────────────────── */
func TestSkillCreate(t *testing.T) {
r := newRBAC(t)
// 14. Valid personal skill -> 201
res := r.as(r.talA, "POST", "/api/v1/skill-definitions", map[string]any{
"markdown": validSkillMD,
"visibility": "personal",
})
if res.code != http.StatusCreated {
t.Fatalf("create personal skill: got %d (%v)", res.code, res.body)
}
rec := res.record(t)
if rec["definition_id"] != "test-skill" {
t.Errorf("definition_id = %v, want test-skill", rec["definition_id"])
}
if rec["name"] != "Test Skill" {
t.Errorf("name = %v, want Test Skill", rec["name"])
}
if rec["status"] != "active" {
t.Errorf("status = %v, want active", rec["status"])
}
if rec["visibility"] != "personal" {
t.Errorf("visibility = %v, want personal", rec["visibility"])
}
if rec["owner_user_id"] != r.talA.id {
t.Errorf("owner_user_id = %v, want %s", rec["owner_user_id"], r.talA.id)
}
// 21. Skills do NOT have a version column
if _, hasVersion := rec["version"]; hasVersion {
t.Errorf("skill record has version field; skills must not have a version")
}
// 15. Valid organization skill -> 201
orgSkillMD := `---
id: org-skill
name: Org Skill
status: active
pages:
- candidates
---
# Org Skill
`
resOrg := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{
"markdown": orgSkillMD,
"visibility": "organization",
})
if resOrg.code != http.StatusCreated {
t.Fatalf("create org skill: got %d (%v)", resOrg.code, resOrg.body)
}
// 16. Invalid Markdown -> 422
resEmpty := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{
"markdown": "",
})
if resEmpty.code != http.StatusUnprocessableEntity {
t.Errorf("empty skill markdown: got %d, want 422", resEmpty.code)
}
// 17. Invalid definition_id -> 422
badIDMD := `---
id: BAD_SKILL
name: Bad Skill
pages:
- candidates
---
`
resBadID := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{
"markdown": badIDMD,
})
if resBadID.code != http.StatusUnprocessableEntity {
t.Errorf("bad skill id: got %d, want 422", resBadID.code)
}
// 18. Invalid page -> 422
badPageMD := `---
id: bad-page-skill
name: Bad Page Skill
pages:
- totally_unknown_page_xyz
---
`
resBadPage := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{
"markdown": badPageMD,
})
if resBadPage.code != http.StatusUnprocessableEntity {
t.Errorf("bad skill page: got %d, want 422 (%v)", resBadPage.code, resBadPage.body)
}
// 19. Duplicate personal skill -> 409
resDupPers := r.as(r.talA, "POST", "/api/v1/skill-definitions", map[string]any{
"markdown": validSkillMD,
"visibility": "personal",
})
if resDupPers.code != http.StatusConflict {
t.Errorf("duplicate personal skill: got %d, want 409 (%v)", resDupPers.code, resDupPers.body)
}
// 20. Duplicate organization skill -> 409
resDupOrg := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{
"markdown": orgSkillMD,
"visibility": "organization",
})
if resDupOrg.code != http.StatusConflict {
t.Errorf("duplicate org skill: got %d, want 409 (%v)", resDupOrg.code, resDupOrg.body)
}
}
/* ── 3. List Tests ────────────────────────────────────────────────────────── */
func TestDefinitionsList(t *testing.T) {
r := newRBAC(t)
// Create:
// - 1 org agent (admin)
// - 1 personal agent for talA
// - 1 personal agent for talB
// - 1 org agent for outsider (in other org)
r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": `---
id: org-agent-1
name: Org Agent 1
pages:
- candidates
---
`,
"visibility": "organization",
})
r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": `---
id: tala-agent
name: TalA Agent
pages:
- candidates
---
`,
"visibility": "personal",
})
r.as(r.talB, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": `---
id: talb-agent
name: TalB Agent
pages:
- candidates
---
`,
"visibility": "personal",
})
r.as(r.outsider, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": `---
id: outsider-agent
name: Outsider Agent
pages:
- candidates
---
`,
"visibility": "organization",
})
// 22, 23, 24, 25. Scoping assertions
talAList := r.as(r.talA, "GET", "/api/v1/agent-definitions", nil)
if talAList.code != http.StatusOK {
t.Fatalf("talA list: %d", talAList.code)
}
talARecs := talAList.records(t)
talAIDMap := map[string]bool{}
for _, rec := range talARecs {
talAIDMap[rec["definition_id"].(string)] = true
}
if !talAIDMap["org-agent-1"] {
t.Errorf("talA should see org-agent-1")
}
if !talAIDMap["tala-agent"] {
t.Errorf("talA should see tala-agent")
}
if talAIDMap["talb-agent"] {
t.Errorf("talA must NOT see talB's personal agent")
}
if talAIDMap["outsider-agent"] {
t.Errorf("talA must NOT see outsider organization's agent")
}
// 26. Visibility filter
onlyPersonal := r.as(r.talA, "GET", "/api/v1/agent-definitions?visibility=personal", nil).records(t)
for _, rec := range onlyPersonal {
if rec["visibility"] != "personal" {
t.Errorf("expected only personal visibility, got %v", rec["visibility"])
}
}
onlyOrg := r.as(r.talA, "GET", "/api/v1/agent-definitions?visibility=organization", nil).records(t)
for _, rec := range onlyOrg {
if rec["visibility"] != "organization" {
t.Errorf("expected only organization visibility, got %v", rec["visibility"])
}
}
badVis := r.as(r.talA, "GET", "/api/v1/agent-definitions?visibility=invalid_vis", nil)
if badVis.code != http.StatusBadRequest {
t.Errorf("bad visibility filter: got %d, want 400", badVis.code)
}
// 27. Status filter
filteredStatus := r.as(r.talA, "GET", "/api/v1/agent-definitions?status=draft", nil).records(t)
for _, rec := range filteredStatus {
if rec["status"] != "draft" {
t.Errorf("expected draft status, got %v", rec["status"])
}
}
// 28. Definition ID filter
defIDList := r.as(r.talA, "GET", "/api/v1/agent-definitions?definition_id=tala-agent", nil).records(t)
if len(defIDList) != 1 || defIDList[0]["definition_id"] != "tala-agent" {
t.Errorf("definition_id filter failed: got %v", defIDList)
}
// 29. Pagination
page1 := r.as(r.talA, "GET", "/api/v1/agent-definitions?limit=1&offset=0", nil)
meta1 := page1.meta(t)
if fmt.Sprint(meta1["limit"]) != "1" || fmt.Sprint(meta1["offset"]) != "0" {
t.Errorf("pagination meta: %v", meta1)
}
// 30. Stable sorting
sortedAsc := r.as(r.talA, "GET", "/api/v1/agent-definitions?sort=definition_id", nil).records(t)
if len(sortedAsc) >= 2 {
id0 := sortedAsc[0]["definition_id"].(string)
id1 := sortedAsc[1]["definition_id"].(string)
if id0 > id1 {
t.Errorf("ascending sort failed: %s > %s", id0, id1)
}
}
}
/* ── 4. Get By ID Tests ───────────────────────────────────────────────────── */
func TestDefinitionsGet(t *testing.T) {
r := newRBAC(t)
// Create personal agent for talA
createA := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": `---
id: get-pers-a
name: Get Pers A
pages:
- candidates
---
`,
"visibility": "personal",
})
idPersA := createA.record(t)["id"].(string)
// Create org agent
createOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": `---
id: get-org
name: Get Org
pages:
- candidates
---
`,
"visibility": "organization",
})
idOrg := createOrg.record(t)["id"].(string)
// Create personal agent for outsider
createOutsider := r.as(r.outsider, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": `---
id: get-pers-outsider
name: Get Pers Outsider
pages:
- candidates
---
`,
"visibility": "personal",
})
idOutsider := createOutsider.record(t)["id"].(string)
// 31. Own personal definition -> 200
getPersA := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idPersA, nil)
if getPersA.code != http.StatusOK {
t.Errorf("get own personal agent: %d", getPersA.code)
}
// 32. Same-org organization definition -> 200
getOrgByTal := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idOrg, nil)
if getOrgByTal.code != http.StatusOK {
t.Errorf("get same-org agent: %d", getOrgByTal.code)
}
// 33. Other user's personal definition -> 404 (inaccessible)
getPersByTalB := r.as(r.talB, "GET", "/api/v1/agent-definitions/"+idPersA, nil)
if getPersByTalB.code != http.StatusNotFound {
t.Errorf("get other user personal agent: got %d, want 404", getPersByTalB.code)
}
// 34. Other organization's definition -> 404 (inaccessible)
getOutsiderByTalA := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idOutsider, nil)
if getOutsiderByTalA.code != http.StatusNotFound {
t.Errorf("get outsider definition: got %d, want 404", getOutsiderByTalA.code)
}
// Malformed UUID -> 404
getMalformed := r.as(r.talA, "GET", "/api/v1/agent-definitions/not-a-uuid", nil)
if getMalformed.code != http.StatusNotFound {
t.Errorf("get malformed uuid: got %d, want 404", getMalformed.code)
}
}
/* ── 5. Patch Tests ───────────────────────────────────────────────────────── */
func TestDefinitionsPatch(t *testing.T) {
r := newRBAC(t)
// Create personal agent for talA
createA := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": `---
id: patch-agent
name: Initial Name
version: 1
pages:
- candidates
---
Initial Body`,
"visibility": "personal",
})
idA := createA.record(t)["id"].(string)
origCreated := createA.record(t)["created_date"].(string)
origUpdated := createA.record(t)["updated_date"].(string)
time.Sleep(10 * time.Millisecond)
// 35, 36, 37, 38. Markdown update -> 200, projections updated, markdown verbatim, updated_date changed
updatedMD := `---
id: patch-agent
name: Updated Name
version: 2
status: published
pages:
- candidates
- positions
---
# Updated Body
Verbatim content with trailing spaces
`
patchRes := r.as(r.talA, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{
"markdown": updatedMD,
})
if patchRes.code != http.StatusOK {
t.Fatalf("patch agent: %d (%v)", patchRes.code, patchRes.body)
}
patchedRec := patchRes.record(t)
if patchedRec["name"] != "Updated Name" {
t.Errorf("name = %v, want Updated Name", patchedRec["name"])
}
if patchedRec["status"] != "published" {
t.Errorf("status = %v, want published", patchedRec["status"])
}
if fmt.Sprint(patchedRec["version"]) != "2" {
t.Errorf("version = %v, want 2", patchedRec["version"])
}
if patchedRec["markdown"] != updatedMD {
t.Errorf("markdown not verbatim:\n got: %q\nwant: %q", patchedRec["markdown"], updatedMD)
}
if patchedRec["created_date"] != origCreated {
t.Errorf("created_date changed on patch")
}
if patchedRec["updated_date"] == origUpdated {
t.Errorf("updated_date did not advance")
}
// 39. Invalid Markdown update -> 422
badPatch := r.as(r.talA, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{
"markdown": "--- invalid yaml --",
})
if badPatch.code != http.StatusUnprocessableEntity {
t.Errorf("invalid patch md: got %d, want 422", badPatch.code)
}
// 40. Server-owned fields cannot be modified
spoofPatch := r.as(r.talA, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{
"owner_user_id": r.talB.id,
"org_id": r.otherOrgID,
"created_by": r.admin.id,
})
if spoofPatch.code != http.StatusOK {
t.Errorf("spoof patch status: %d", spoofPatch.code)
}
reread := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idA, nil).record(t)
if reread["owner_user_id"] != r.talA.id {
t.Errorf("owner_user_id altered on patch: %v", reread["owner_user_id"])
}
if reread["org_id"] != r.orgID {
t.Errorf("org_id altered on patch: %v", reread["org_id"])
}
// 41. Unauthorized update: talB cannot patch talA's definition -> 404
resTalBPatch := r.as(r.talB, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{
"status": "archived",
})
if resTalBPatch.code != http.StatusNotFound {
t.Errorf("talB patch talA: got %d, want 404", resTalBPatch.code)
}
// Create org agent
createOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": `---
id: org-for-patch
name: Org Patch
pages:
- candidates
---
`,
"visibility": "organization",
})
idOrg := createOrg.record(t)["id"].(string)
// Talent cannot patch org definition -> 403
talOrgPatch := r.as(r.talA, "PATCH", "/api/v1/agent-definitions/"+idOrg, map[string]any{
"status": "archived",
})
if talOrgPatch.code != http.StatusForbidden {
t.Errorf("talent patch org agent: got %d, want 403", talOrgPatch.code)
}
// Employer can patch org definition -> 200
empOrgPatch := r.as(r.empA, "PATCH", "/api/v1/agent-definitions/"+idOrg, map[string]any{
"status": "archived",
})
if empOrgPatch.code != http.StatusOK {
t.Errorf("employer patch org agent: got %d, want 200", empOrgPatch.code)
}
// Visibility is immutable after creation -> 422
visPatch := r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+idOrg, map[string]any{
"visibility": "personal",
})
if visPatch.code != http.StatusUnprocessableEntity {
t.Errorf("visibility mutation: got %d, want 422 (%v)", visPatch.code, visPatch.body)
}
}
/* ── 6. Delete Tests ──────────────────────────────────────────────────────── */
func TestDefinitionsDelete(t *testing.T) {
r := newRBAC(t)
// Create personal agent for talA
createA := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": `---
id: del-agent-a
name: Del Agent A
pages:
- candidates
---
`,
"visibility": "personal",
})
idA := createA.record(t)["id"].(string)
// Create org agent
createOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": `---
id: del-agent-org
name: Del Agent Org
pages:
- candidates
---
`,
"visibility": "organization",
})
idOrg := createOrg.record(t)["id"].(string)
// 46. Talent cannot delete org definition -> 403
talDelOrg := r.as(r.talA, "DELETE", "/api/v1/agent-definitions/"+idOrg, nil)
if talDelOrg.code != http.StatusForbidden {
t.Errorf("talent delete org agent: got %d, want 403", talDelOrg.code)
}
// 44, 48, 49. Owner can delete personal agent -> 200, returns { "data": { "id": ... } }, subsequent GET -> 404
delA := r.as(r.talA, "DELETE", "/api/v1/agent-definitions/"+idA, nil)
if delA.code != http.StatusOK {
t.Fatalf("delete personal agent: got %d", delA.code)
}
delRec := delA.record(t)
if delRec["id"] != idA {
t.Errorf("delete response id = %v, want %s", delRec["id"], idA)
}
getAAfter := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idA, nil)
if getAAfter.code != http.StatusNotFound {
t.Errorf("subsequent GET deleted agent: got %d, want 404", getAAfter.code)
}
// 45. Operator (employer) can delete org definition -> 200
delOrg := r.as(r.empA, "DELETE", "/api/v1/agent-definitions/"+idOrg, nil)
if delOrg.code != http.StatusOK {
t.Fatalf("employer delete org agent: got %d", delOrg.code)
}
getOrgAfter := r.as(r.admin, "GET", "/api/v1/agent-definitions/"+idOrg, nil)
if getOrgAfter.code != http.StatusNotFound {
t.Errorf("subsequent GET deleted org agent: got %d, want 404", getOrgAfter.code)
}
// Idempotent delete on non-existent UUID -> 200
missingUUID := "00000000-0000-0000-0000-000000000000"
delMissing := r.as(r.talA, "DELETE", "/api/v1/agent-definitions/"+missingUUID, nil)
if delMissing.code != http.StatusOK {
t.Errorf("idempotent delete: got %d, want 200", delMissing.code)
}
}
/* ── 7. Security and SQL Injection ────────────────────────────────────────── */
func TestSecurityAndSQLInjection(t *testing.T) {
r := newRBAC(t)
// SQL injection in filter
sqliList := r.as(r.talA, "GET", "/api/v1/agent-definitions?definition_id=x'%20OR%20'1'='1", nil)
if sqliList.code != http.StatusOK {
t.Errorf("sqli filter request failed: %d", sqliList.code)
}
if len(sqliList.records(t)) != 0 {
t.Errorf("sqli in definition_id filter leaked records")
}
// SQL injection in sort
sqliSort := r.as(r.talA, "GET", "/api/v1/agent-definitions?sort=name%20DESC%3BDROP%20TABLE%20users%3B", nil)
if sqliSort.code != http.StatusBadRequest {
t.Errorf("sqli in sort should be rejected as invalid query: got %d (%v)", sqliSort.code, sqliSort.body)
}
// Unauthenticated requests -> 401
unauthList := r.doAnon("GET", "/api/v1/agent-definitions", nil)
if unauthList.code != http.StatusUnauthorized {
t.Errorf("unauth list: got %d, want 401", unauthList.code)
}
unauthCreate := r.doAnon("POST", "/api/v1/agent-definitions", map[string]any{
"markdown": validAgentMD,
})
if unauthCreate.code != http.StatusUnauthorized {
t.Errorf("unauth create: got %d, want 401", unauthCreate.code)
}
}
/* ── 8. Full CRUD & Projection Consistency Flow ───────────────────────────── */
func TestFullCRUDFlowAndProjections(t *testing.T) {
r := newRBAC(t)
// 58. Create
createRes := r.as(r.empA, "POST", "/api/v1/skill-definitions", map[string]any{
"markdown": validSkillMD,
"visibility": "organization",
})
if createRes.code != http.StatusCreated {
t.Fatalf("create skill failed: %d (%v)", createRes.code, createRes.body)
}
id := createRes.record(t)["id"].(string)
// 59. List includes created record
listRes := r.as(r.empA, "GET", "/api/v1/skill-definitions?definition_id=test-skill", nil)
if listRes.code != http.StatusOK || len(listRes.records(t)) == 0 {
t.Fatalf("list skill failed: %d (%v)", listRes.code, listRes.body)
}
// 60. Get created record and verify projections
getRes := r.as(r.talA, "GET", "/api/v1/skill-definitions/"+id, nil)
if getRes.code != http.StatusOK {
t.Fatalf("get skill failed: %d", getRes.code)
}
rec := getRes.record(t)
if rec["definition_id"] != "test-skill" || rec["name"] != "Test Skill" || rec["status"] != "active" {
t.Errorf("projection mismatch on get: %v", rec)
}
if rec["markdown"] != validSkillMD {
t.Errorf("markdown not verbatim on get")
}
// 61. Patch
newSkillMD := `---
id: test-skill
name: Updated Skill Name
status: inactive
pages:
- candidates
- profile
---
# Updated Skill Body
`
patchRes := r.as(r.empA, "PATCH", "/api/v1/skill-definitions/"+id, map[string]any{
"markdown": newSkillMD,
})
if patchRes.code != http.StatusOK {
t.Fatalf("patch skill failed: %d (%v)", patchRes.code, patchRes.body)
}
patchedRec := patchRes.record(t)
if patchedRec["name"] != "Updated Skill Name" || patchedRec["status"] != "inactive" {
t.Errorf("projection not updated on patch: %v", patchedRec)
}
if patchedRec["markdown"] != newSkillMD {
t.Errorf("markdown not verbatim on patch")
}
// 62. Delete
delRes := r.as(r.empA, "DELETE", "/api/v1/skill-definitions/"+id, nil)
if delRes.code != http.StatusOK {
t.Fatalf("delete skill failed: %d", delRes.code)
}
getAfterDel := r.as(r.empA, "GET", "/api/v1/skill-definitions/"+id, nil)
if getAfterDel.code != http.StatusNotFound {
t.Errorf("get after delete: got %d, want 404", getAfterDel.code)
}
}