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}