1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "bytes"
7 "context"
8 "testing"
9 "testing/synctest"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17 "bnema.dev/ermite/internal/ports/mocks"
18 "bnema.dev/ermite/internal/usecase"
19)
20
21type moderationUnit struct {
22 meta *mocks.RepoMetaStore
23 pending *mocks.PendingIssueStore
24 bans *mocks.IssueBanStore
25 bus *mocks.EventBus
26 mod *usecase.IssueModeration
27 repo domain.Repo
28 id domain.PendingIssueID
29}
30
31func newModerationUnit(t *testing.T, mode domain.IssuesMode) *moderationUnit {
32 t.Helper()
33 id, err := domain.NewPendingIssueID()
34 require.NoError(t, err)
35 u := &moderationUnit{
36 meta: mocks.NewRepoMetaStore(t),
37 pending: mocks.NewPendingIssueStore(t),
38 bans: mocks.NewIssueBanStore(t),
39 bus: mocks.NewEventBus(t),
40 repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(mode)},
41 id: id,
42 }
43 clock := mocks.NewClock(t)
44 clock.EXPECT().Now().Return(now).Maybe()
45 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
46 u.mod = usecase.NewIssueModeration(usecase.IssueModerationConfig{Meta: u.meta, Pending: u.pending, Bans: u.bans, Bus: u.bus, Clock: clock})
47 return u
48}
49
50func TestIssueModerationIsOwnerOnly(t *testing.T) {
51 t.Parallel()
52 u := newModerationUnit(t, domain.IssuesPublic) // no store is ever called
53 ctx := context.Background()
54 notOwners := map[string]domain.Principal{
55 "anonymous": domain.Anonymous(),
56 "issue writer": issuePrincipal(domain.ScopeIssueWrite),
57 "one scope short": allScopesToken(func(tk *domain.Token) {
58 tk.Scopes = tk.Scopes[1:]
59 }),
60 "repo limited": allScopesToken(func(tk *domain.Token) { tk.Repos = []domain.RepoName{u.repo.Name} }),
61 "branch limited": allScopesToken(func(tk *domain.Token) { tk.BranchPrefixes = []string{"agent/"} }),
62 }
63 for who, p := range notOwners {
64 _, err := u.mod.ListPending(ctx, p, domain.RepoName{}, ports.ListPendingParams{})
65 assert.ErrorIs(t, err, domain.ErrNotFound, who)
66 _, err = u.mod.Approve(ctx, p, u.repo.Name, u.id)
67 assert.ErrorIs(t, err, domain.ErrNotFound, who)
68 assert.ErrorIs(t, u.mod.Reject(ctx, p, u.repo.Name, u.id, true), domain.ErrNotFound, who)
69 }
70}
71
72func TestIssueModerationListAppliesPageLimits(t *testing.T) {
73 t.Parallel()
74 u := newModerationUnit(t, domain.IssuesPublic)
75 u.pending.EXPECT().List(mock.Anything, domain.RepoName{}, ports.ListPendingParams{PageSize: usecase.DefaultPageSize, PageToken: "t"}).
76 Return(ports.PendingIssuePage{NextPageToken: "n"}, nil).Once()
77 got, err := u.mod.ListPending(context.Background(), domain.Owner(), domain.RepoName{}, ports.ListPendingParams{PageToken: "t"})
78 require.NoError(t, err)
79 assert.Equal(t, "n", got.NextPageToken)
80 _, err = u.mod.ListPending(context.Background(), domain.Owner(), domain.RepoName{}, ports.ListPendingParams{PageSize: -1})
81 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
82}
83
84func TestIssueModerationApprove(t *testing.T) {
85 t.Parallel()
86 ctx := context.Background()
87 t.Run("publishes IssueOpened after the commit", func(t *testing.T) {
88 t.Parallel()
89 u := newModerationUnit(t, domain.IssuesPublic)
90 issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen}
91 u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once()
92 expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) })).Once()
93 u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()
94 got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)
95 require.NoError(t, err)
96 assert.Equal(t, int64(7), got.Number)
97 })
98 t.Run("issues off hides the repository", func(t *testing.T) {
99 t.Parallel()
100 for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
101 u := newModerationUnit(t, mode)
102 _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
103 assert.ErrorIs(t, err, domain.ErrNotFound)
104 }
105 })
106 t.Run("no event when the store refuses", func(t *testing.T) {
107 t.Parallel()
108 u := newModerationUnit(t, domain.IssuesOwner)
109 u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(domain.Issue{}, domain.ErrNotFound).Once()
110 _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
111 assert.ErrorIs(t, err, domain.ErrNotFound)
112 })
113}
114
115func TestIssueModerationReject(t *testing.T) {
116 t.Parallel()
117 ctx := context.Background()
118 hash := bytes.Repeat([]byte{3}, domain.IPHashLen)
119 t.Run("with a ban", func(t *testing.T) {
120 t.Parallel()
121 u := newModerationUnit(t, domain.IssuesPublic)
122 u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, Repo: u.repo.Name, IPHash: hash}, nil).Once()
123 u.bans.EXPECT().Ban(mock.Anything, hash, now.Add(domain.BanDuration)).Return(nil).Once()
124 u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
125 u.bus.EXPECT().Publish(mock.Anything, domain.IssuePendingRemoved{Repo: u.repo.Name, ID: u.id, At: now}).Once()
126 require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true))
127 })
128 t.Run("without a ban", func(t *testing.T) {
129 t.Parallel()
130 u := newModerationUnit(t, domain.IssuesPublic)
131 u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
132 u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
133 u.bus.EXPECT().Publish(mock.Anything, domain.IssuePendingRemoved{Repo: u.repo.Name, ID: u.id, At: now}).Once()
134 require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, false))
135 })
136 t.Run("unknown id", func(t *testing.T) {
137 t.Parallel()
138 u := newModerationUnit(t, domain.IssuesPublic)
139 u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{}, domain.ErrNotFound).Once()
140 assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), domain.ErrNotFound)
141 })
142 t.Run("a failed ban keeps the pending issue", func(t *testing.T) {
143 t.Parallel()
144 u := newModerationUnit(t, domain.IssuesPublic)
145 u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
146 u.bans.EXPECT().Ban(mock.Anything, hash, mock.Anything).Return(assert.AnError).Once()
147 assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), assert.AnError)
148 })
149}
150
151func TestIssueKeysSubkeys(t *testing.T) {
152 t.Parallel()
153 ctx := context.Background()
154 master := bytes.Repeat([]byte{0xAB}, 32)
155 store := mocks.NewIssueKeyStore(t)
156 store.EXPECT().Key(mock.Anything).Return(master, nil)
157 k1, err := usecase.NewIssueKeys(ctx, store)
158 require.NoError(t, err)
159 k2, err := usecase.NewIssueKeys(ctx, store)
160 require.NoError(t, err)
161
162 assert.Equal(t, k1.PoWKey(), k2.PoWKey(), "stable")
163 assert.Equal(t, k1.FormTokenKey(), k2.FormTokenKey())
164 assert.Equal(t, k1.IPHash("1.2.3.4"), k2.IPHash("1.2.3.4"))
165
166 assert.Len(t, k1.PoWKey(), 32)
167 assert.NotEqual(t, k1.PoWKey(), k1.FormTokenKey(), "one key per purpose")
168 assert.NotEqual(t, master, k1.PoWKey())
169 assert.NotEqual(t, master, k1.FormTokenKey())
170
171 h := k1.IPHash("1.2.3.4")
172 assert.Len(t, h, domain.IPHashLen)
173 assert.NotEqual(t, h, k1.IPHash("1.2.3.5"))
174 assert.NotContains(t, string(h), "1.2.3.4")
175
176 other := mocks.NewIssueKeyStore(t)
177 other.EXPECT().Key(mock.Anything).Return(bytes.Repeat([]byte{0xCD}, 32), nil)
178 k3, err := usecase.NewIssueKeys(ctx, other)
179 require.NoError(t, err)
180 assert.NotEqual(t, h, k3.IPHash("1.2.3.4"), "another server key, another hash")
181
182 // The returned keys are copies.
183 pow := k1.PoWKey()
184 pow[0] ^= 0xFF
185 assert.NotEqual(t, pow, k1.PoWKey())
186}
187
188func TestIssueSubmittedReachesOnlyTheOwnerTopic(t *testing.T) {
189 t.Parallel()
190 synctest.Test(t, func(t *testing.T) {
191 r := newEventRig(t, 16)
192 a := r.repo("aaa", domain.VisibilityPublic)
193 r.meta.EXPECT().Get(mock.Anything, a).Unset()
194 r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
195 ctx, cancel := context.WithCancel(context.Background())
196 defer cancel()
197
198 owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
199 home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}})
200 repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}})
201 rootRepo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
202 anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
203 synctest.Wait()
204
205 r.feed <- domain.IssueSubmitted{Repo: a, ID: "x", At: now}
206 // A sentinel every watcher sees, to prove the first one was skipped.
207 r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
208
209 sub, ok := owner.next(t).Event.(domain.IssueSubmitted)
210 require.True(t, ok, "the owner topic gets it")
211 assert.Equal(t, a, sub.Repo)
212 _, ok = owner.next(t).Event.(domain.IssueOpened)
213 require.True(t, ok)
214 for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "root repo+home": rootRepo, "anonymous": anon} {
215 e, ok := w.next(t).Event.(domain.IssueOpened)
216 require.True(t, ok, "%s: the first event is the sentinel, not issue.submitted (got %T)", name, e)
217 }
218 })
219}
220
221func TestIssuePendingRemovedReachesOnlyOwnerTopics(t *testing.T) {
222 t.Parallel()
223 synctest.Test(t, func(t *testing.T) {
224 r := newEventRig(t, 16)
225 a := r.repo("aaa", domain.VisibilityPublic)
226 r.meta.EXPECT().Get(mock.Anything, a).Unset()
227 r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
228 ctx, cancel := context.WithCancel(context.Background())
229 defer cancel()
230
231 owner1 := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
232 owner2 := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
233 home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}})
234 repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}})
235 anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
236 synctest.Wait()
237
238 r.feed <- domain.IssuePendingRemoved{Repo: a, ID: "x", At: now}
239 r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
240
241 for name, w := range map[string]*watcher{"owner 1": owner1, "owner 2": owner2} {
242 _, ok := w.next(t).Event.(domain.IssuePendingRemoved)
243 require.True(t, ok, "%s gets the refresh", name)
244 _, ok = w.next(t).Event.(domain.IssueOpened)
245 require.True(t, ok)
246 }
247 for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "anonymous": anon} {
248 e, ok := w.next(t).Event.(domain.IssueOpened)
249 require.True(t, ok, "%s: the first event is the sentinel (got %T)", name, e)
250 }
251 })
252}