detached aa7340c

issue_moderation_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close