update the archive options
Some checks failed
CI / test (push) Failing after 4m40s
CI / fixture (push) Failing after 7s

This commit is contained in:
2026-09-10 19:30:46 +05:30
parent 74089eb3e9
commit 4e1f746b22
5 changed files with 388 additions and 0 deletions

View File

@@ -8,6 +8,8 @@ import (
"strings"
"testing"
"time"
"github.com/krow/krow-backend/go-api/internal/httpserver"
)
// Phase 4E — Backend CRUD APIs for authored Agent and Skill definitions.
@@ -1327,3 +1329,194 @@ Ask myself.
t.Fatal("an agent naming itself as its own subagent was published")
}
}
/* ── 8. Curated (built-in) agent protection ───────────────────────────────── */
// agentMD builds a minimal valid agent definition for a given id.
func agentMD(id, name string) string {
return fmt.Sprintf("---\nid: %s\nname: %s\nstatus: draft\nversion: 1\npages:\n - candidates\n---\n\n## Instructions\nDo the thing.\n", id, name)
}
// TestCuratedAgentIsNotDeletable covers the protection the Agents list implies
// but React alone cannot enforce.
//
// A curated agent is published by `importagents` as an ordinary organization
// row, so nothing in the table distinguishes it from a shared agent somebody
// authored — the distinguishing fact is that the deployment ships its spec.
// Without a check at the endpoint, any operator with a terminal could delete
// the definition the RUNTIME resolves from, leaving the agent in the list and
// every run of it answering 404.
func TestCuratedAgentIsNotDeletable(t *testing.T) {
a := newAPI(t, httpserver.WithCuratedAgents("curated-agent"))
// What importagents publishes: the curated spec, at organization visibility.
curated := a.do("POST", "/api/v1/agent-definitions", map[string]any{
"markdown": agentMD("curated-agent", "Curated Agent"),
"visibility": "organization",
})
if curated.code != http.StatusCreated && curated.code != http.StatusOK {
t.Fatalf("publish curated agent: got %d", curated.code)
}
curatedID := curated.record(t)["id"].(string)
// The admin who may delete any other organization definition is refused
// this one.
del := a.do("DELETE", "/api/v1/agent-definitions/"+curatedID, nil)
if del.code != http.StatusForbidden {
t.Errorf("delete curated agent: got %d, want 403", del.code)
}
// And it is still there — refused, not deleted-then-reported.
after := a.do("GET", "/api/v1/agent-definitions/"+curatedID, nil)
if after.code != http.StatusOK {
t.Fatalf("curated agent after refused delete: got %d, want 200", after.code)
}
if got := after.record(t)["definition_id"]; got != "curated-agent" {
t.Errorf("curated agent definition_id = %v, want curated-agent", got)
}
}
// TestPersonalOverrideOfCuratedAgentStaysDeletable protects the revert path.
//
// "Revert to shipped" in the Agents list deletes the account's own definition
// of a shipped id. Protecting by id alone would break it, so the guard is
// scoped to the organization tier — this is the test that says so.
func TestPersonalOverrideOfCuratedAgentStaysDeletable(t *testing.T) {
a := newAPI(t, httpserver.WithCuratedAgents("curated-agent"))
override := a.do("POST", "/api/v1/agent-definitions", map[string]any{
"markdown": agentMD("curated-agent", "My Version"),
"visibility": "personal",
})
if override.code != http.StatusCreated && override.code != http.StatusOK {
t.Fatalf("create personal override: got %d", override.code)
}
overrideID := override.record(t)["id"].(string)
del := a.do("DELETE", "/api/v1/agent-definitions/"+overrideID, nil)
if del.code != http.StatusOK {
t.Errorf("delete personal override of a curated id: got %d, want 200", del.code)
}
after := a.do("GET", "/api/v1/agent-definitions/"+overrideID, nil)
if after.code != http.StatusNotFound {
t.Errorf("override after delete: got %d, want 404", after.code)
}
}
// TestCustomAgentDeleteIsIsolated is the isolation case: removing one custom
// agent removes that agent and nothing else.
func TestCustomAgentDeleteIsIsolated(t *testing.T) {
a := newAPI(t, httpserver.WithCuratedAgents("curated-agent"))
// A curated agent, a second custom agent, and a skill — none of which the
// delete below is about.
curated := a.do("POST", "/api/v1/agent-definitions", map[string]any{
"markdown": agentMD("curated-agent", "Curated Agent"), "visibility": "organization",
}).record(t)["id"].(string)
keep := a.do("POST", "/api/v1/agent-definitions", map[string]any{
"markdown": agentMD("keep-me", "Keep Me"), "visibility": "personal",
}).record(t)["id"].(string)
skill := a.do("POST", "/api/v1/skill-definitions", map[string]any{
"markdown": validSkillMD, "visibility": "personal",
}).record(t)["id"].(string)
target := a.do("POST", "/api/v1/agent-definitions", map[string]any{
"markdown": agentMD("remove-me", "Remove Me"), "visibility": "personal",
}).record(t)["id"].(string)
if got := a.do("DELETE", "/api/v1/agent-definitions/"+target, nil); got.code != http.StatusOK {
t.Fatalf("delete custom agent: got %d", got.code)
}
// Gone.
if got := a.do("GET", "/api/v1/agent-definitions/"+target, nil); got.code != http.StatusNotFound {
t.Errorf("removed agent: got %d, want 404", got.code)
}
// Everything else untouched.
for name, id := range map[string]string{"curated agent": curated, "other custom agent": keep} {
if got := a.do("GET", "/api/v1/agent-definitions/"+id, nil); got.code != http.StatusOK {
t.Errorf("%s after an unrelated delete: got %d, want 200", name, got.code)
}
}
if got := a.do("GET", "/api/v1/skill-definitions/"+skill, nil); got.code != http.StatusOK {
t.Errorf("skill after an unrelated agent delete: got %d, want 200", got.code)
}
}
// TestArchiveAndRestorePreserveTheSameAgent is the persistence half of Remove.
//
// Removing an authored agent archives it. That claim is only worth anything if
// archiving keeps the row: the same uuid, the same definition_id and the same
// Markdown, so restoring returns the agent somebody wrote rather than a new one
// wearing its name. This asserts the round trip against the real endpoints.
func TestArchiveAndRestorePreserveTheSameAgent(t *testing.T) {
a := newAPI(t, httpserver.WithCuratedAgents("curated-agent"))
const live = `---
id: coverage-helper
name: Coverage Helper
status: published
version: 3
pages:
- candidates
---
## Instructions
Find the shifts nobody has taken.
`
created := a.do("POST", "/api/v1/agent-definitions", map[string]any{
"markdown": live, "visibility": "personal",
})
if created.code != http.StatusCreated && created.code != http.StatusOK {
t.Fatalf("create agent: got %d", created.code)
}
rec := created.record(t)
id := rec["id"].(string)
definitionID := rec["definition_id"]
// Remove -> archive. Same row, same body, only the status moves.
archived := a.do("PATCH", "/api/v1/agent-definitions/"+id, map[string]any{
"markdown": strings.Replace(live, "status: published", "status: archived", 1),
})
if archived.code != http.StatusOK {
t.Fatalf("archive agent: got %d", archived.code)
}
arc := archived.record(t)
if arc["status"] != "archived" {
t.Errorf("status after remove = %v, want archived", arc["status"])
}
if arc["id"] != id || arc["definition_id"] != definitionID {
t.Errorf("identity changed on archive: %v/%v, want %s/%v",
arc["id"], arc["definition_id"], id, definitionID)
}
if !strings.Contains(arc["markdown"].(string), "Find the shifts nobody has taken.") {
t.Error("instructions were lost when the agent was archived")
}
// It is still there — removal is not deletion.
if got := a.do("GET", "/api/v1/agent-definitions/"+id, nil); got.code != http.StatusOK {
t.Fatalf("removed agent should still be readable: got %d, want 200", got.code)
}
// Restore -> the SAME agent, as a draft.
restored := a.do("PATCH", "/api/v1/agent-definitions/"+id, map[string]any{
"markdown": strings.Replace(live, "status: published", "status: draft", 1),
})
if restored.code != http.StatusOK {
t.Fatalf("restore agent: got %d", restored.code)
}
res := restored.record(t)
if res["status"] != "draft" {
t.Errorf("status after restore = %v, want draft", res["status"])
}
if res["id"] != id || res["definition_id"] != definitionID {
t.Errorf("restore created a different agent: %v/%v, want %s/%v",
res["id"], res["definition_id"], id, definitionID)
}
if !strings.Contains(res["markdown"].(string), "Find the shifts nobody has taken.") {
t.Error("instructions were lost on the round trip")
}
if got := res["version"]; got != arc["version"] {
t.Errorf("version moved on a restore: %v -> %v", arc["version"], got)
}
}