tinyrelayfile
tinyrelay/internal/daemon/git_agent_maintainer_test.go/

internal/daemon/git_agent_maintainer_test.go raw
1package daemon
2
3import (
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
17const 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.
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}
42
43func 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
48func 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
60func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs