v0.28.1 cc7ba64

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		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}

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 2

Tags 45

↑↓ moveenter openesc close