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") } }