§3 says an unknown subagent key fails at publish, not at run time, and refuseSubagentCycle enforced that in one direction only: the edge was checked when the PARENT was written, and nothing re-checked it when the CHILD was later archived. So a spec could validate on Monday and be delegating into nothing by Friday. That is what happened on 2026-09-15. activity-agent was archived while krow-workforce-agent v2 still listed it, and every run since logged runtime.unknown_subagent and answered activity questions without its activity capability -- quietly, because the parent still Completed. Both archive paths now refuse with 409 naming the dependents: the status-only patch the UI sends, and a markdown save whose frontmatter says archived. Only PUBLISHED parents count, so an abandoned draft cannot pin a production agent in place. Unlike the cycle check this fails closed when the graph cannot be read, because the only backstop here is the failure it exists to prevent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
1604 lines
53 KiB
Go
1604 lines
53 KiB
Go
package httpserver_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/httpserver"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestSkillVersionsAreRecordedAndServerNumbered covers the skill half of §3.
|
|
//
|
|
// Skills carry no `version:` in their frontmatter, so unlike an agent there is
|
|
// no author-supplied number to honour and nothing to refuse: the server takes
|
|
// the next one after whatever was last published. Before this, skills were
|
|
// never versioned at all — repo.KindSkill existed with nothing writing it, and
|
|
// an edit to a skill left no record of what it used to say.
|
|
func TestSkillVersionsAreRecordedAndServerNumbered(t *testing.T) {
|
|
r := newRBAC(t)
|
|
ctx := context.Background()
|
|
|
|
count := func(definitionID string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := r.h.Pool.QueryRow(ctx,
|
|
`SELECT count(*) FROM definition_versions
|
|
WHERE org_id = $1::uuid AND kind = 'skill' AND definition_id = $2`,
|
|
r.orgID, definitionID).Scan(&n); err != nil {
|
|
t.Fatalf("count skill versions: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
stored := func(definitionID string, version int) string {
|
|
t.Helper()
|
|
var md string
|
|
if err := r.h.Pool.QueryRow(ctx,
|
|
`SELECT markdown FROM definition_versions
|
|
WHERE org_id = $1::uuid AND kind = 'skill'
|
|
AND definition_id = $2 AND version = $3`,
|
|
r.orgID, definitionID, version).Scan(&md); err != nil {
|
|
t.Fatalf("read skill v%d: %v", version, err)
|
|
}
|
|
return md
|
|
}
|
|
|
|
const first = `---
|
|
id: versioned-skill
|
|
name: Versioned Skill
|
|
description: a skill that should acquire a history
|
|
status: active
|
|
pages:
|
|
- candidates
|
|
---
|
|
|
|
# Versioned Skill
|
|
The first body.
|
|
`
|
|
|
|
res := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{
|
|
"markdown": first,
|
|
"visibility": "personal",
|
|
})
|
|
if res.code != http.StatusCreated {
|
|
t.Fatalf("create skill: status %d (%v)", res.code, res.body)
|
|
}
|
|
id, _ := res.record(t)["id"].(string)
|
|
if got := count("versioned-skill"); got != 1 {
|
|
t.Fatalf("after create: %d version(s), want 1", got)
|
|
}
|
|
|
|
// An edit is always a new version — the author names no number, so there
|
|
// is nothing to rewrite and nothing to refuse.
|
|
second := strings.Replace(first, "The first body.", "The second body.", 1)
|
|
res = r.as(r.admin, "PATCH", "/api/v1/skill-definitions/"+id,
|
|
map[string]any{"markdown": second})
|
|
if res.code != http.StatusOK {
|
|
t.Fatalf("edit skill: status %d (%v)", res.code, res.body)
|
|
}
|
|
if got := count("versioned-skill"); got != 2 {
|
|
t.Fatalf("after an edit: %d version(s), want 2", got)
|
|
}
|
|
|
|
// v1 still says what it said. This is the whole point: before, the text
|
|
// was simply gone.
|
|
if md := stored("versioned-skill", 1); !strings.Contains(md, "The first body.") {
|
|
t.Errorf("v1 no longer holds the original text:\n%s", md)
|
|
}
|
|
if md := stored("versioned-skill", 2); !strings.Contains(md, "The second body.") {
|
|
t.Errorf("v2 does not hold the new text:\n%s", md)
|
|
}
|
|
|
|
// Saving the same text again is not a publish. Without this every save
|
|
// would add a version and the number would stop meaning anything.
|
|
res = r.as(r.admin, "PATCH", "/api/v1/skill-definitions/"+id,
|
|
map[string]any{"markdown": second})
|
|
if res.code != http.StatusOK {
|
|
t.Fatalf("re-saving unchanged: status %d (%v)", res.code, res.body)
|
|
}
|
|
if got := count("versioned-skill"); got != 2 {
|
|
t.Errorf("re-saving unchanged text added a version: %d, want 2", got)
|
|
}
|
|
|
|
// An inactive skill is the skill vocabulary's draft: not in service, so
|
|
// not recorded.
|
|
const inactive = `---
|
|
id: inactive-skill
|
|
name: Inactive Skill
|
|
description: not in service
|
|
status: inactive
|
|
pages:
|
|
- candidates
|
|
---
|
|
|
|
# Inactive Skill
|
|
Nothing here is published.
|
|
`
|
|
res = r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{
|
|
"markdown": inactive, "visibility": "personal",
|
|
})
|
|
if res.code != http.StatusCreated {
|
|
t.Fatalf("create inactive skill: status %d (%v)", res.code, res.body)
|
|
}
|
|
if got := count("inactive-skill"); got != 0 {
|
|
t.Errorf("an inactive skill was versioned: %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// TestReserialisedRepublishIsNotARewrite is the other half of
|
|
// TestPublishedVersionCannotBeRewritten.
|
|
//
|
|
// The guard against rewriting a published version compared raw Markdown, so it
|
|
// refused a definition that had been through the authoring UI and come back
|
|
// re-serialised — same agent, different bytes. In production that stopped a
|
|
// deploy on a `webSearch: false` written out where the hand-authored file had
|
|
// left the key absent, which the parser defaults to false anyway.
|
|
//
|
|
// Refusing a change that is not a change is still a bug, even though it fails
|
|
// safe. The comparison is definition.SameAgent now; this pins the behaviour at
|
|
// the API rather than in a unit test, because it is the deploy that broke.
|
|
func TestReserialisedRepublishIsNotARewrite(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
const published = `---
|
|
id: reserialised-agent
|
|
name: Reserialised Agent
|
|
description: published once, saved again by the editor
|
|
status: published
|
|
version: 1
|
|
pages:
|
|
- candidates
|
|
---
|
|
|
|
## Instructions
|
|
The instructions, unchanged throughout.
|
|
`
|
|
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: status %d (%v)", res.code, res.body)
|
|
}
|
|
id, _ := res.record(t)["id"].(string)
|
|
|
|
// What the editor writes back: the same agent, with a defaulted key made
|
|
// explicit. Nothing about the agent has changed.
|
|
reserialised := strings.Replace(published,
|
|
"pages:\n - candidates\n", "pages:\n - candidates\nwebSearch: false\n", 1)
|
|
if reserialised == published {
|
|
t.Fatal("fixture did not change; the test is not testing anything")
|
|
}
|
|
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+id,
|
|
map[string]any{"markdown": reserialised})
|
|
if res.code != http.StatusOK {
|
|
t.Fatalf("a re-serialised republish was refused: status %d, want 200 (%v)",
|
|
res.code, res.body)
|
|
}
|
|
|
|
// And the guard is still armed: a real change at the same version is
|
|
// still refused.
|
|
changed := strings.Replace(reserialised,
|
|
"The instructions, unchanged throughout.", "Different instructions.", 1)
|
|
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+id,
|
|
map[string]any{"markdown": changed})
|
|
if res.code != http.StatusConflict {
|
|
t.Errorf("a real change at a published version: status %d, want 409 (%v)",
|
|
res.code, res.body)
|
|
}
|
|
}
|
|
|
|
// TestPublishedVersionCannotGoBackwards covers §3's "monotonic".
|
|
//
|
|
// The rewrite guard only compares content at ONE version number, so an older
|
|
// number republished with the text that was originally published under it
|
|
// looked like a no-op: no conflict, nothing to refuse, and the live row
|
|
// silently reverted. The agent in the UI then reads v1 while the newest thing
|
|
// anybody approved was v2.
|
|
func TestPublishedVersionCannotGoBackwards(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
const v1 = `---
|
|
id: monotonic-agent
|
|
name: Monotonic Agent
|
|
description: published twice, then rolled back
|
|
status: published
|
|
version: 1
|
|
pages:
|
|
- candidates
|
|
---
|
|
|
|
## Instructions
|
|
The first version.
|
|
`
|
|
res := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": v1, "visibility": "personal",
|
|
})
|
|
if res.code != http.StatusCreated {
|
|
t.Fatalf("create v1: status %d (%v)", res.code, res.body)
|
|
}
|
|
id, _ := res.record(t)["id"].(string)
|
|
|
|
v2 := strings.Replace(strings.Replace(v1, "version: 1", "version: 2", 1),
|
|
"The first version.", "The second version.", 1)
|
|
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+id, map[string]any{"markdown": v2})
|
|
if res.code != http.StatusOK {
|
|
t.Fatalf("publish v2: status %d (%v)", res.code, res.body)
|
|
}
|
|
|
|
// Back to v1, byte-for-byte what v1 said. Nothing here conflicts — which
|
|
// is exactly why it used to succeed.
|
|
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+id, map[string]any{"markdown": v1})
|
|
if res.code != http.StatusConflict {
|
|
t.Fatalf("republishing v1 after v2: status %d, want 409 (%v)", res.code, res.body)
|
|
}
|
|
|
|
// And the live definition is still v2, not silently reverted.
|
|
res = r.as(r.admin, "GET", "/api/v1/agent-definitions/"+id, nil)
|
|
if got := fmt.Sprint(res.record(t)["version"]); got != "2" {
|
|
t.Errorf("live version = %s, want 2 — the refused publish rolled it back anyway", got)
|
|
}
|
|
}
|
|
|
|
// TestSubagentCycleIsRefusedAtPublish covers §3's DAG requirement.
|
|
//
|
|
// runtime.MaxDelegationDepth bounds a cycle that reaches run time, so this is
|
|
// not a safety hole — it is a budget one. Every run that entered the loop would
|
|
// spend its whole allowance delegating in a circle before terminating, and the
|
|
// person who wrote the loop would learn about it from a bill rather than from
|
|
// the publish that created it.
|
|
func TestSubagentCycleIsRefusedAtPublish(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
// version is a parameter so the loop-closing edit can BUMP it. Otherwise
|
|
// the rewrite guard refuses that edit for changing published text, the
|
|
// test passes for the wrong reason, and it would keep passing with cycle
|
|
// detection removed entirely.
|
|
agent := func(id, name string, version int, subagents ...string) string {
|
|
var sub string
|
|
if len(subagents) > 0 {
|
|
sub = "subagents:\n"
|
|
for _, s := range subagents {
|
|
sub += " - " + s + "\n"
|
|
}
|
|
}
|
|
return fmt.Sprintf(`---
|
|
id: %s
|
|
name: %s
|
|
description: part of a delegation graph
|
|
status: published
|
|
version: %d
|
|
pages:
|
|
- candidates
|
|
%s---
|
|
|
|
## Instructions
|
|
Delegate.
|
|
`, id, name, version, sub)
|
|
}
|
|
|
|
// A, with no subagents yet.
|
|
res := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": agent("cycle-a", "Cycle A", 1), "visibility": "organization",
|
|
})
|
|
if res.code != http.StatusCreated {
|
|
t.Fatalf("create A: status %d (%v)", res.code, res.body)
|
|
}
|
|
idA, _ := res.record(t)["id"].(string)
|
|
|
|
// B delegates to A. Still a DAG.
|
|
res = r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": agent("cycle-b", "Cycle B", 1, "cycle-a"), "visibility": "organization",
|
|
})
|
|
if res.code != http.StatusCreated {
|
|
t.Fatalf("create B pointing at A: status %d, want 201 — a chain is not a cycle (%v)",
|
|
res.code, res.body)
|
|
}
|
|
|
|
// Now close the loop: A delegates to B.
|
|
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{
|
|
"markdown": agent("cycle-a", "Cycle A", 2, "cycle-b"),
|
|
})
|
|
if res.code != http.StatusUnprocessableEntity && res.code != http.StatusBadRequest {
|
|
t.Fatalf("closing the loop: status %d, want a validation failure (%v)", res.code, res.body)
|
|
}
|
|
if body := fmt.Sprint(res.body); !strings.Contains(body, "cycle") {
|
|
t.Errorf("the refusal did not mention a cycle: %v", res.body)
|
|
}
|
|
|
|
// A must be unchanged — refused, not half-applied.
|
|
res = r.as(r.admin, "GET", "/api/v1/agent-definitions/"+idA, nil)
|
|
if md, _ := res.record(t)["markdown"].(string); strings.Contains(md, "cycle-b") {
|
|
t.Error("the refused edit was applied anyway")
|
|
}
|
|
}
|
|
|
|
// A self-reference is the shortest cycle and the easiest to write by accident.
|
|
func TestSelfReferencingSubagentIsRefused(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
const md = `---
|
|
id: narcissus-agent
|
|
name: Narcissus Agent
|
|
description: names itself
|
|
status: published
|
|
version: 1
|
|
pages:
|
|
- candidates
|
|
subagents:
|
|
- narcissus-agent
|
|
---
|
|
|
|
## Instructions
|
|
Ask myself.
|
|
`
|
|
res := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": md, "visibility": "organization",
|
|
})
|
|
if res.code == http.StatusCreated {
|
|
t.Fatal("an agent naming itself as its own subagent was published")
|
|
}
|
|
}
|
|
|
|
/* ── 8. Curated (built-in) agent protection ───────────────────────────────── */
|
|
|
|
// agentMD builds a minimal valid agent definition for a given id.
|
|
func agentMD(id, name string) string {
|
|
return fmt.Sprintf("---\nid: %s\nname: %s\nstatus: draft\nversion: 1\npages:\n - candidates\n---\n\n## Instructions\nDo the thing.\n", id, name)
|
|
}
|
|
|
|
// TestCuratedAgentIsNotDeletable covers the protection the Agents list implies
|
|
// but React alone cannot enforce.
|
|
//
|
|
// A curated agent is published by `importagents` as an ordinary organization
|
|
// row, so nothing in the table distinguishes it from a shared agent somebody
|
|
// authored — the distinguishing fact is that the deployment ships its spec.
|
|
// Without a check at the endpoint, any operator with a terminal could delete
|
|
// the definition the RUNTIME resolves from, leaving the agent in the list and
|
|
// every run of it answering 404.
|
|
func TestCuratedAgentIsNotDeletable(t *testing.T) {
|
|
a := newAPI(t, httpserver.WithCuratedAgents("curated-agent"))
|
|
|
|
// What importagents publishes: the curated spec, at organization visibility.
|
|
curated := a.do("POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": agentMD("curated-agent", "Curated Agent"),
|
|
"visibility": "organization",
|
|
})
|
|
if curated.code != http.StatusCreated && curated.code != http.StatusOK {
|
|
t.Fatalf("publish curated agent: got %d", curated.code)
|
|
}
|
|
curatedID := curated.record(t)["id"].(string)
|
|
|
|
// The admin who may delete any other organization definition is refused
|
|
// this one.
|
|
del := a.do("DELETE", "/api/v1/agent-definitions/"+curatedID, nil)
|
|
if del.code != http.StatusForbidden {
|
|
t.Errorf("delete curated agent: got %d, want 403", del.code)
|
|
}
|
|
|
|
// And it is still there — refused, not deleted-then-reported.
|
|
after := a.do("GET", "/api/v1/agent-definitions/"+curatedID, nil)
|
|
if after.code != http.StatusOK {
|
|
t.Fatalf("curated agent after refused delete: got %d, want 200", after.code)
|
|
}
|
|
if got := after.record(t)["definition_id"]; got != "curated-agent" {
|
|
t.Errorf("curated agent definition_id = %v, want curated-agent", got)
|
|
}
|
|
}
|
|
|
|
// TestPersonalOverrideOfCuratedAgentStaysDeletable protects the revert path.
|
|
//
|
|
// "Revert to shipped" in the Agents list deletes the account's own definition
|
|
// of a shipped id. Protecting by id alone would break it, so the guard is
|
|
// scoped to the organization tier — this is the test that says so.
|
|
func TestPersonalOverrideOfCuratedAgentStaysDeletable(t *testing.T) {
|
|
a := newAPI(t, httpserver.WithCuratedAgents("curated-agent"))
|
|
|
|
override := a.do("POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": agentMD("curated-agent", "My Version"),
|
|
"visibility": "personal",
|
|
})
|
|
if override.code != http.StatusCreated && override.code != http.StatusOK {
|
|
t.Fatalf("create personal override: got %d", override.code)
|
|
}
|
|
overrideID := override.record(t)["id"].(string)
|
|
|
|
del := a.do("DELETE", "/api/v1/agent-definitions/"+overrideID, nil)
|
|
if del.code != http.StatusOK {
|
|
t.Errorf("delete personal override of a curated id: got %d, want 200", del.code)
|
|
}
|
|
after := a.do("GET", "/api/v1/agent-definitions/"+overrideID, nil)
|
|
if after.code != http.StatusNotFound {
|
|
t.Errorf("override after delete: got %d, want 404", after.code)
|
|
}
|
|
}
|
|
|
|
// TestCustomAgentDeleteIsIsolated is the isolation case: removing one custom
|
|
// agent removes that agent and nothing else.
|
|
func TestCustomAgentDeleteIsIsolated(t *testing.T) {
|
|
a := newAPI(t, httpserver.WithCuratedAgents("curated-agent"))
|
|
|
|
// A curated agent, a second custom agent, and a skill — none of which the
|
|
// delete below is about.
|
|
curated := a.do("POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": agentMD("curated-agent", "Curated Agent"), "visibility": "organization",
|
|
}).record(t)["id"].(string)
|
|
keep := a.do("POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": agentMD("keep-me", "Keep Me"), "visibility": "personal",
|
|
}).record(t)["id"].(string)
|
|
skill := a.do("POST", "/api/v1/skill-definitions", map[string]any{
|
|
"markdown": validSkillMD, "visibility": "personal",
|
|
}).record(t)["id"].(string)
|
|
|
|
target := a.do("POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": agentMD("remove-me", "Remove Me"), "visibility": "personal",
|
|
}).record(t)["id"].(string)
|
|
|
|
if got := a.do("DELETE", "/api/v1/agent-definitions/"+target, nil); got.code != http.StatusOK {
|
|
t.Fatalf("delete custom agent: got %d", got.code)
|
|
}
|
|
|
|
// Gone.
|
|
if got := a.do("GET", "/api/v1/agent-definitions/"+target, nil); got.code != http.StatusNotFound {
|
|
t.Errorf("removed agent: got %d, want 404", got.code)
|
|
}
|
|
// Everything else untouched.
|
|
for name, id := range map[string]string{"curated agent": curated, "other custom agent": keep} {
|
|
if got := a.do("GET", "/api/v1/agent-definitions/"+id, nil); got.code != http.StatusOK {
|
|
t.Errorf("%s after an unrelated delete: got %d, want 200", name, got.code)
|
|
}
|
|
}
|
|
if got := a.do("GET", "/api/v1/skill-definitions/"+skill, nil); got.code != http.StatusOK {
|
|
t.Errorf("skill after an unrelated agent delete: got %d, want 200", got.code)
|
|
}
|
|
}
|
|
|
|
// TestArchiveAndRestorePreserveTheSameAgent is the persistence half of Remove.
|
|
//
|
|
// Removing an authored agent archives it. That claim is only worth anything if
|
|
// archiving keeps the row: the same uuid, the same definition_id and the same
|
|
// Markdown, so restoring returns the agent somebody wrote rather than a new one
|
|
// wearing its name. This asserts the round trip against the real endpoints.
|
|
func TestArchiveAndRestorePreserveTheSameAgent(t *testing.T) {
|
|
a := newAPI(t, httpserver.WithCuratedAgents("curated-agent"))
|
|
|
|
const live = `---
|
|
id: coverage-helper
|
|
name: Coverage Helper
|
|
status: published
|
|
version: 3
|
|
pages:
|
|
- candidates
|
|
---
|
|
|
|
## Instructions
|
|
Find the shifts nobody has taken.
|
|
`
|
|
created := a.do("POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": live, "visibility": "personal",
|
|
})
|
|
if created.code != http.StatusCreated && created.code != http.StatusOK {
|
|
t.Fatalf("create agent: got %d", created.code)
|
|
}
|
|
rec := created.record(t)
|
|
id := rec["id"].(string)
|
|
definitionID := rec["definition_id"]
|
|
|
|
// Remove -> archive. Same row, same body, only the status moves.
|
|
archived := a.do("PATCH", "/api/v1/agent-definitions/"+id, map[string]any{
|
|
"markdown": strings.Replace(live, "status: published", "status: archived", 1),
|
|
})
|
|
if archived.code != http.StatusOK {
|
|
t.Fatalf("archive agent: got %d", archived.code)
|
|
}
|
|
arc := archived.record(t)
|
|
if arc["status"] != "archived" {
|
|
t.Errorf("status after remove = %v, want archived", arc["status"])
|
|
}
|
|
if arc["id"] != id || arc["definition_id"] != definitionID {
|
|
t.Errorf("identity changed on archive: %v/%v, want %s/%v",
|
|
arc["id"], arc["definition_id"], id, definitionID)
|
|
}
|
|
if !strings.Contains(arc["markdown"].(string), "Find the shifts nobody has taken.") {
|
|
t.Error("instructions were lost when the agent was archived")
|
|
}
|
|
|
|
// It is still there — removal is not deletion.
|
|
if got := a.do("GET", "/api/v1/agent-definitions/"+id, nil); got.code != http.StatusOK {
|
|
t.Fatalf("removed agent should still be readable: got %d, want 200", got.code)
|
|
}
|
|
|
|
// Restore -> the SAME agent, as a draft.
|
|
restored := a.do("PATCH", "/api/v1/agent-definitions/"+id, map[string]any{
|
|
"markdown": strings.Replace(live, "status: published", "status: draft", 1),
|
|
})
|
|
if restored.code != http.StatusOK {
|
|
t.Fatalf("restore agent: got %d", restored.code)
|
|
}
|
|
res := restored.record(t)
|
|
if res["status"] != "draft" {
|
|
t.Errorf("status after restore = %v, want draft", res["status"])
|
|
}
|
|
if res["id"] != id || res["definition_id"] != definitionID {
|
|
t.Errorf("restore created a different agent: %v/%v, want %s/%v",
|
|
res["id"], res["definition_id"], id, definitionID)
|
|
}
|
|
if !strings.Contains(res["markdown"].(string), "Find the shifts nobody has taken.") {
|
|
t.Error("instructions were lost on the round trip")
|
|
}
|
|
if got := res["version"]; got != arc["version"] {
|
|
t.Errorf("version moved on a restore: %v -> %v", arc["version"], got)
|
|
}
|
|
}
|
|
|
|
// The reverse of the cycle and unknown-key checks. Those prove an edge is
|
|
// valid when the PARENT is written; this proves the edge stays valid when the
|
|
// CHILD is archived. Without it a published parent keeps delegating into
|
|
// nothing — exactly what krow-workforce-agent did after activity-agent was
|
|
// archived under it on 2026-09-15.
|
|
func TestArchivingADelegatedSubagentIsRefused(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
agent := func(id, name, status string, version int, subagents ...string) string {
|
|
var sub string
|
|
if len(subagents) > 0 {
|
|
sub = "subagents:\n"
|
|
for _, s := range subagents {
|
|
sub += " - " + s + "\n"
|
|
}
|
|
}
|
|
return fmt.Sprintf(`---
|
|
id: %s
|
|
name: %s
|
|
description: part of a delegation graph
|
|
status: %s
|
|
version: %d
|
|
pages:
|
|
- candidates
|
|
%s---
|
|
|
|
## Instructions
|
|
Delegate.
|
|
`, id, name, status, version, sub)
|
|
}
|
|
|
|
res := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": agent("dep-child", "Child", "published", 1), "visibility": "organization",
|
|
})
|
|
if res.code != http.StatusCreated {
|
|
t.Fatalf("create child: status %d (%v)", res.code, res.body)
|
|
}
|
|
childID, _ := res.record(t)["id"].(string)
|
|
|
|
res = r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": agent("dep-parent", "Parent", "published", 1, "dep-child"), "visibility": "organization",
|
|
})
|
|
if res.code != http.StatusCreated {
|
|
t.Fatalf("create parent: status %d (%v)", res.code, res.body)
|
|
}
|
|
parentID, _ := res.record(t)["id"].(string)
|
|
|
|
// Both ways of archiving must be refused: the status-only patch the UI
|
|
// sends, and a markdown save whose frontmatter says archived.
|
|
for name, patch := range map[string]map[string]any{
|
|
"status patch": {"status": "archived"},
|
|
"markdown save": {"markdown": agent("dep-child", "Child", "archived", 2)},
|
|
} {
|
|
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+childID, patch)
|
|
if res.code != http.StatusConflict {
|
|
t.Fatalf("%s: archiving a delegated-to agent: status %d, want 409 (%v)", name, res.code, res.body)
|
|
}
|
|
if body := fmt.Sprint(res.body); !strings.Contains(body, "dep-parent") {
|
|
t.Errorf("%s: the refusal did not name the dependent: %v", name, res.body)
|
|
}
|
|
}
|
|
|
|
// Refused, not half-applied.
|
|
res = r.as(r.admin, "GET", "/api/v1/agent-definitions/"+childID, nil)
|
|
if st, _ := res.record(t)["status"].(string); st != "published" {
|
|
t.Fatalf("child status after refused archives = %q, want published", st)
|
|
}
|
|
|
|
// A DRAFT parent does not pin the child. Move the parent to draft and the
|
|
// archive goes through: an abandoned experiment must not hold a
|
|
// production agent in place.
|
|
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+parentID, map[string]any{"status": "draft"})
|
|
if res.code != http.StatusOK {
|
|
t.Fatalf("draft the parent: status %d (%v)", res.code, res.body)
|
|
}
|
|
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+childID, map[string]any{"status": "archived"})
|
|
if res.code != http.StatusOK {
|
|
t.Fatalf("archive with only a draft dependent: status %d, want 200 (%v)", res.code, res.body)
|
|
}
|
|
}
|