detached 98d36bf

issues_pending_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6	"bytes"
7	"context"
8	"sync"
9	"testing"
10	"time"
11
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports"
17)
18
19func newPending(t *testing.T, repo domain.RepoName, title, author string, at time.Time) domain.PendingIssue {
20	t.Helper()
21	id, err := domain.NewPendingIssueID()
22	require.NoError(t, err)
23	ti, err := domain.ParseIssueTitle(title)
24	require.NoError(t, err)
25	body, err := domain.ParseIssueBody("details")
26	require.NoError(t, err)
27	return domain.PendingIssue{
28		ID: id, Repo: repo, Title: ti, Body: body, Author: author, CreatedAt: at,
29		IPHash: bytes.Repeat([]byte{7}, domain.IPHashLen),
30	}
31}
32
33func TestPendingIssuePutGetListCount(t *testing.T) {
34	ctx := context.Background()
35	s, _, n := repoWithMRs(t)
36	other := name(t, "other")
37	require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
38	ps := s.PendingIssues()
39
40	a := newPending(t, n, "a", "Alex", created.Add(time.Minute))
41	b := newPending(t, other, "b", "", created.Add(2*time.Minute))
42	c := newPending(t, n, "c", "", created.Add(3*time.Minute))
43	for _, p := range []domain.PendingIssue{c, a, b} {
44		require.NoError(t, ps.Put(ctx, p))
45	}
46	require.ErrorIs(t, ps.Put(ctx, a), domain.ErrConflict, "an id is used once")
47	require.ErrorIs(t, ps.Put(ctx, newPending(t, name(t, "ghost"), "g", "", created)), domain.ErrNotFound)
48
49	got, err := ps.Get(ctx, n, a.ID)
50	require.NoError(t, err)
51	assert.Equal(t, a.Title, got.Title)
52	assert.Equal(t, "Alex", got.Author)
53	assert.Equal(t, a.IPHash, got.IPHash)
54	assert.True(t, got.CreatedAt.Equal(a.CreatedAt))
55	assert.NotZero(t, got.Version)
56	_, err = ps.Get(ctx, other, a.ID)
57	assert.ErrorIs(t, err, domain.ErrNotFound, "ids are per repository")
58
59	all, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{})
60	require.NoError(t, err)
61	require.Len(t, all.Issues, 3)
62	assert.Equal(t, []domain.PendingIssueID{a.ID, b.ID, c.ID}, []domain.PendingIssueID{all.Issues[0].ID, all.Issues[1].ID, all.Issues[2].ID}, "oldest first")
63
64	mine, err := ps.List(ctx, n, ports.ListPendingParams{})
65	require.NoError(t, err)
66	assert.Len(t, mine.Issues, 2)
67
68	p1, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2})
69	require.NoError(t, err)
70	require.Len(t, p1.Issues, 2)
71	require.NotEmpty(t, p1.NextPageToken)
72	p2, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2, PageToken: p1.NextPageToken})
73	require.NoError(t, err)
74	require.Len(t, p2.Issues, 1)
75	assert.Equal(t, c.ID, p2.Issues[0].ID)
76	assert.Empty(t, p2.NextPageToken)
77	_, err = ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageToken: "x"})
78	assert.ErrorIs(t, err, domain.ErrInvalid)
79
80	cnt, err := ps.Count(ctx)
81	require.NoError(t, err)
82	assert.Equal(t, 3, cnt)
83
84	require.NoError(t, ps.Delete(ctx, n, a.ID))
85	assert.ErrorIs(t, ps.Delete(ctx, n, a.ID), domain.ErrNotFound)
86	cnt, err = ps.Count(ctx)
87	require.NoError(t, err)
88	assert.Equal(t, 2, cnt)
89}
90
91func TestPendingIssueCountIsCapped(t *testing.T) {
92	ctx := context.Background()
93	s, _, n := repoWithMRs(t)
94	ps := s.PendingIssues()
95	for i := range domain.MaxPendingIssues + 5 {
96		require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))))
97	}
98	cnt, err := ps.Count(ctx)
99	require.NoError(t, err)
100	assert.Equal(t, domain.MaxPendingIssues+1, cnt)
101}
102
103func TestPendingIssueApproveSharesNumbers(t *testing.T) {
104	ctx := context.Background()
105	s, mrs, n := repoWithMRs(t)
106	ps := s.PendingIssues()
107
108	mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
109	require.NoError(t, err)
110	assert.Equal(t, int64(1), mr.Number)
111
112	named := newPending(t, n, "named", "Alex", created)
113	anon := newPending(t, n, "anon", "", created.Add(time.Second))
114	require.NoError(t, ps.Put(ctx, named))
115	require.NoError(t, ps.Put(ctx, anon))
116
117	issue, err := ps.Approve(ctx, n, named.ID, created.Add(time.Hour))
118	require.NoError(t, err)
119	assert.Equal(t, int64(2), issue.Number)
120	assert.Equal(t, domain.IssueStateOpen, issue.State)
121	assert.Equal(t, "Alex", issue.Author)
122	assert.Equal(t, "named", issue.Title.String())
123	assert.True(t, issue.External, "an approved submission is external")
124
125	_, err = ps.Get(ctx, n, named.ID)
126	assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone")
127	_, err = ps.Approve(ctx, n, named.ID, created.Add(time.Hour))
128	assert.ErrorIs(t, err, domain.ErrNotFound, "approved once")
129
130	got, err := s.Issues().Get(ctx, n, 2)
131	require.NoError(t, err)
132	assert.Equal(t, issue.Title, got.Title)
133	assert.True(t, got.External, "the flag is stored")
134	open := openOf(t, s.Issues(), n)
135	require.Len(t, open, 1)
136	assert.Equal(t, int64(2), open[0].Number)
137
138	second, err := ps.Approve(ctx, n, anon.ID, created.Add(time.Hour))
139	require.NoError(t, err)
140	assert.Equal(t, int64(3), second.Number)
141	assert.Equal(t, "anonymous", second.Author)
142
143	next, err := s.Issues().Create(ctx, newIssue(t, n, "owner"))
144	require.NoError(t, err)
145	assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter")
146	assert.False(t, next.External, "an issue of the owner is not external")
147}
148
149func TestPendingIssueApproveConcurrent(t *testing.T) {
150	ctx := context.Background()
151	s, mrs, n := repoWithMRs(t)
152	ps := s.PendingIssues()
153	const k = 8
154	ids := make([]domain.PendingIssueID, k)
155	for i := range k {
156		p := newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))
157		ids[i] = p.ID
158		require.NoError(t, ps.Put(ctx, p))
159	}
160	var mu sync.Mutex
161	seen := map[int64]bool{}
162	var wg sync.WaitGroup
163	for _, id := range ids {
164		wg.Add(1)
165		go func() {
166			defer wg.Done()
167			is, err := ps.Approve(ctx, n, id, created.Add(time.Hour))
168			if assert.NoError(t, err) {
169				mu.Lock()
170				seen[is.Number] = true
171				mu.Unlock()
172			}
173		}()
174	}
175	wg.Add(1)
176	go func() {
177		defer wg.Done()
178		mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
179		if assert.NoError(t, err) {
180			mu.Lock()
181			seen[mr.Number] = true
182			mu.Unlock()
183		}
184	}()
185	wg.Wait()
186	assert.Len(t, seen, k+1, "no number is given twice")
187}
188
189func TestPendingIssueApproveOnDeletedRepoFails(t *testing.T) {
190	ctx := context.Background()
191	s, _, n := repoWithMRs(t)
192	ps := s.PendingIssues()
193	p := newPending(t, n, "t", "", created)
194	require.NoError(t, ps.Put(ctx, p))
195	require.NoError(t, s.Delete(ctx, n))
196
197	_, err := ps.Approve(ctx, n, p.ID, created.Add(time.Hour))
198	assert.ErrorIs(t, err, domain.ErrNotFound)
199	assert.ErrorIs(t, ps.Put(ctx, newPending(t, n, "t", "", created)), domain.ErrNotFound)
200	assert.Zero(t, countKeys(t, s, "is/"))
201	assert.Zero(t, countKeys(t, s, "isopen/"))
202}
203
204func TestRepoDeleteRemovesPendingIssues(t *testing.T) {
205	ctx := context.Background()
206	s, _, n := repoWithMRs(t)
207	other := name(t, "other")
208	require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
209	ps := s.PendingIssues()
210	require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created)))
211	require.NoError(t, ps.Put(ctx, newPending(t, n, "u", "", created)))
212	keep := newPending(t, other, "k", "", created)
213	require.NoError(t, ps.Put(ctx, keep))
214
215	require.NoError(t, s.Delete(ctx, n))
216	assert.Zero(t, countKeys(t, s, "ispend/"+n.String()+"/"))
217	_, err := ps.Get(ctx, other, keep.ID)
218	assert.NoError(t, err, "another repository keeps its pending issues")
219	cnt, err := ps.Count(ctx)
220	require.NoError(t, err)
221	assert.Equal(t, 1, cnt)
222}
223
224func TestIssueBansExpire(t *testing.T) {
225	ctx := context.Background()
226	s := openStore(t, t.TempDir())
227	bans := s.IssueBans()
228	h := bytes.Repeat([]byte{1}, domain.IPHashLen)
229	other := bytes.Repeat([]byte{2}, domain.IPHashLen)
230	now := created
231
232	banned, err := bans.Banned(ctx, h, now)
233	require.NoError(t, err)
234	assert.False(t, banned)
235
236	require.NoError(t, bans.Ban(ctx, h, now.Add(domain.BanDuration)))
237	require.ErrorIs(t, bans.Ban(ctx, []byte("short"), now), domain.ErrInvalid)
238	banned, err = bans.Banned(ctx, h, now.Add(time.Hour))
239	require.NoError(t, err)
240	assert.True(t, banned)
241	banned, err = bans.Banned(ctx, other, now.Add(time.Hour))
242	require.NoError(t, err)
243	assert.False(t, banned, "another hash is not banned")
244	assert.Equal(t, 1, countKeys(t, s, "isban/"))
245
246	banned, err = bans.Banned(ctx, h, now.Add(domain.BanDuration))
247	require.NoError(t, err)
248	assert.False(t, banned, "expired")
249	assert.Zero(t, countKeys(t, s, "isban/"), "an expired ban is deleted")
250}
251
252func TestIssueKeyPersists(t *testing.T) {
253	ctx := context.Background()
254	dir := t.TempDir()
255	s := openStore(t, dir)
256	key, err := s.IssueKeys().Key(ctx)
257	require.NoError(t, err)
258	assert.Len(t, key, 32)
259	again, err := s.IssueKeys().Key(ctx)
260	require.NoError(t, err)
261	assert.Equal(t, key, again)
262	sess, err := s.Sessions().Key(ctx)
263	require.NoError(t, err)
264	assert.NotEqual(t, sess, key, "not the session key")
265	require.NoError(t, s.Close(ctx))
266
267	s = openStore(t, dir)
268	reopened, err := s.IssueKeys().Key(ctx)
269	require.NoError(t, err)
270	assert.Equal(t, key, reopened, "a restart keeps the key")
271}

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