§3 says a published version is immutable and editing publishes a new one.
The machinery for that was all present — an append-only definition_versions
table, a trigger, and repo.VersionsRepo.Snapshot, which already refuses to
store a version number whose content differs from what is stored.
Nothing acted on that refusal. snapshotIfPublished's error was discarded at
both call sites (`_ = s.snapshotIfPublished(...)`), and deliberately so: the
comment there explains that losing an author's work to protect a record of it
is the wrong trade. That is right for a recording failure and wrong for
exactly one case. A conflict is not the history failing to record; it is the
invariant firing.
The effect was silent. Editing a published agent without raising the
frontmatter version answered 200: the live row took the new text, the history
kept the old, and two different definitions were both called v1. Because
runtime.LoadAgentVersion resolves a pin by returning the CURRENT definition
whenever the pinned number equals the current one, a conversation pinned to v1
then ran the rewritten instructions while the audit trail showed the
originals. Verified against a live stack before the fix: PATCH answered 200,
agent_definitions held "SILENTLY CHANGED" and definition_versions still held
the published text, both labelled v2.
So the conflict is now detected before anything is written, where refusing
costs the author nothing but a version bump. The post-write snapshot keeps its
original contract for every other kind of failure, and republishing a version
unchanged stays the no-op it was. Drafts are untouched: they carry no promise,
and are still rewritten in place.
Not addressed here, and each its own change:
- cmd/importagents never creates versions at all (documented at main.go:10),
so the nine file-published organization agents are outside this entirely
and every deploy still mutates v1 in place.
- skill definitions never snapshot, so KindSkill exists with nothing writing
it. Fixing that changes skill authoring behaviour and wants its own pass.
- a concurrent publish of one version number with differing content can still
pass this check and be caught by the unique index afterwards, where it is
swallowed as before. That is the pre-existing behaviour, narrowed rather
than removed.
Tests: the new case fails without the fix — the live row takes the rewritten
text at version 1 — and passes with it. Full suite green against PostgreSQL,
with only TestLive* skipped, which is what CI allows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
998 lines
31 KiB
Go
998 lines
31 KiB
Go
package httpserver_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Phase 4E — Backend CRUD APIs for authored Agent and Skill definitions.
|
|
|
|
const validAgentMD = `---
|
|
id: test-agent
|
|
name: Test Agent
|
|
description: An authored agent for testing
|
|
status: draft
|
|
version: 1
|
|
pages:
|
|
- candidates
|
|
---
|
|
|
|
## Instructions
|
|
Execute testing tasks carefully.
|
|
`
|
|
|
|
const validSkillMD = `---
|
|
id: test-skill
|
|
name: Test Skill
|
|
description: An authored skill for testing
|
|
status: active
|
|
pages:
|
|
- candidates
|
|
---
|
|
|
|
# Test Skill
|
|
Skill body instructions.
|
|
`
|
|
|
|
/* ── 1. Agent Create Tests ────────────────────────────────────────────────── */
|
|
|
|
func TestAgentCreate(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
// 1. Valid personal agent -> 201
|
|
res := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": validAgentMD,
|
|
"visibility": "personal",
|
|
})
|
|
if res.code != http.StatusCreated {
|
|
t.Fatalf("create personal agent: got status %d (%v)", res.code, res.body)
|
|
}
|
|
rec := res.record(t)
|
|
if rec["definition_id"] != "test-agent" {
|
|
t.Errorf("definition_id = %v, want test-agent", rec["definition_id"])
|
|
}
|
|
if rec["name"] != "Test Agent" {
|
|
t.Errorf("name = %v, want Test Agent", rec["name"])
|
|
}
|
|
if rec["status"] != "draft" {
|
|
t.Errorf("status = %v, want draft", rec["status"])
|
|
}
|
|
if fmt.Sprint(rec["version"]) != "1" {
|
|
t.Errorf("version = %v, want 1", rec["version"])
|
|
}
|
|
if rec["visibility"] != "personal" {
|
|
t.Errorf("visibility = %v, want personal", rec["visibility"])
|
|
}
|
|
|
|
// 3. Personal fields derived from authenticated identity
|
|
if rec["owner_user_id"] != r.talA.id {
|
|
t.Errorf("owner_user_id = %v, want %s", rec["owner_user_id"], r.talA.id)
|
|
}
|
|
if rec["created_by"] != r.talA.id {
|
|
t.Errorf("created_by = %v, want %s", rec["created_by"], r.talA.id)
|
|
}
|
|
if rec["org_id"] != r.orgID {
|
|
t.Errorf("org_id = %v, want %s", rec["org_id"], r.orgID)
|
|
}
|
|
|
|
// 2. Valid organization agent -> 201 (by admin)
|
|
orgAgentMD := `---
|
|
id: shared-agent
|
|
name: Shared Agent
|
|
pages:
|
|
- candidates
|
|
---
|
|
## Instructions
|
|
Shared instructions.
|
|
`
|
|
resOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": orgAgentMD,
|
|
"visibility": "organization",
|
|
})
|
|
if resOrg.code != http.StatusCreated {
|
|
t.Fatalf("create org agent: got status %d (%v)", resOrg.code, resOrg.body)
|
|
}
|
|
orgRec := resOrg.record(t)
|
|
// 4. Organization fields derived from authenticated identity
|
|
if orgRec["visibility"] != "organization" {
|
|
t.Errorf("visibility = %v, want organization", orgRec["visibility"])
|
|
}
|
|
if orgRec["owner_user_id"] != nil {
|
|
t.Errorf("owner_user_id = %v, want nil for organization tier", orgRec["owner_user_id"])
|
|
}
|
|
if orgRec["created_by"] != r.admin.id {
|
|
t.Errorf("created_by = %v, want %s", orgRec["created_by"], r.admin.id)
|
|
}
|
|
|
|
// 5, 6, 7. Client-supplied org_id, owner_user_id, created_by cannot override session
|
|
manipulatedMD := `---
|
|
id: spoof-agent
|
|
name: Spoof Agent
|
|
pages:
|
|
- candidates
|
|
---
|
|
`
|
|
resSpoof := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": manipulatedMD,
|
|
"visibility": "personal",
|
|
"org_id": r.otherOrgID,
|
|
"owner_user_id": r.talB.id,
|
|
"created_by": r.admin.id,
|
|
})
|
|
if resSpoof.code != http.StatusCreated {
|
|
t.Fatalf("create spoofed agent: status %d", resSpoof.code)
|
|
}
|
|
spoofRec := resSpoof.record(t)
|
|
if spoofRec["org_id"] != r.orgID {
|
|
t.Errorf("org_id spoofed: got %v, want %s", spoofRec["org_id"], r.orgID)
|
|
}
|
|
if spoofRec["owner_user_id"] != r.talA.id {
|
|
t.Errorf("owner_user_id spoofed: got %v, want %s", spoofRec["owner_user_id"], r.talA.id)
|
|
}
|
|
if spoofRec["created_by"] != r.talA.id {
|
|
t.Errorf("created_by spoofed: got %v, want %s", spoofRec["created_by"], r.talA.id)
|
|
}
|
|
|
|
// 8. Invalid Markdown -> 422
|
|
resEmpty := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": "",
|
|
})
|
|
if resEmpty.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("empty markdown: got %d, want 422", resEmpty.code)
|
|
}
|
|
|
|
// 9. Invalid definition_id -> 422
|
|
badIDMD := `---
|
|
id: Bad_ID!
|
|
name: Bad ID Agent
|
|
pages:
|
|
- candidates
|
|
---
|
|
`
|
|
resBadID := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": badIDMD,
|
|
})
|
|
if resBadID.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("bad definition_id: got %d, want 422 (%v)", resBadID.code, resBadID.body)
|
|
}
|
|
|
|
// 10. Missing name -> 422
|
|
noNameMD := `---
|
|
id: no-name-agent
|
|
pages:
|
|
- candidates
|
|
---
|
|
`
|
|
resNoName := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": noNameMD,
|
|
})
|
|
if resNoName.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("missing name: got %d, want 422 (%v)", resNoName.code, resNoName.body)
|
|
}
|
|
|
|
// 11. Version > MaxVersion -> 422
|
|
hugeVersionMD := `---
|
|
id: huge-v
|
|
name: Huge Version
|
|
version: 999999999999999
|
|
pages:
|
|
- candidates
|
|
---
|
|
`
|
|
resHugeV := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": hugeVersionMD,
|
|
})
|
|
if resHugeV.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("huge version: got %d, want 422 (%v)", resHugeV.code, resHugeV.body)
|
|
}
|
|
|
|
// 12. Duplicate personal definition -> 409
|
|
resDupPersonal := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": validAgentMD,
|
|
"visibility": "personal",
|
|
})
|
|
if resDupPersonal.code != http.StatusConflict {
|
|
t.Errorf("duplicate personal agent: got %d, want 409 (%v)", resDupPersonal.code, resDupPersonal.body)
|
|
}
|
|
|
|
// 13. Duplicate organization definition -> 409
|
|
resDupOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": orgAgentMD,
|
|
"visibility": "organization",
|
|
})
|
|
if resDupOrg.code != http.StatusConflict {
|
|
t.Errorf("duplicate org agent: got %d, want 409 (%v)", resDupOrg.code, resDupOrg.body)
|
|
}
|
|
|
|
// Shadow-by-id: personal agent with SAME id as organization agent succeeds!
|
|
resShadow := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": orgAgentMD,
|
|
"visibility": "personal",
|
|
})
|
|
if resShadow.code != http.StatusCreated {
|
|
t.Errorf("shadow personal agent: got %d, want 201 (%v)", resShadow.code, resShadow.body)
|
|
}
|
|
|
|
// Talent cannot create organization definition -> 403
|
|
talOrgMD := `---
|
|
id: tal-org
|
|
name: Tal Org
|
|
pages:
|
|
- candidates
|
|
---
|
|
`
|
|
resTalOrg := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": talOrgMD,
|
|
"visibility": "organization",
|
|
})
|
|
if resTalOrg.code != http.StatusForbidden {
|
|
t.Errorf("talent create org agent: got %d, want 403 (%v)", resTalOrg.code, resTalOrg.body)
|
|
}
|
|
}
|
|
|
|
/* ── 2. Skill Create Tests ────────────────────────────────────────────────── */
|
|
|
|
func TestSkillCreate(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
// 14. Valid personal skill -> 201
|
|
res := r.as(r.talA, "POST", "/api/v1/skill-definitions", map[string]any{
|
|
"markdown": validSkillMD,
|
|
"visibility": "personal",
|
|
})
|
|
if res.code != http.StatusCreated {
|
|
t.Fatalf("create personal skill: got %d (%v)", res.code, res.body)
|
|
}
|
|
rec := res.record(t)
|
|
if rec["definition_id"] != "test-skill" {
|
|
t.Errorf("definition_id = %v, want test-skill", rec["definition_id"])
|
|
}
|
|
if rec["name"] != "Test Skill" {
|
|
t.Errorf("name = %v, want Test Skill", rec["name"])
|
|
}
|
|
if rec["status"] != "active" {
|
|
t.Errorf("status = %v, want active", rec["status"])
|
|
}
|
|
if rec["visibility"] != "personal" {
|
|
t.Errorf("visibility = %v, want personal", rec["visibility"])
|
|
}
|
|
if rec["owner_user_id"] != r.talA.id {
|
|
t.Errorf("owner_user_id = %v, want %s", rec["owner_user_id"], r.talA.id)
|
|
}
|
|
// 21. Skills do NOT have a version column
|
|
if _, hasVersion := rec["version"]; hasVersion {
|
|
t.Errorf("skill record has version field; skills must not have a version")
|
|
}
|
|
|
|
// 15. Valid organization skill -> 201
|
|
orgSkillMD := `---
|
|
id: org-skill
|
|
name: Org Skill
|
|
status: active
|
|
pages:
|
|
- candidates
|
|
---
|
|
# Org Skill
|
|
`
|
|
resOrg := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{
|
|
"markdown": orgSkillMD,
|
|
"visibility": "organization",
|
|
})
|
|
if resOrg.code != http.StatusCreated {
|
|
t.Fatalf("create org skill: got %d (%v)", resOrg.code, resOrg.body)
|
|
}
|
|
|
|
// 16. Invalid Markdown -> 422
|
|
resEmpty := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{
|
|
"markdown": "",
|
|
})
|
|
if resEmpty.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("empty skill markdown: got %d, want 422", resEmpty.code)
|
|
}
|
|
|
|
// 17. Invalid definition_id -> 422
|
|
badIDMD := `---
|
|
id: BAD_SKILL
|
|
name: Bad Skill
|
|
pages:
|
|
- candidates
|
|
---
|
|
`
|
|
resBadID := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{
|
|
"markdown": badIDMD,
|
|
})
|
|
if resBadID.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("bad skill id: got %d, want 422", resBadID.code)
|
|
}
|
|
|
|
// 18. Invalid page -> 422
|
|
badPageMD := `---
|
|
id: bad-page-skill
|
|
name: Bad Page Skill
|
|
pages:
|
|
- totally_unknown_page_xyz
|
|
---
|
|
`
|
|
resBadPage := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{
|
|
"markdown": badPageMD,
|
|
})
|
|
if resBadPage.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("bad skill page: got %d, want 422 (%v)", resBadPage.code, resBadPage.body)
|
|
}
|
|
|
|
// 19. Duplicate personal skill -> 409
|
|
resDupPers := r.as(r.talA, "POST", "/api/v1/skill-definitions", map[string]any{
|
|
"markdown": validSkillMD,
|
|
"visibility": "personal",
|
|
})
|
|
if resDupPers.code != http.StatusConflict {
|
|
t.Errorf("duplicate personal skill: got %d, want 409 (%v)", resDupPers.code, resDupPers.body)
|
|
}
|
|
|
|
// 20. Duplicate organization skill -> 409
|
|
resDupOrg := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{
|
|
"markdown": orgSkillMD,
|
|
"visibility": "organization",
|
|
})
|
|
if resDupOrg.code != http.StatusConflict {
|
|
t.Errorf("duplicate org skill: got %d, want 409 (%v)", resDupOrg.code, resDupOrg.body)
|
|
}
|
|
}
|
|
|
|
/* ── 3. List Tests ────────────────────────────────────────────────────────── */
|
|
|
|
func TestDefinitionsList(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
// Create:
|
|
// - 1 org agent (admin)
|
|
// - 1 personal agent for talA
|
|
// - 1 personal agent for talB
|
|
// - 1 org agent for outsider (in other org)
|
|
r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": `---
|
|
id: org-agent-1
|
|
name: Org Agent 1
|
|
pages:
|
|
- candidates
|
|
---
|
|
`,
|
|
"visibility": "organization",
|
|
})
|
|
r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": `---
|
|
id: tala-agent
|
|
name: TalA Agent
|
|
pages:
|
|
- candidates
|
|
---
|
|
`,
|
|
"visibility": "personal",
|
|
})
|
|
r.as(r.talB, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": `---
|
|
id: talb-agent
|
|
name: TalB Agent
|
|
pages:
|
|
- candidates
|
|
---
|
|
`,
|
|
"visibility": "personal",
|
|
})
|
|
r.as(r.outsider, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": `---
|
|
id: outsider-agent
|
|
name: Outsider Agent
|
|
pages:
|
|
- candidates
|
|
---
|
|
`,
|
|
"visibility": "organization",
|
|
})
|
|
|
|
// 22, 23, 24, 25. Scoping assertions
|
|
talAList := r.as(r.talA, "GET", "/api/v1/agent-definitions", nil)
|
|
if talAList.code != http.StatusOK {
|
|
t.Fatalf("talA list: %d", talAList.code)
|
|
}
|
|
talARecs := talAList.records(t)
|
|
talAIDMap := map[string]bool{}
|
|
for _, rec := range talARecs {
|
|
talAIDMap[rec["definition_id"].(string)] = true
|
|
}
|
|
|
|
if !talAIDMap["org-agent-1"] {
|
|
t.Errorf("talA should see org-agent-1")
|
|
}
|
|
if !talAIDMap["tala-agent"] {
|
|
t.Errorf("talA should see tala-agent")
|
|
}
|
|
if talAIDMap["talb-agent"] {
|
|
t.Errorf("talA must NOT see talB's personal agent")
|
|
}
|
|
if talAIDMap["outsider-agent"] {
|
|
t.Errorf("talA must NOT see outsider organization's agent")
|
|
}
|
|
|
|
// 26. Visibility filter
|
|
onlyPersonal := r.as(r.talA, "GET", "/api/v1/agent-definitions?visibility=personal", nil).records(t)
|
|
for _, rec := range onlyPersonal {
|
|
if rec["visibility"] != "personal" {
|
|
t.Errorf("expected only personal visibility, got %v", rec["visibility"])
|
|
}
|
|
}
|
|
onlyOrg := r.as(r.talA, "GET", "/api/v1/agent-definitions?visibility=organization", nil).records(t)
|
|
for _, rec := range onlyOrg {
|
|
if rec["visibility"] != "organization" {
|
|
t.Errorf("expected only organization visibility, got %v", rec["visibility"])
|
|
}
|
|
}
|
|
badVis := r.as(r.talA, "GET", "/api/v1/agent-definitions?visibility=invalid_vis", nil)
|
|
if badVis.code != http.StatusBadRequest {
|
|
t.Errorf("bad visibility filter: got %d, want 400", badVis.code)
|
|
}
|
|
|
|
// 27. Status filter
|
|
filteredStatus := r.as(r.talA, "GET", "/api/v1/agent-definitions?status=draft", nil).records(t)
|
|
for _, rec := range filteredStatus {
|
|
if rec["status"] != "draft" {
|
|
t.Errorf("expected draft status, got %v", rec["status"])
|
|
}
|
|
}
|
|
|
|
// 28. Definition ID filter
|
|
defIDList := r.as(r.talA, "GET", "/api/v1/agent-definitions?definition_id=tala-agent", nil).records(t)
|
|
if len(defIDList) != 1 || defIDList[0]["definition_id"] != "tala-agent" {
|
|
t.Errorf("definition_id filter failed: got %v", defIDList)
|
|
}
|
|
|
|
// 29. Pagination
|
|
page1 := r.as(r.talA, "GET", "/api/v1/agent-definitions?limit=1&offset=0", nil)
|
|
meta1 := page1.meta(t)
|
|
if fmt.Sprint(meta1["limit"]) != "1" || fmt.Sprint(meta1["offset"]) != "0" {
|
|
t.Errorf("pagination meta: %v", meta1)
|
|
}
|
|
|
|
// 30. Stable sorting
|
|
sortedAsc := r.as(r.talA, "GET", "/api/v1/agent-definitions?sort=definition_id", nil).records(t)
|
|
if len(sortedAsc) >= 2 {
|
|
id0 := sortedAsc[0]["definition_id"].(string)
|
|
id1 := sortedAsc[1]["definition_id"].(string)
|
|
if id0 > id1 {
|
|
t.Errorf("ascending sort failed: %s > %s", id0, id1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── 4. Get By ID Tests ───────────────────────────────────────────────────── */
|
|
|
|
func TestDefinitionsGet(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
// Create personal agent for talA
|
|
createA := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": `---
|
|
id: get-pers-a
|
|
name: Get Pers A
|
|
pages:
|
|
- candidates
|
|
---
|
|
`,
|
|
"visibility": "personal",
|
|
})
|
|
idPersA := createA.record(t)["id"].(string)
|
|
|
|
// Create org agent
|
|
createOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": `---
|
|
id: get-org
|
|
name: Get Org
|
|
pages:
|
|
- candidates
|
|
---
|
|
`,
|
|
"visibility": "organization",
|
|
})
|
|
idOrg := createOrg.record(t)["id"].(string)
|
|
|
|
// Create personal agent for outsider
|
|
createOutsider := r.as(r.outsider, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": `---
|
|
id: get-pers-outsider
|
|
name: Get Pers Outsider
|
|
pages:
|
|
- candidates
|
|
---
|
|
`,
|
|
"visibility": "personal",
|
|
})
|
|
idOutsider := createOutsider.record(t)["id"].(string)
|
|
|
|
// 31. Own personal definition -> 200
|
|
getPersA := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idPersA, nil)
|
|
if getPersA.code != http.StatusOK {
|
|
t.Errorf("get own personal agent: %d", getPersA.code)
|
|
}
|
|
|
|
// 32. Same-org organization definition -> 200
|
|
getOrgByTal := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idOrg, nil)
|
|
if getOrgByTal.code != http.StatusOK {
|
|
t.Errorf("get same-org agent: %d", getOrgByTal.code)
|
|
}
|
|
|
|
// 33. Other user's personal definition -> 404 (inaccessible)
|
|
getPersByTalB := r.as(r.talB, "GET", "/api/v1/agent-definitions/"+idPersA, nil)
|
|
if getPersByTalB.code != http.StatusNotFound {
|
|
t.Errorf("get other user personal agent: got %d, want 404", getPersByTalB.code)
|
|
}
|
|
|
|
// 34. Other organization's definition -> 404 (inaccessible)
|
|
getOutsiderByTalA := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idOutsider, nil)
|
|
if getOutsiderByTalA.code != http.StatusNotFound {
|
|
t.Errorf("get outsider definition: got %d, want 404", getOutsiderByTalA.code)
|
|
}
|
|
|
|
// Malformed UUID -> 404
|
|
getMalformed := r.as(r.talA, "GET", "/api/v1/agent-definitions/not-a-uuid", nil)
|
|
if getMalformed.code != http.StatusNotFound {
|
|
t.Errorf("get malformed uuid: got %d, want 404", getMalformed.code)
|
|
}
|
|
}
|
|
|
|
/* ── 5. Patch Tests ───────────────────────────────────────────────────────── */
|
|
|
|
func TestDefinitionsPatch(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
// Create personal agent for talA
|
|
createA := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": `---
|
|
id: patch-agent
|
|
name: Initial Name
|
|
version: 1
|
|
pages:
|
|
- candidates
|
|
---
|
|
Initial Body`,
|
|
"visibility": "personal",
|
|
})
|
|
idA := createA.record(t)["id"].(string)
|
|
origCreated := createA.record(t)["created_date"].(string)
|
|
origUpdated := createA.record(t)["updated_date"].(string)
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// 35, 36, 37, 38. Markdown update -> 200, projections updated, markdown verbatim, updated_date changed
|
|
updatedMD := `---
|
|
id: patch-agent
|
|
name: Updated Name
|
|
version: 2
|
|
status: published
|
|
pages:
|
|
- candidates
|
|
- positions
|
|
---
|
|
|
|
# Updated Body
|
|
Verbatim content with trailing spaces
|
|
`
|
|
patchRes := r.as(r.talA, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{
|
|
"markdown": updatedMD,
|
|
})
|
|
if patchRes.code != http.StatusOK {
|
|
t.Fatalf("patch agent: %d (%v)", patchRes.code, patchRes.body)
|
|
}
|
|
patchedRec := patchRes.record(t)
|
|
if patchedRec["name"] != "Updated Name" {
|
|
t.Errorf("name = %v, want Updated Name", patchedRec["name"])
|
|
}
|
|
if patchedRec["status"] != "published" {
|
|
t.Errorf("status = %v, want published", patchedRec["status"])
|
|
}
|
|
if fmt.Sprint(patchedRec["version"]) != "2" {
|
|
t.Errorf("version = %v, want 2", patchedRec["version"])
|
|
}
|
|
if patchedRec["markdown"] != updatedMD {
|
|
t.Errorf("markdown not verbatim:\n got: %q\nwant: %q", patchedRec["markdown"], updatedMD)
|
|
}
|
|
if patchedRec["created_date"] != origCreated {
|
|
t.Errorf("created_date changed on patch")
|
|
}
|
|
if patchedRec["updated_date"] == origUpdated {
|
|
t.Errorf("updated_date did not advance")
|
|
}
|
|
|
|
// 39. Invalid Markdown update -> 422
|
|
badPatch := r.as(r.talA, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{
|
|
"markdown": "--- invalid yaml --",
|
|
})
|
|
if badPatch.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("invalid patch md: got %d, want 422", badPatch.code)
|
|
}
|
|
|
|
// 40. Server-owned fields cannot be modified
|
|
spoofPatch := r.as(r.talA, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{
|
|
"owner_user_id": r.talB.id,
|
|
"org_id": r.otherOrgID,
|
|
"created_by": r.admin.id,
|
|
})
|
|
if spoofPatch.code != http.StatusOK {
|
|
t.Errorf("spoof patch status: %d", spoofPatch.code)
|
|
}
|
|
reread := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idA, nil).record(t)
|
|
if reread["owner_user_id"] != r.talA.id {
|
|
t.Errorf("owner_user_id altered on patch: %v", reread["owner_user_id"])
|
|
}
|
|
if reread["org_id"] != r.orgID {
|
|
t.Errorf("org_id altered on patch: %v", reread["org_id"])
|
|
}
|
|
|
|
// 41. Unauthorized update: talB cannot patch talA's definition -> 404
|
|
resTalBPatch := r.as(r.talB, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{
|
|
"status": "archived",
|
|
})
|
|
if resTalBPatch.code != http.StatusNotFound {
|
|
t.Errorf("talB patch talA: got %d, want 404", resTalBPatch.code)
|
|
}
|
|
|
|
// Create org agent
|
|
createOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": `---
|
|
id: org-for-patch
|
|
name: Org Patch
|
|
pages:
|
|
- candidates
|
|
---
|
|
`,
|
|
"visibility": "organization",
|
|
})
|
|
idOrg := createOrg.record(t)["id"].(string)
|
|
|
|
// Talent cannot patch org definition -> 403
|
|
talOrgPatch := r.as(r.talA, "PATCH", "/api/v1/agent-definitions/"+idOrg, map[string]any{
|
|
"status": "archived",
|
|
})
|
|
if talOrgPatch.code != http.StatusForbidden {
|
|
t.Errorf("talent patch org agent: got %d, want 403", talOrgPatch.code)
|
|
}
|
|
|
|
// Employer can patch org definition -> 200
|
|
empOrgPatch := r.as(r.empA, "PATCH", "/api/v1/agent-definitions/"+idOrg, map[string]any{
|
|
"status": "archived",
|
|
})
|
|
if empOrgPatch.code != http.StatusOK {
|
|
t.Errorf("employer patch org agent: got %d, want 200", empOrgPatch.code)
|
|
}
|
|
|
|
// Visibility is immutable after creation -> 422
|
|
visPatch := r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+idOrg, map[string]any{
|
|
"visibility": "personal",
|
|
})
|
|
if visPatch.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("visibility mutation: got %d, want 422 (%v)", visPatch.code, visPatch.body)
|
|
}
|
|
}
|
|
|
|
/* ── 6. Delete Tests ──────────────────────────────────────────────────────── */
|
|
|
|
func TestDefinitionsDelete(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
// Create personal agent for talA
|
|
createA := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": `---
|
|
id: del-agent-a
|
|
name: Del Agent A
|
|
pages:
|
|
- candidates
|
|
---
|
|
`,
|
|
"visibility": "personal",
|
|
})
|
|
idA := createA.record(t)["id"].(string)
|
|
|
|
// Create org agent
|
|
createOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": `---
|
|
id: del-agent-org
|
|
name: Del Agent Org
|
|
pages:
|
|
- candidates
|
|
---
|
|
`,
|
|
"visibility": "organization",
|
|
})
|
|
idOrg := createOrg.record(t)["id"].(string)
|
|
|
|
// 46. Talent cannot delete org definition -> 403
|
|
talDelOrg := r.as(r.talA, "DELETE", "/api/v1/agent-definitions/"+idOrg, nil)
|
|
if talDelOrg.code != http.StatusForbidden {
|
|
t.Errorf("talent delete org agent: got %d, want 403", talDelOrg.code)
|
|
}
|
|
|
|
// 44, 48, 49. Owner can delete personal agent -> 200, returns { "data": { "id": ... } }, subsequent GET -> 404
|
|
delA := r.as(r.talA, "DELETE", "/api/v1/agent-definitions/"+idA, nil)
|
|
if delA.code != http.StatusOK {
|
|
t.Fatalf("delete personal agent: got %d", delA.code)
|
|
}
|
|
delRec := delA.record(t)
|
|
if delRec["id"] != idA {
|
|
t.Errorf("delete response id = %v, want %s", delRec["id"], idA)
|
|
}
|
|
getAAfter := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idA, nil)
|
|
if getAAfter.code != http.StatusNotFound {
|
|
t.Errorf("subsequent GET deleted agent: got %d, want 404", getAAfter.code)
|
|
}
|
|
|
|
// 45. Operator (employer) can delete org definition -> 200
|
|
delOrg := r.as(r.empA, "DELETE", "/api/v1/agent-definitions/"+idOrg, nil)
|
|
if delOrg.code != http.StatusOK {
|
|
t.Fatalf("employer delete org agent: got %d", delOrg.code)
|
|
}
|
|
getOrgAfter := r.as(r.admin, "GET", "/api/v1/agent-definitions/"+idOrg, nil)
|
|
if getOrgAfter.code != http.StatusNotFound {
|
|
t.Errorf("subsequent GET deleted org agent: got %d, want 404", getOrgAfter.code)
|
|
}
|
|
|
|
// Idempotent delete on non-existent UUID -> 200
|
|
missingUUID := "00000000-0000-0000-0000-000000000000"
|
|
delMissing := r.as(r.talA, "DELETE", "/api/v1/agent-definitions/"+missingUUID, nil)
|
|
if delMissing.code != http.StatusOK {
|
|
t.Errorf("idempotent delete: got %d, want 200", delMissing.code)
|
|
}
|
|
}
|
|
|
|
/* ── 7. Security and SQL Injection ────────────────────────────────────────── */
|
|
|
|
func TestSecurityAndSQLInjection(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
// SQL injection in filter
|
|
sqliList := r.as(r.talA, "GET", "/api/v1/agent-definitions?definition_id=x'%20OR%20'1'='1", nil)
|
|
if sqliList.code != http.StatusOK {
|
|
t.Errorf("sqli filter request failed: %d", sqliList.code)
|
|
}
|
|
if len(sqliList.records(t)) != 0 {
|
|
t.Errorf("sqli in definition_id filter leaked records")
|
|
}
|
|
|
|
// SQL injection in sort
|
|
sqliSort := r.as(r.talA, "GET", "/api/v1/agent-definitions?sort=name%20DESC%3BDROP%20TABLE%20users%3B", nil)
|
|
if sqliSort.code != http.StatusBadRequest {
|
|
t.Errorf("sqli in sort should be rejected as invalid query: got %d (%v)", sqliSort.code, sqliSort.body)
|
|
}
|
|
|
|
// Unauthenticated requests -> 401
|
|
unauthList := r.doAnon("GET", "/api/v1/agent-definitions", nil)
|
|
if unauthList.code != http.StatusUnauthorized {
|
|
t.Errorf("unauth list: got %d, want 401", unauthList.code)
|
|
}
|
|
unauthCreate := r.doAnon("POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": validAgentMD,
|
|
})
|
|
if unauthCreate.code != http.StatusUnauthorized {
|
|
t.Errorf("unauth create: got %d, want 401", unauthCreate.code)
|
|
}
|
|
}
|
|
|
|
/* ── 8. Full CRUD & Projection Consistency Flow ───────────────────────────── */
|
|
|
|
func TestFullCRUDFlowAndProjections(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
// 58. Create
|
|
createRes := r.as(r.empA, "POST", "/api/v1/skill-definitions", map[string]any{
|
|
"markdown": validSkillMD,
|
|
"visibility": "organization",
|
|
})
|
|
if createRes.code != http.StatusCreated {
|
|
t.Fatalf("create skill failed: %d (%v)", createRes.code, createRes.body)
|
|
}
|
|
id := createRes.record(t)["id"].(string)
|
|
|
|
// 59. List includes created record
|
|
listRes := r.as(r.empA, "GET", "/api/v1/skill-definitions?definition_id=test-skill", nil)
|
|
if listRes.code != http.StatusOK || len(listRes.records(t)) == 0 {
|
|
t.Fatalf("list skill failed: %d (%v)", listRes.code, listRes.body)
|
|
}
|
|
|
|
// 60. Get created record and verify projections
|
|
getRes := r.as(r.talA, "GET", "/api/v1/skill-definitions/"+id, nil)
|
|
if getRes.code != http.StatusOK {
|
|
t.Fatalf("get skill failed: %d", getRes.code)
|
|
}
|
|
rec := getRes.record(t)
|
|
if rec["definition_id"] != "test-skill" || rec["name"] != "Test Skill" || rec["status"] != "active" {
|
|
t.Errorf("projection mismatch on get: %v", rec)
|
|
}
|
|
if rec["markdown"] != validSkillMD {
|
|
t.Errorf("markdown not verbatim on get")
|
|
}
|
|
|
|
// 61. Patch
|
|
newSkillMD := `---
|
|
id: test-skill
|
|
name: Updated Skill Name
|
|
status: inactive
|
|
pages:
|
|
- candidates
|
|
- profile
|
|
---
|
|
# Updated Skill Body
|
|
`
|
|
patchRes := r.as(r.empA, "PATCH", "/api/v1/skill-definitions/"+id, map[string]any{
|
|
"markdown": newSkillMD,
|
|
})
|
|
if patchRes.code != http.StatusOK {
|
|
t.Fatalf("patch skill failed: %d (%v)", patchRes.code, patchRes.body)
|
|
}
|
|
patchedRec := patchRes.record(t)
|
|
if patchedRec["name"] != "Updated Skill Name" || patchedRec["status"] != "inactive" {
|
|
t.Errorf("projection not updated on patch: %v", patchedRec)
|
|
}
|
|
if patchedRec["markdown"] != newSkillMD {
|
|
t.Errorf("markdown not verbatim on patch")
|
|
}
|
|
|
|
// 62. Delete
|
|
delRes := r.as(r.empA, "DELETE", "/api/v1/skill-definitions/"+id, nil)
|
|
if delRes.code != http.StatusOK {
|
|
t.Fatalf("delete skill failed: %d", delRes.code)
|
|
}
|
|
getAfterDel := r.as(r.empA, "GET", "/api/v1/skill-definitions/"+id, nil)
|
|
if getAfterDel.code != http.StatusNotFound {
|
|
t.Errorf("get after delete: got %d, want 404", getAfterDel.code)
|
|
}
|
|
}
|
|
|
|
/* ── Tool names are checked at publish ────────────────────────────────────── */
|
|
|
|
// §3: an unknown tool name fails validation at PUBLISH. Before this, the name
|
|
// was accepted, stored, and dropped by the runtime at resolve time — so an
|
|
// author got an agent that was silently missing a capability they believed they
|
|
// had chosen, and found out by watching it fail to answer.
|
|
func TestAgentCreateRejectsAnUnknownToolName(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
withTools := func(names string) string {
|
|
return strings.Replace(validAgentMD, "pages:\n - candidates",
|
|
"tools:\n"+names+"pages:\n - candidates", 1)
|
|
}
|
|
|
|
res := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": withTools(" - not_a_real_tool\n"),
|
|
"visibility": "personal",
|
|
})
|
|
if res.code != http.StatusBadRequest && res.code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("unknown tool accepted: status %d (%v)", res.code, res.body)
|
|
}
|
|
if body, _ := json.Marshal(res.body); !strings.Contains(string(body), "not_a_real_tool") {
|
|
t.Errorf("the error does not name the offending tool: %s", body)
|
|
}
|
|
|
|
// A real tool is accepted, so the check is not simply refusing everything.
|
|
ok := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": withTools(" - candidates_awaiting\n"),
|
|
"visibility": "personal",
|
|
})
|
|
if ok.code != http.StatusCreated {
|
|
t.Fatalf("a real tool was refused: status %d (%v)", ok.code, ok.body)
|
|
}
|
|
}
|
|
|
|
// TestPublishedVersionCannotBeRewritten covers §3: a published version is
|
|
// immutable, and editing publishes a NEW one.
|
|
//
|
|
// The failure this guards against was silent rather than loud. Editing a
|
|
// published agent without raising the frontmatter version used to answer 200:
|
|
// the live row took the new text, the append-only history kept the old, and
|
|
// two different definitions were both called v1. runtime.LoadAgentVersion
|
|
// resolves a pin by returning the CURRENT definition whenever the pinned
|
|
// number equals the current one, so a conversation "pinned to v1" then ran the
|
|
// rewritten instructions while the audit trail showed the originals.
|
|
func TestPublishedVersionCannotBeRewritten(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
const published = `---
|
|
id: pinned-agent
|
|
name: Pinned Agent
|
|
description: published, and therefore immutable at this version
|
|
status: published
|
|
version: 1
|
|
pages:
|
|
- candidates
|
|
---
|
|
|
|
## Instructions
|
|
The original instructions.
|
|
`
|
|
|
|
res := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": published,
|
|
"visibility": "personal",
|
|
})
|
|
if res.code != http.StatusCreated {
|
|
t.Fatalf("create published agent: status %d (%v)", res.code, res.body)
|
|
}
|
|
id, _ := res.record(t)["id"].(string)
|
|
if id == "" {
|
|
t.Fatal("created agent has no id")
|
|
}
|
|
|
|
// Same version number, different body: refused.
|
|
rewritten := strings.Replace(published,
|
|
"The original instructions.", "Rewritten instructions.", 1)
|
|
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+id,
|
|
map[string]any{"markdown": rewritten})
|
|
if res.code != http.StatusConflict {
|
|
t.Fatalf("rewriting published v1: status %d, want 409 (%v)", res.code, res.body)
|
|
}
|
|
|
|
// And the refusal actually protected something — the live definition is
|
|
// unchanged, not merely reported as unchanged.
|
|
res = r.as(r.admin, "GET", "/api/v1/agent-definitions/"+id, nil)
|
|
if res.code != http.StatusOK {
|
|
t.Fatalf("re-read agent: status %d (%v)", res.code, res.body)
|
|
}
|
|
md, _ := res.record(t)["markdown"].(string)
|
|
if !strings.Contains(md, "The original instructions.") {
|
|
t.Errorf("the refused edit still changed the stored definition:\n%s", md)
|
|
}
|
|
if strings.Contains(md, "Rewritten instructions.") {
|
|
t.Errorf("the refused edit was applied anyway:\n%s", md)
|
|
}
|
|
|
|
// Republishing the SAME version with the SAME content stays a no-op, so a
|
|
// save that changes nothing is not turned into an error.
|
|
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+id,
|
|
map[string]any{"markdown": published})
|
|
if res.code != http.StatusOK {
|
|
t.Errorf("republishing v1 unchanged: status %d, want 200 (%v)", res.code, res.body)
|
|
}
|
|
|
|
// Raising the version is the supported way to publish a change.
|
|
bumped := strings.Replace(rewritten, "version: 1", "version: 2", 1)
|
|
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+id,
|
|
map[string]any{"markdown": bumped})
|
|
if res.code != http.StatusOK {
|
|
t.Fatalf("publishing v2: status %d, want 200 (%v)", res.code, res.body)
|
|
}
|
|
res = r.as(r.admin, "GET", "/api/v1/agent-definitions/"+id, nil)
|
|
md, _ = res.record(t)["markdown"].(string)
|
|
if !strings.Contains(md, "Rewritten instructions.") {
|
|
t.Errorf("v2 did not take the new text:\n%s", md)
|
|
}
|
|
|
|
// A draft carries no such promise: it is not published, so it may be
|
|
// rewritten in place as often as its author likes.
|
|
const draft = `---
|
|
id: draft-agent
|
|
name: Draft Agent
|
|
description: still a draft
|
|
status: draft
|
|
version: 1
|
|
pages:
|
|
- candidates
|
|
---
|
|
|
|
## Instructions
|
|
First draft.
|
|
`
|
|
res = r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
|
"markdown": draft, "visibility": "personal",
|
|
})
|
|
if res.code != http.StatusCreated {
|
|
t.Fatalf("create draft: status %d (%v)", res.code, res.body)
|
|
}
|
|
draftID, _ := res.record(t)["id"].(string)
|
|
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+draftID,
|
|
map[string]any{"markdown": strings.Replace(draft, "First draft.", "Second draft.", 1)})
|
|
if res.code != http.StatusOK {
|
|
t.Errorf("rewriting a draft at the same version: status %d, want 200 (%v)", res.code, res.body)
|
|
}
|
|
}
|