1package daemon23import (4 "context"5 "encoding/json"6 "net/http/httptest"7 "os/exec"8 "path/filepath"9 "strings"10 "testing"11 "time"1213 "github.com/FelineStateMachine/tinyrelay/internal/event"14 gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"15)1617const testOwnerNPub = "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"1819// agentTestRepo announces one repository from the test owner and returns a20// working clone with one commit on main, plus that commit's id.21func agentTestRepo(t *testing.T, tenant *Tenant, identifier string) (string, string) {22 t.Helper()23 now := time.Now().Unix()24 announcement := signedEvent(t, testOwnerSecret, event.KIND_REPO, now, [][]string{{"d", identifier}, {"clone", "http://relay.test/" + testOwnerNPub + "/" + identifier + ".git"}, {"relays", "ws://relay.test"}}, "")25 if err := publishAs(t, tenant, announcement); err != nil {26 t.Fatalf("announcement rejected: %v", err)27 }28 source := filepath.Join(t.TempDir(), "source.git")29 gitTest(t, "init", "--bare", source)30 work := filepath.Join(t.TempDir(), "work")31 gitTest(t, "clone", source, work)32 gitTest(t, "-C", work, "config", "user.email", "test@example.com")33 gitTest(t, "-C", work, "config", "user.name", "Test")34 if err := osWrite(filepath.Join(work, "README"), []byte("agent\n")); err != nil {35 t.Fatal(err)36 }37 gitTest(t, "-C", work, "add", "README")38 gitTest(t, "-C", work, "commit", "-m", "first")39 gitTest(t, "-C", work, "branch", "-M", "main")40 return work, strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))41}4243func agentStateEvent(t *testing.T, identifier, sha string, createdAt int64) event.Event {44 t.Helper()45 return signedEvent(t, testAgentSecret, event.KIND_REPO_STATE, createdAt, [][]string{{"d", identifier}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}}, "")46}4748func waitForRef(t *testing.T, tenant *Tenant, owner, identifier, ref, sha string) {49 t.Helper()50 deadline := time.Now().Add(10 * time.Second)51 for time.Now().Before(deadline) {52 if r, err := tenant.git.BrowseRepository(owner, identifier); err == nil && r.Refs[ref] == sha {53 return54 }55 time.Sleep(20 * time.Millisecond)56 }57 t.Fatalf("%s did not reach %s", ref, sha)58}5960func TestAgentMaintainGrantMakesTheAgentARepositoryMaintainer(t *testing.T) {61 ctx := context.Background()62 app, tenant := testTenant(t)63 p := tenant.Policy()64 p.Features.Grasp = true65 if err := tenant.applyPolicy(ctx, p); err != nil {66 t.Fatal(err)67 }68 owner, _ := event.PublicKey(testOwnerSecret)69 agent, _ := event.PublicKey(testAgentSecret)70 now := time.Now().Unix()71 work, sha := agentTestRepo(t, tenant, "agentrepo")72 repo := gitrelay.Repository{Owner: owner, Identifier: "agentrepo"}73 coordinate := "30617:" + owner + ":agentrepo"74 grantWith := func(level string, createdAt int64) event.Event {75 return agentGrantEvent(t, agent, createdAt, createdAt+3600, []string{"repo", owner + ":agentrepo:" + level}, []string{"k", "30618"}, []string{"k", "1632"})76 }7778 // A read grant lets the agent publish, but it is not a maintainer.79 if err := publishAs(t, tenant, grantWith("read", now)); err != nil {80 t.Fatalf("read grant rejected: %v", err)81 }82 if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", sha, now+1)); err == nil || !strings.Contains(err.Error(), "not an accepted repository maintainer") {83 t.Fatalf("state under a read grant: %v", err)84 }85 if tenant.IsMaintainer(ctx, repo, agent) {86 t.Fatal("read grant counted as maintainer")87 }8889 // A maintain grant makes the agent a maintainer: its state is accepted90 // and a push matching that state goes through.91 if err := publishAs(t, tenant, grantWith("maintain", now+2)); err != nil {92 t.Fatalf("maintain grant rejected: %v", err)93 }94 if !tenant.IsMaintainer(ctx, repo, agent) {95 t.Fatal("maintain grant did not count as maintainer")96 }97 if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", sha, now+3)); err != nil {98 t.Fatalf("state under a maintain grant rejected: %v", err)99 }100 server := httptest.NewServer(app)101 defer server.Close()102 url := server.URL + "/r/main/" + testOwnerNPub + "/agentrepo.git"103 gitTest(t, "-C", work, "push", url, "refs/heads/main:refs/heads/main")104 waitForRef(t, tenant, owner, "agentrepo", "refs/heads/main", sha)105106 // Browse output names the agent as a maintainer with its grant name.107 raw, _ := json.Marshal(map[string]any{"owner": owner, "repo": "agentrepo", "view": "tree"})108 result, err := tenant.Execute(ctx, "", "browserepo", []json.RawMessage{raw})109 if err != nil {110 t.Fatalf("browserepo: %v", err)111 }112 page, ok := result.(gitrelay.BrowsePage)113 if !ok {114 t.Fatalf("browserepo result %T", result)115 }116 if len(page.Maintainers) != 2 || page.Maintainers[0] != (gitrelay.Maintainer{PubKey: owner, Role: "owner"}) || page.Maintainers[1] != (gitrelay.Maintainer{PubKey: agent, Role: "agent", Name: "helper"}) {117 t.Fatalf("maintainers = %+v", page.Maintainers)118 }119120 // The agent's status change counts on an issue it did not open.121 issue := signedEvent(t, testOwnerSecret, event.KIND_GIT_ISSUE, now+4, [][]string{{"a", coordinate}, {"p", owner}, {"subject", "Bug"}}, "Details")122 if err := publishAs(t, tenant, issue); err != nil {123 t.Fatalf("issue rejected: %v", err)124 }125 closed := signedEvent(t, testAgentSecret, 1632, now+5, [][]string{{"a", coordinate}, {"e", issue.ID, "", "root"}, {"p", owner}}, "")126 if err := publishAs(t, tenant, closed); err != nil {127 t.Fatalf("agent status rejected: %v", err)128 }129 raw, _ = json.Marshal(map[string]any{"owner": owner, "repo": "agentrepo", "event": issue.ID})130 result, err = tenant.Execute(ctx, agent, "browseissue", []json.RawMessage{raw})131 if err != nil {132 t.Fatalf("browseissue: %v", err)133 }134 detail := result.(collaborationDetail)135 if detail.Item.Status != "closed" || !detail.CanStatus {136 t.Fatalf("status = %q can_status = %v, want closed by the agent maintainer", detail.Item.Status, detail.CanStatus)137 }138 maintainers, _ := detail.Repository["maintainers"].([]gitrelay.Maintainer)139 if len(maintainers) != 2 || maintainers[1].Role != "agent" {140 t.Fatalf("detail maintainers = %+v", maintainers)141 }142143 // A second commit needs a new state; a paused grant signs nothing that144 // counts, so the push is refused by the signed-ref hook.145 if err := osWrite(filepath.Join(work, "README"), []byte("agent again\n")); err != nil {146 t.Fatal(err)147 }148 gitTest(t, "-C", work, "commit", "-am", "second")149 next := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))150 if _, err := tenant.community.SetAgentPaused(ctx, owner, agent, true); err != nil {151 t.Fatal(err)152 }153 if tenant.IsMaintainer(ctx, repo, agent) {154 t.Fatal("paused grant counted as maintainer")155 }156 if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", next, now+6)); err == nil || !strings.Contains(err.Error(), "paused") {157 t.Fatalf("state under a paused grant: %v", err)158 }159 if out, err := exec.Command("git", "-C", work, "push", url, "refs/heads/main:refs/heads/main").CombinedOutput(); err == nil {160 t.Fatalf("push without a matching signed state succeeded: %s", out)161 }162 if page, _ := tenant.Execute(ctx, "", "browserepo", []json.RawMessage{raw}); len(page.(gitrelay.BrowsePage).Maintainers) != 1 {163 t.Fatalf("paused agent still listed: %+v", page.(gitrelay.BrowsePage).Maintainers)164 }165166 // Resumed, the agent maintains again; revoked, it never does.167 if _, err := tenant.community.SetAgentPaused(ctx, owner, agent, false); err != nil {168 t.Fatal(err)169 }170 if !tenant.IsMaintainer(ctx, repo, agent) {171 t.Fatal("resumed grant did not count as maintainer")172 }173 if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", next, now+7)); err != nil {174 t.Fatalf("state after resume rejected: %v", err)175 }176 gitTest(t, "-C", work, "push", url, "refs/heads/main:refs/heads/main")177 waitForRef(t, tenant, owner, "agentrepo", "refs/heads/main", next)178 if _, err := tenant.community.RevokeAgent(ctx, owner, agent, now+8); err != nil {179 t.Fatal(err)180 }181 if tenant.IsMaintainer(ctx, repo, agent) {182 t.Fatal("revoked grant counted as maintainer")183 }184 if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", sha, now+9)); err == nil || !strings.Contains(err.Error(), "revoked") {185 t.Fatalf("state under a revoked grant: %v", err)186 }187}