Files
krow_backend/go-api/internal/httpserver/definitions_api_test.go
Suriyakumarvijayanayagam 80ba57ace3 Refuse an edit that would rewrite an already-published version
§3 says a published version is immutable and editing publishes a new one.
The machinery for that was all present — an append-only definition_versions
table, a trigger, and repo.VersionsRepo.Snapshot, which already refuses to
store a version number whose content differs from what is stored.

Nothing acted on that refusal. snapshotIfPublished's error was discarded at
both call sites (`_ = s.snapshotIfPublished(...)`), and deliberately so: the
comment there explains that losing an author's work to protect a record of it
is the wrong trade. That is right for a recording failure and wrong for
exactly one case. A conflict is not the history failing to record; it is the
invariant firing.

The effect was silent. Editing a published agent without raising the
frontmatter version answered 200: the live row took the new text, the history
kept the old, and two different definitions were both called v1. Because
runtime.LoadAgentVersion resolves a pin by returning the CURRENT definition
whenever the pinned number equals the current one, a conversation pinned to v1
then ran the rewritten instructions while the audit trail showed the
originals. Verified against a live stack before the fix: PATCH answered 200,
agent_definitions held "SILENTLY CHANGED" and definition_versions still held
the published text, both labelled v2.

So the conflict is now detected before anything is written, where refusing
costs the author nothing but a version bump. The post-write snapshot keeps its
original contract for every other kind of failure, and republishing a version
unchanged stays the no-op it was. Drafts are untouched: they carry no promise,
and are still rewritten in place.

Not addressed here, and each its own change:

  - cmd/importagents never creates versions at all (documented at main.go:10),
    so the nine file-published organization agents are outside this entirely
    and every deploy still mutates v1 in place.
  - skill definitions never snapshot, so KindSkill exists with nothing writing
    it. Fixing that changes skill authoring behaviour and wants its own pass.
  - a concurrent publish of one version number with differing content can still
    pass this check and be caught by the unique index afterwards, where it is
    swallowed as before. That is the pre-existing behaviour, narrowed rather
    than removed.

Tests: the new case fails without the fix — the live row takes the rewritten
text at version 1 — and passes with it. Full suite green against PostgreSQL,
with only TestLive* skipped, which is what CI allows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-08-28 18:28:20 +05:30

998 lines
31 KiB
Go

package httpserver_test
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"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)
}
}
/* ── Tool names are checked at publish ────────────────────────────────────── */
// §3: an unknown tool name fails validation at PUBLISH. Before this, the name
// was accepted, stored, and dropped by the runtime at resolve time — so an
// author got an agent that was silently missing a capability they believed they
// had chosen, and found out by watching it fail to answer.
func TestAgentCreateRejectsAnUnknownToolName(t *testing.T) {
r := newRBAC(t)
withTools := func(names string) string {
return strings.Replace(validAgentMD, "pages:\n - candidates",
"tools:\n"+names+"pages:\n - candidates", 1)
}
res := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": withTools(" - not_a_real_tool\n"),
"visibility": "personal",
})
if res.code != http.StatusBadRequest && res.code != http.StatusUnprocessableEntity {
t.Fatalf("unknown tool accepted: status %d (%v)", res.code, res.body)
}
if body, _ := json.Marshal(res.body); !strings.Contains(string(body), "not_a_real_tool") {
t.Errorf("the error does not name the offending tool: %s", body)
}
// A real tool is accepted, so the check is not simply refusing everything.
ok := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": withTools(" - candidates_awaiting\n"),
"visibility": "personal",
})
if ok.code != http.StatusCreated {
t.Fatalf("a real tool was refused: status %d (%v)", ok.code, ok.body)
}
}
// TestPublishedVersionCannotBeRewritten covers §3: a published version is
// immutable, and editing publishes a NEW one.
//
// The failure this guards against was silent rather than loud. Editing a
// published agent without raising the frontmatter version used to answer 200:
// the live row took the new text, the append-only history kept the old, and
// two different definitions were both called v1. runtime.LoadAgentVersion
// resolves a pin by returning the CURRENT definition whenever the pinned
// number equals the current one, so a conversation "pinned to v1" then ran the
// rewritten instructions while the audit trail showed the originals.
func TestPublishedVersionCannotBeRewritten(t *testing.T) {
r := newRBAC(t)
const published = `---
id: pinned-agent
name: Pinned Agent
description: published, and therefore immutable at this version
status: published
version: 1
pages:
- candidates
---
## Instructions
The original instructions.
`
res := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": published,
"visibility": "personal",
})
if res.code != http.StatusCreated {
t.Fatalf("create published agent: status %d (%v)", res.code, res.body)
}
id, _ := res.record(t)["id"].(string)
if id == "" {
t.Fatal("created agent has no id")
}
// Same version number, different body: refused.
rewritten := strings.Replace(published,
"The original instructions.", "Rewritten instructions.", 1)
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+id,
map[string]any{"markdown": rewritten})
if res.code != http.StatusConflict {
t.Fatalf("rewriting published v1: status %d, want 409 (%v)", res.code, res.body)
}
// And the refusal actually protected something — the live definition is
// unchanged, not merely reported as unchanged.
res = r.as(r.admin, "GET", "/api/v1/agent-definitions/"+id, nil)
if res.code != http.StatusOK {
t.Fatalf("re-read agent: status %d (%v)", res.code, res.body)
}
md, _ := res.record(t)["markdown"].(string)
if !strings.Contains(md, "The original instructions.") {
t.Errorf("the refused edit still changed the stored definition:\n%s", md)
}
if strings.Contains(md, "Rewritten instructions.") {
t.Errorf("the refused edit was applied anyway:\n%s", md)
}
// Republishing the SAME version with the SAME content stays a no-op, so a
// save that changes nothing is not turned into an error.
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+id,
map[string]any{"markdown": published})
if res.code != http.StatusOK {
t.Errorf("republishing v1 unchanged: status %d, want 200 (%v)", res.code, res.body)
}
// Raising the version is the supported way to publish a change.
bumped := strings.Replace(rewritten, "version: 1", "version: 2", 1)
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+id,
map[string]any{"markdown": bumped})
if res.code != http.StatusOK {
t.Fatalf("publishing v2: status %d, want 200 (%v)", res.code, res.body)
}
res = r.as(r.admin, "GET", "/api/v1/agent-definitions/"+id, nil)
md, _ = res.record(t)["markdown"].(string)
if !strings.Contains(md, "Rewritten instructions.") {
t.Errorf("v2 did not take the new text:\n%s", md)
}
// A draft carries no such promise: it is not published, so it may be
// rewritten in place as often as its author likes.
const draft = `---
id: draft-agent
name: Draft Agent
description: still a draft
status: draft
version: 1
pages:
- candidates
---
## Instructions
First draft.
`
res = r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": draft, "visibility": "personal",
})
if res.code != http.StatusCreated {
t.Fatalf("create draft: status %d (%v)", res.code, res.body)
}
draftID, _ := res.record(t)["id"].(string)
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+draftID,
map[string]any{"markdown": strings.Replace(draft, "First draft.", "Second draft.", 1)})
if res.code != http.StatusOK {
t.Errorf("rewriting a draft at the same version: status %d, want 200 (%v)", res.code, res.body)
}
}