Two rules §3 states and nothing checked.
MONOTONIC. The rewrite guard added earlier compares content at ONE version
number, so republishing an OLDER number with the text originally published
under it looked like a no-op: nothing conflicted, nothing was refused, and the
live row silently reverted. The agent then reads v1 in the UI while the newest
thing anybody approved was v2. The test publishes v1, publishes v2, republishes
v1 byte-for-byte, and asserts both the refusal and that the live row is still
v2. Without the guard it answers 200 and the row goes back to version 1.
DAG. §3 says cycle detection runs at publish; only the runtime depth cap
existed. That cap means a cycle was never a safety problem — it was a budget
one. Every run entering the loop spends its whole allowance delegating in a
circle before terminating, and the author learns about it from a bill rather
than from the publish that created it.
definition.FindSubagentCycle is a pure function over id -> subagent ids, so it
is tested directly: chains, diamonds, self-reference, loops not involving the
first agent walked, and a 5000-long chain that would matter if this were
written to recurse carelessly. It REPORTS the cycle ("a -> b -> c -> a")
rather than merely detecting one, because an operator otherwise has to find it
by hand across a set of specs. The report is deterministic — a test runs it
fifty times over a graph with two cycles and requires the same answer, since Go
randomises map iteration and an error message that changes between identical
runs is one nobody trusts.
Wired into both publish paths. importagents has every spec in hand, which is
the only place that is cheaply true. The API builds the graph from
organization-visible agents plus the incoming definition standing in for its
stored self — otherwise an edit that CREATES a cycle is checked against the
version that did not have one and passes. Personal agents are excluded: they
are invisible to everyone else so cannot complete anyone else's loop, and
reading them would mean reading other people's drafts to validate your own.
An edge to an agent that is not in the set is ignored rather than reported.
That is a different failure with a different message
(runtime.unknown_subagent), and conflating them prints "cycle detected" for
what is actually a typo.
The cycle test bumps the version on the loop-closing edit. Without that the
rewrite guard refuses it for changing published text, the test passes for the
wrong reason, and it would keep passing with cycle detection deleted — which
is how it was first written, and what running it without the guard showed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
1330 lines
42 KiB
Go
1330 lines
42 KiB
Go
package httpserver_test
|
|
|
|
import (
|
|
"context"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|