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 u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()
93 got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)
94 require.NoError(t, err)
95 assert.Equal(t, int64(7), got.Number)
96 })
97 t.Run("issues off hides the repository", func(t *testing.T) {
98 t.Parallel()
99 for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
100 u := newModerationUnit(t, mode)
101 _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
102 assert.ErrorIs(t, err, domain.ErrNotFound)
103 }
104 })
105 t.Run("no event when the store refuses", func(t *testing.T) {
106 t.Parallel()
107 u := newModerationUnit(t, domain.IssuesOwner)
108 u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(domain.Issue{}, domain.ErrNotFound).Once()
109 _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
110 assert.ErrorIs(t, err, domain.ErrNotFound)
111 })
112}
113
114func TestIssueModerationReject(t *testing.T) {
115 t.Parallel()
116 ctx := context.Background()
117 hash := bytes.Repeat([]byte{3}, domain.IPHashLen)
118 t.Run("with a ban", func(t *testing.T) {
119 t.Parallel()
120 u := newModerationUnit(t, domain.IssuesPublic)
121 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()
122 u.bans.EXPECT().Ban(mock.Anything, hash, now.Add(domain.BanDuration)).Return(nil).Once()
123 u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
124 u.bus.EXPECT().Publish(mock.Anything, domain.IssuePendingRemoved{Repo: u.repo.Name, ID: u.id, At: now}).Once()
125 require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true))
126 })
127 t.Run("without a ban", func(t *testing.T) {
128 t.Parallel()
129 u := newModerationUnit(t, domain.IssuesPublic)
130 u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
131 u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
132 u.bus.EXPECT().Publish(mock.Anything, domain.IssuePendingRemoved{Repo: u.repo.Name, ID: u.id, At: now}).Once()
133 require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, false))
134 })
135 t.Run("unknown id", func(t *testing.T) {
136 t.Parallel()
137 u := newModerationUnit(t, domain.IssuesPublic)
138 u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{}, domain.ErrNotFound).Once()
139 assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), domain.ErrNotFound)
140 })
141 t.Run("a failed ban keeps the pending issue", func(t *testing.T) {
142 t.Parallel()
143 u := newModerationUnit(t, domain.IssuesPublic)
144 u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
145 u.bans.EXPECT().Ban(mock.Anything, hash, mock.Anything).Return(assert.AnError).Once()
146 assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), assert.AnError)
147 })
148}
149
150func TestIssueKeysSubkeys(t *testing.T) {
151 t.Parallel()
152 ctx := context.Background()
153 master := bytes.Repeat([]byte{0xAB}, 32)
154 store := mocks.NewIssueKeyStore(t)
155 store.EXPECT().Key(mock.Anything).Return(master, nil)
156 k1, err := usecase.NewIssueKeys(ctx, store)
157 require.NoError(t, err)
158 k2, err := usecase.NewIssueKeys(ctx, store)
159 require.NoError(t, err)
160
161 assert.Equal(t, k1.PoWKey(), k2.PoWKey(), "stable")
162 assert.Equal(t, k1.FormTokenKey(), k2.FormTokenKey())
163 assert.Equal(t, k1.IPHash("1.2.3.4"), k2.IPHash("1.2.3.4"))
164
165 assert.Len(t, k1.PoWKey(), 32)
166 assert.NotEqual(t, k1.PoWKey(), k1.FormTokenKey(), "one key per purpose")
167 assert.NotEqual(t, master, k1.PoWKey())
168 assert.NotEqual(t, master, k1.FormTokenKey())
169
170 h := k1.IPHash("1.2.3.4")
171 assert.Len(t, h, domain.IPHashLen)
172 assert.NotEqual(t, h, k1.IPHash("1.2.3.5"))
173 assert.NotContains(t, string(h), "1.2.3.4")
174
175 other := mocks.NewIssueKeyStore(t)
176 other.EXPECT().Key(mock.Anything).Return(bytes.Repeat([]byte{0xCD}, 32), nil)
177 k3, err := usecase.NewIssueKeys(ctx, other)
178 require.NoError(t, err)
179 assert.NotEqual(t, h, k3.IPHash("1.2.3.4"), "another server key, another hash")
180
181 // The returned keys are copies.
182 pow := k1.PoWKey()
183 pow[0] ^= 0xFF
184 assert.NotEqual(t, pow, k1.PoWKey())
185}
186
187func TestIssueSubmittedReachesOnlyTheOwnerTopic(t *testing.T) {
188 t.Parallel()
189 synctest.Test(t, func(t *testing.T) {
190 r := newEventRig(t, 16)
191 a := r.repo("aaa", domain.VisibilityPublic)
192 r.meta.EXPECT().Get(mock.Anything, a).Unset()
193 r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
194 ctx, cancel := context.WithCancel(context.Background())
195 defer cancel()
196
197 owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
198 home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}})
199 repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}})
200 rootRepo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
201 anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
202 synctest.Wait()
203
204 r.feed <- domain.IssueSubmitted{Repo: a, ID: "x", At: now}
205 // A sentinel every watcher sees, to prove the first one was skipped.
206 r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
207
208 sub, ok := owner.next(t).Event.(domain.IssueSubmitted)
209 require.True(t, ok, "the owner topic gets it")
210 assert.Equal(t, a, sub.Repo)
211 _, ok = owner.next(t).Event.(domain.IssueOpened)
212 require.True(t, ok)
213 for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "root repo+home": rootRepo, "anonymous": anon} {
214 e, ok := w.next(t).Event.(domain.IssueOpened)
215 require.True(t, ok, "%s: the first event is the sentinel, not issue.submitted (got %T)", name, e)
216 }
217 })
218}
219
220func TestIssuePendingRemovedReachesOnlyOwnerTopics(t *testing.T) {
221 t.Parallel()
222 synctest.Test(t, func(t *testing.T) {
223 r := newEventRig(t, 16)
224 a := r.repo("aaa", domain.VisibilityPublic)
225 r.meta.EXPECT().Get(mock.Anything, a).Unset()
226 r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
227 ctx, cancel := context.WithCancel(context.Background())
228 defer cancel()
229
230 owner1 := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
231 owner2 := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
232 home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}})
233 repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}})
234 anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
235 synctest.Wait()
236
237 r.feed <- domain.IssuePendingRemoved{Repo: a, ID: "x", At: now}
238 r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
239
240 for name, w := range map[string]*watcher{"owner 1": owner1, "owner 2": owner2} {
241 _, ok := w.next(t).Event.(domain.IssuePendingRemoved)
242 require.True(t, ok, "%s gets the refresh", name)
243 _, ok = w.next(t).Event.(domain.IssueOpened)
244 require.True(t, ok)
245 }
246 for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "anonymous": anon} {
247 e, ok := w.next(t).Event.(domain.IssueOpened)
248 require.True(t, ok, "%s: the first event is the sentinel (got %T)", name, e)
249 }
250 })
251}