detached 83e495f

issue_moderation_api_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"crypto/rand"
7	"testing"
8	"time"
9
10	"connectrpc.com/connect"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13
14	pb "bnema.dev/ermite-api/gen/ermite"
15	"bnema.dev/ermite/internal/clientlimit"
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20func (r *issueRig) pending(repo, title string) domain.PendingIssue {
21	r.t.Helper()
22	id, err := domain.NewPendingIssueID()
23	require.NoError(r.t, err)
24	ti, err := domain.ParseIssueTitle(title)
25	require.NoError(r.t, err)
26	body, err := domain.ParseIssueBody("body of " + title)
27	require.NoError(r.t, err)
28	hash := make([]byte, domain.IPHashLen)
29	_, err = rand.Read(hash)
30	require.NoError(r.t, err)
31	p := domain.PendingIssue{
32		ID: id, Repo: r.name(repo), Title: ti, Body: body, Author: "visitor",
33		CreatedAt: time.Now().UTC(), IPHash: hash,
34	}
35	require.NoError(r.t, r.forge.PendingIssues.Put(r.ctx, p))
36	return p
37}
38
39func TestIssueModerationOverTheAPI(t *testing.T) {
40	r := newIssueRig(t)
41	ctx := r.ctx
42	r.mode("pub", domain.IssuesOwner)
43	owner := r.api(r.token(domain.AllScopes()))
44	p1 := r.pending("pub", "first")
45	p2 := r.pending("pub", "second")
46
47	// Only owner rights see anything: everyone else gets NotFound.
48	for name, c := range map[string]*clients{"anonymous": r.anon, "reader": r.reader, "writer": r.writer, "blind": r.blind, "branch": r.branch} {
49		_, err := c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{}))
50		assert.Equal(t, "not_found", codeName(err), "%s list", name)
51		_, err = c.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
52		assert.Equal(t, "not_found", codeName(err), "%s approve", name)
53		_, err = c.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p1.ID.String(), Ban: true}))
54		assert.Equal(t, "not_found", codeName(err), "%s reject", name)
55		// A malformed repository tells a non-owner nothing more.
56		_, err = c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{Repo: "Bad Repo!"}))
57		assert.Equal(t, "not_found", codeName(err), "%s list, malformed repo", name)
58		_, err = c.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "Bad Repo!", Id: p1.ID.String()}))
59		assert.Equal(t, "not_found", codeName(err), "%s approve, malformed repo", name)
60		_, err = c.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "Bad Repo!", Id: "nope"}))
61		assert.Equal(t, "not_found", codeName(err), "%s reject, malformed repo", name)
62	}
63	still, err := r.forge.PendingIssues.Count(ctx)
64	require.NoError(t, err)
65	assert.Equal(t, 2, still, "nothing changed")
66
67	for name, c := range map[string]*clients{"root": r.root, "owner token": owner} {
68		res, err := c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{}))
69		require.NoError(t, err, name)
70		require.Len(t, res.Msg.GetIssues(), 2, name)
71		assert.Equal(t, p1.ID.String(), res.Msg.GetIssues()[0].GetId(), "oldest first")
72		assert.Equal(t, "visitor", res.Msg.GetIssues()[0].GetAuthor())
73	}
74	res, err := owner.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{Repo: "priv"}))
75	require.NoError(t, err)
76	assert.Empty(t, res.Msg.GetIssues())
77
78	// An owner does learn that a repository name is malformed.
79	_, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "Bad Repo!", Id: p1.ID.String()}))
80	assert.Equal(t, "invalid_argument", codeName(err))
81
82	// A malformed or unknown id is NotFound.
83	_, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: "nope"}))
84	assert.Equal(t, "not_found", codeName(err))
85
86	// Approval takes the next number shared with the merge requests and issues.
87	made, err := owner.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "pub", Title: "by owner"}))
88	require.NoError(t, err)
89	assert.Equal(t, int64(1), made.Msg.GetIssue().GetNumber())
90	ap, err := owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
91	require.NoError(t, err)
92	assert.Equal(t, int64(2), ap.Msg.GetIssue().GetNumber())
93	assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, ap.Msg.GetIssue().GetState())
94	assert.Equal(t, "first", ap.Msg.GetIssue().GetTitle())
95	got, err := r.reader.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "pub", Number: 2}))
96	require.NoError(t, err)
97	assert.Equal(t, "visitor", got.Msg.GetIssue().GetAuthor())
98	_, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
99	assert.Equal(t, "not_found", codeName(err), "approved once")
100
101	// Rejecting with a ban stores the ban and removes the record.
102	_, err = owner.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p2.ID.String(), Ban: true}))
103	require.NoError(t, err)
104	banned, err := r.forge.IssueBans.Banned(ctx, p2.IPHash, time.Now())
105	require.NoError(t, err)
106	assert.True(t, banned)
107	banned, err = r.forge.IssueBans.Banned(ctx, p2.IPHash, time.Now().Add(domain.BanDuration+time.Hour))
108	require.NoError(t, err)
109	assert.False(t, banned, "the ban expires")
110	_, err = r.forge.PendingIssues.Get(ctx, p2.Repo, p2.ID)
111	assert.ErrorIs(t, err, domain.ErrNotFound)
112
113	// Rejecting without a ban bans nobody.
114	p3 := r.pending("pub", "third")
115	_, err = owner.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p3.ID.String()}))
116	require.NoError(t, err)
117	banned, err = r.forge.IssueBans.Banned(ctx, p3.IPHash, time.Now())
118	require.NoError(t, err)
119	assert.False(t, banned)
120
121	// The inbox lists what awaits.
122	r.pending("pub", "fourth")
123	ov, err := r.forge.Inbox.Overview(ctx, domain.Root(), domain.RepoName{})
124	require.NoError(t, err)
125	assert.Equal(t, 1, ov.Counts.Pending)
126	require.Len(t, ov.Pending, 1)
127	assert.Nil(t, ov.Pending[0].IPHash)
128
129	// The public mode is accepted.
130	pub := domain.IssuesPublic
131	_, err = r.forge.Repos.Update(ctx, domain.Root(), r.name("pub"), ports.UpdateRepoParams{Issues: &pub})
132	assert.NoError(t, err)
133}
134
135// clientlimit may not import the domain: its copies of the limits must match.
136func TestSubmissionLimiterUsesTheDomainLimits(t *testing.T) {
137	assert.Equal(t, domain.SubmitPerHour, clientlimit.PerHour)
138	assert.Equal(t, domain.SubmitPerDay, clientlimit.PerDay)
139}

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