| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http/httptest" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 14 | gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit" |
| 15 | ) |
| 16 | |
| 17 | const testOwnerNPub = "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d" |
| 18 | |
| 19 | // agentTestRepo announces one repository from the test owner and returns a |
| 20 | // working clone with one commit on main, plus that commit's id. |
| 21 | func 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 | } |
| 42 | |
| 43 | func 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 | } |
| 47 | |
| 48 | func 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 | return |
| 54 | } |
| 55 | time.Sleep(20 * time.Millisecond) |
| 56 | } |
| 57 | t.Fatalf("%s did not reach %s" , ref, sha) |
| 58 | } |
| 59 | |
| 60 | func TestAgentMaintainGrantMakesTheAgentARepositoryMaintainer(t *testing.T) { |
| 61 | ctx := context.Background() |
| 62 | app, tenant := testTenant(t) |
| 63 | p := tenant.Policy() |
| 64 | p.Features.Grasp = true |
| 65 | 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 | } |
| 77 | |
| 78 | // 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 | } |
| 88 | |
| 89 | // A maintain grant makes the agent a maintainer: its state is accepted |
| 90 | // 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) |
| 105 | |
| 106 | // 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 | } |
| 119 | |
| 120 | // 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 | } |
| 142 | |
| 143 | // A second commit needs a new state; a paused grant signs nothing that |
| 144 | // 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 | } |
| 165 | |
| 166 | // 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 | } |