detached 51f0135

feat(usecase): add issue moderation, the form subkeys and the owner-only issue.submitted event

commit
51f013579321ca1951185a5f481623636a235432
author
brice · · 2026-10-19 07:04 UTC
parent
2576c64
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +456 -1
M internal/usecase/events.go +19 -0
@@ -190,6 +190,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
190190 return repoSnapshot(ev.Visibility, ev.CreatedAt)
191191 case domain.IssueCommented:
192192 return repoSnapshot(ev.Visibility, ev.CreatedAt)
193+ case domain.IssueSubmitted:
194+ return bufferedEvent{known: true} // owner topic only: no repository snapshot
193195 case domain.RunUpdated:
194196 return repoSnapshot(ev.Visibility, ev.CreatedAt)
195197 case domain.JobUpdated:
@@ -499,6 +501,23 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
499501 return nil
500502 }
501503 return send(out)
504+ case domain.IssueSubmitted:
505+ // Owner topic only: never home, never a repository topic, not even
506+ // for an owner who watches the repository. No content travels.
507+ if !w.owner {
508+ return nil
509+ }
510+ repo, err := lookupRepo(ctx, e.repos.meta, ev.Repo)
511+ switch {
512+ case errors.Is(err, domain.ErrNotFound), ctx.Err() != nil:
513+ return nil
514+ case err != nil:
515+ return fmt.Errorf("check access to %q: %w", ev.Repo, err)
516+ }
517+ if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
518+ return nil
519+ }
520+ return send(out)
502521 case domain.RepoCreated:
503522 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
504523 case domain.RepoUpdated:
@@ -190,6 +190,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
190 return repoSnapshot(ev.Visibility, ev.CreatedAt)190 return repoSnapshot(ev.Visibility, ev.CreatedAt)
191 case domain.IssueCommented:191 case domain.IssueCommented:
192 return repoSnapshot(ev.Visibility, ev.CreatedAt)192 return repoSnapshot(ev.Visibility, ev.CreatedAt)
193+ case domain.IssueSubmitted:
194+ return bufferedEvent{known: true} // owner topic only: no repository snapshot
193 case domain.RunUpdated:195 case domain.RunUpdated:
194 return repoSnapshot(ev.Visibility, ev.CreatedAt)196 return repoSnapshot(ev.Visibility, ev.CreatedAt)
195 case domain.JobUpdated:197 case domain.JobUpdated:
@@ -499,6 +501,23 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
499 return nil501 return nil
500 }502 }
501 return send(out)503 return send(out)
504+ case domain.IssueSubmitted:
505+ // Owner topic only: never home, never a repository topic, not even
506+ // for an owner who watches the repository. No content travels.
507+ if !w.owner {
508+ return nil
509+ }
510+ repo, err := lookupRepo(ctx, e.repos.meta, ev.Repo)
511+ switch {
512+ case errors.Is(err, domain.ErrNotFound), ctx.Err() != nil:
513+ return nil
514+ case err != nil:
515+ return fmt.Errorf("check access to %q: %w", ev.Repo, err)
516+ }
517+ if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
518+ return nil
519+ }
520+ return send(out)
502 case domain.RepoCreated:521 case domain.RepoCreated:
503 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)522 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
504 case domain.RepoUpdated:523 case domain.RepoUpdated:
M internal/usecase/inbox.go +25 -1
@@ -22,6 +22,8 @@ type Inbox struct {
2222 meta ports.RepoMetaStore
2323 mrs ports.MergeRequestStore
2424 issues ports.IssueStore
25+ // pending is nil when the public form is not wired.
26+ pending ports.PendingIssueStore
2527 }
2628
2729 var _ ports.InboxService = (*Inbox)(nil)
@@ -31,11 +33,14 @@ type InboxConfig struct {
3133 Meta ports.RepoMetaStore
3234 MergeRequests ports.MergeRequestStore
3335 Issues ports.IssueStore
36+ // Pending lists the issues awaiting moderation. Optional: without it the
37+ // inbox has no pending section.
38+ Pending ports.PendingIssueStore
3439 }
3540
3641 // NewInbox builds the inbox use case.
3742 func NewInbox(cfg InboxConfig) *Inbox {
38- return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues}
43+ return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}
3944 }
4045
4146 // Overview lists what is open, through the indexes of open items. The counts
@@ -102,6 +107,25 @@ func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain
102107 }
103108 }
104109
110+ if in.pending != nil {
111+ page, err := in.pending.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: domain.MaxPendingIssues})
112+ if err != nil {
113+ return out, fmt.Errorf("list pending issues: %w", err)
114+ }
115+ for _, pi := range page.Issues {
116+ if err := look(pi.Repo); err != nil {
117+ return out, err
118+ }
119+ if !exists[pi.Repo] || !issuesOn[pi.Repo] {
120+ continue
121+ }
122+ pi.IPHash = nil // the hash is for bans, the page never needs it
123+ out.Pending = append(out.Pending, pi)
124+ repos[pi.Repo] = true
125+ }
126+ out.Counts.Pending = len(out.Pending)
127+ }
128+
105129 slices.SortStableFunc(out.MergeRequests, func(a, b ports.InboxMergeRequest) int {
106130 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
107131 })
@@ -22,6 +22,8 @@ type Inbox struct {
22 meta ports.RepoMetaStore22 meta ports.RepoMetaStore
23 mrs ports.MergeRequestStore23 mrs ports.MergeRequestStore
24 issues ports.IssueStore24 issues ports.IssueStore
25+ // pending is nil when the public form is not wired.
26+ pending ports.PendingIssueStore
25 }27 }
26 28
27 var _ ports.InboxService = (*Inbox)(nil)29 var _ ports.InboxService = (*Inbox)(nil)
@@ -31,11 +33,14 @@ type InboxConfig struct {
31 Meta ports.RepoMetaStore33 Meta ports.RepoMetaStore
32 MergeRequests ports.MergeRequestStore34 MergeRequests ports.MergeRequestStore
33 Issues ports.IssueStore35 Issues ports.IssueStore
36+ // Pending lists the issues awaiting moderation. Optional: without it the
37+ // inbox has no pending section.
38+ Pending ports.PendingIssueStore
34 }39 }
35 40
36 // NewInbox builds the inbox use case.41 // NewInbox builds the inbox use case.
37 func NewInbox(cfg InboxConfig) *Inbox {42 func NewInbox(cfg InboxConfig) *Inbox {
38- return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues}43+ return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}
39 }44 }
40 45
41 // Overview lists what is open, through the indexes of open items. The counts46 // Overview lists what is open, through the indexes of open items. The counts
@@ -102,6 +107,25 @@ func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain
102 }107 }
103 }108 }
104 109
110+ if in.pending != nil {
111+ page, err := in.pending.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: domain.MaxPendingIssues})
112+ if err != nil {
113+ return out, fmt.Errorf("list pending issues: %w", err)
114+ }
115+ for _, pi := range page.Issues {
116+ if err := look(pi.Repo); err != nil {
117+ return out, err
118+ }
119+ if !exists[pi.Repo] || !issuesOn[pi.Repo] {
120+ continue
121+ }
122+ pi.IPHash = nil // the hash is for bans, the page never needs it
123+ out.Pending = append(out.Pending, pi)
124+ repos[pi.Repo] = true
125+ }
126+ out.Counts.Pending = len(out.Pending)
127+ }
128+
105 slices.SortStableFunc(out.MergeRequests, func(a, b ports.InboxMergeRequest) int {129 slices.SortStableFunc(out.MergeRequests, func(a, b ports.InboxMergeRequest) int {
106 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)130 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
107 })131 })
M internal/usecase/inbox_test.go +23 -0
@@ -85,3 +85,26 @@ func TestInboxOverviewSortsFiltersAndSkipsIssuesOff(t *testing.T) {
8585 assert.Equal(t, "alpha", got.Issues[0].Repo.String())
8686 assert.Len(t, got.Repos, 2, "the filter list keeps every repository")
8787 }
88+
89+func TestInboxListsPendingIssuesWithoutTheirHash(t *testing.T) {
90+ t.Parallel()
91+ ctx := context.Background()
92+ mrs, is, meta, pending := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t), mocks.NewPendingIssueStore(t)
93+ in := usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: is, Pending: pending})
94+ mrs.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
95+ is.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
96+ meta.EXPECT().Get(mock.Anything, name(t, "on")).Return(repoWithIssues(t, "on", domain.IssuesPublic), nil)
97+ meta.EXPECT().Get(mock.Anything, name(t, "off")).Return(repoWithIssues(t, "off", domain.IssuesOff), nil)
98+ ti, err := domain.ParseIssueTitle("hi")
99+ require.NoError(t, err)
100+ pending.EXPECT().List(mock.Anything, domain.RepoName{}, mock.Anything).Return(ports.PendingIssuePage{Issues: []domain.PendingIssue{
101+ {ID: "a", Repo: name(t, "on"), Title: ti, IPHash: []byte("secret")},
102+ {ID: "b", Repo: name(t, "off"), Title: ti},
103+ }}, nil)
104+ ov, err := in.Overview(ctx, domain.Owner(), name(t, "other"))
105+ require.NoError(t, err)
106+ assert.Equal(t, 1, ov.Counts.Pending, "issues off hides pending ones too")
107+ require.Len(t, ov.Pending, 1)
108+ assert.Nil(t, ov.Pending[0].IPHash)
109+ assert.Equal(t, []domain.RepoName{name(t, "on")}, ov.Repos)
110+}
@@ -85,3 +85,26 @@ func TestInboxOverviewSortsFiltersAndSkipsIssuesOff(t *testing.T) {
85 assert.Equal(t, "alpha", got.Issues[0].Repo.String())85 assert.Equal(t, "alpha", got.Issues[0].Repo.String())
86 assert.Len(t, got.Repos, 2, "the filter list keeps every repository")86 assert.Len(t, got.Repos, 2, "the filter list keeps every repository")
87 }87 }
88+
89+func TestInboxListsPendingIssuesWithoutTheirHash(t *testing.T) {
90+ t.Parallel()
91+ ctx := context.Background()
92+ mrs, is, meta, pending := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t), mocks.NewPendingIssueStore(t)
93+ in := usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: is, Pending: pending})
94+ mrs.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
95+ is.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
96+ meta.EXPECT().Get(mock.Anything, name(t, "on")).Return(repoWithIssues(t, "on", domain.IssuesPublic), nil)
97+ meta.EXPECT().Get(mock.Anything, name(t, "off")).Return(repoWithIssues(t, "off", domain.IssuesOff), nil)
98+ ti, err := domain.ParseIssueTitle("hi")
99+ require.NoError(t, err)
100+ pending.EXPECT().List(mock.Anything, domain.RepoName{}, mock.Anything).Return(ports.PendingIssuePage{Issues: []domain.PendingIssue{
101+ {ID: "a", Repo: name(t, "on"), Title: ti, IPHash: []byte("secret")},
102+ {ID: "b", Repo: name(t, "off"), Title: ti},
103+ }}, nil)
104+ ov, err := in.Overview(ctx, domain.Owner(), name(t, "other"))
105+ require.NoError(t, err)
106+ assert.Equal(t, 1, ov.Counts.Pending, "issues off hides pending ones too")
107+ require.Len(t, ov.Pending, 1)
108+ assert.Nil(t, ov.Pending[0].IPHash)
109+ assert.Equal(t, []domain.RepoName{name(t, "on")}, ov.Repos)
110+}
A internal/usecase/issue_keys.go +62 -0
@@ -0,0 +1,62 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/hkdf"
8+ "crypto/hmac"
9+ "crypto/sha256"
10+ "fmt"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// HKDF info strings of the subkeys of the issue form key. Each purpose has its
17+// own key, so that a value signed for one can never pass for another.
18+const (
19+ infoIPHash = "ermite issue ip-hash"
20+ infoPoW = "ermite issue pow"
21+ infoFormToken = "ermite issue form-token"
22+)
23+
24+// IssueKeys holds the subkeys of the public issue form, derived with
25+// HKDF-SHA256 from the server key of the IssueKeyStore.
26+type IssueKeys struct {
27+ ipHash, pow, formToken []byte
28+}
29+
30+// NewIssueKeys loads the server key (generated on the first call) and derives
31+// the subkeys.
32+func NewIssueKeys(ctx context.Context, store ports.IssueKeyStore) (*IssueKeys, error) {
33+ master, err := store.Key(ctx)
34+ if err != nil {
35+ return nil, fmt.Errorf("load issue key: %w", err)
36+ }
37+ k := &IssueKeys{}
38+ for _, d := range []struct {
39+ dst *[]byte
40+ info string
41+ }{{&k.ipHash, infoIPHash}, {&k.pow, infoPoW}, {&k.formToken, infoFormToken}} {
42+ if *d.dst, err = hkdf.Key(sha256.New, master, nil, d.info, sha256.Size); err != nil {
43+ return nil, fmt.Errorf("derive %q: %w", d.info, err)
44+ }
45+ }
46+ return k, nil
47+}
48+
49+// IPHash returns HMAC-SHA256 of the client key (clientlimit.Key of the
50+// address) under the ip-hash subkey: domain.IPHashLen bytes. It is stable
51+// across restarts and says nothing about the address without the key.
52+func (k *IssueKeys) IPHash(addrKey string) []byte {
53+ m := hmac.New(sha256.New, k.ipHash)
54+ m.Write([]byte(addrKey))
55+ return m.Sum(nil)[:domain.IPHashLen]
56+}
57+
58+// PoWKey returns a copy of the subkey that signs the proof-of-work challenges.
59+func (k *IssueKeys) PoWKey() []byte { return append([]byte(nil), k.pow...) }
60+
61+// FormTokenKey returns a copy of the subkey that signs the form tokens.
62+func (k *IssueKeys) FormTokenKey() []byte { return append([]byte(nil), k.formToken...) }
@@ -0,0 +1,62 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/hkdf"
8+ "crypto/hmac"
9+ "crypto/sha256"
10+ "fmt"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// HKDF info strings of the subkeys of the issue form key. Each purpose has its
17+// own key, so that a value signed for one can never pass for another.
18+const (
19+ infoIPHash = "ermite issue ip-hash"
20+ infoPoW = "ermite issue pow"
21+ infoFormToken = "ermite issue form-token"
22+)
23+
24+// IssueKeys holds the subkeys of the public issue form, derived with
25+// HKDF-SHA256 from the server key of the IssueKeyStore.
26+type IssueKeys struct {
27+ ipHash, pow, formToken []byte
28+}
29+
30+// NewIssueKeys loads the server key (generated on the first call) and derives
31+// the subkeys.
32+func NewIssueKeys(ctx context.Context, store ports.IssueKeyStore) (*IssueKeys, error) {
33+ master, err := store.Key(ctx)
34+ if err != nil {
35+ return nil, fmt.Errorf("load issue key: %w", err)
36+ }
37+ k := &IssueKeys{}
38+ for _, d := range []struct {
39+ dst *[]byte
40+ info string
41+ }{{&k.ipHash, infoIPHash}, {&k.pow, infoPoW}, {&k.formToken, infoFormToken}} {
42+ if *d.dst, err = hkdf.Key(sha256.New, master, nil, d.info, sha256.Size); err != nil {
43+ return nil, fmt.Errorf("derive %q: %w", d.info, err)
44+ }
45+ }
46+ return k, nil
47+}
48+
49+// IPHash returns HMAC-SHA256 of the client key (clientlimit.Key of the
50+// address) under the ip-hash subkey: domain.IPHashLen bytes. It is stable
51+// across restarts and says nothing about the address without the key.
52+func (k *IssueKeys) IPHash(addrKey string) []byte {
53+ m := hmac.New(sha256.New, k.ipHash)
54+ m.Write([]byte(addrKey))
55+ return m.Sum(nil)[:domain.IPHashLen]
56+}
57+
58+// PoWKey returns a copy of the subkey that signs the proof-of-work challenges.
59+func (k *IssueKeys) PoWKey() []byte { return append([]byte(nil), k.pow...) }
60+
61+// FormTokenKey returns a copy of the subkey that signs the form tokens.
62+func (k *IssueKeys) FormTokenKey() []byte { return append([]byte(nil), k.formToken...) }
A internal/usecase/issue_moderation.go +118 -0
@@ -0,0 +1,118 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// IssueModeration is the owner's moderation of the issues submitted through
17+// the public form (ADR 0031): list, approve and reject, with an optional ban of
18+// the submitter. Owner rights only; anyone else gets domain.ErrNotFound, as
19+// for a page that does not exist.
20+type IssueModeration struct {
21+ meta ports.RepoMetaStore
22+ pending ports.PendingIssueStore
23+ bans ports.IssueBanStore
24+ bus ports.EventBus
25+ clock ports.Clock
26+}
27+
28+var _ ports.IssueModerationService = (*IssueModeration)(nil)
29+
30+// IssueModerationConfig holds what the moderation needs. Every field is
31+// required.
32+type IssueModerationConfig struct {
33+ Meta ports.RepoMetaStore
34+ Pending ports.PendingIssueStore
35+ Bans ports.IssueBanStore
36+ Bus ports.EventBus
37+ Clock ports.Clock
38+}
39+
40+// NewIssueModeration builds the moderation use case.
41+func NewIssueModeration(cfg IssueModerationConfig) *IssueModeration {
42+ return &IssueModeration{meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, bus: cfg.Bus, clock: cfg.Clock}
43+}
44+
45+// ListPending lists the pending issues of a repository, or of every repository
46+// when repo is zero, oldest first.
47+func (m *IssueModeration) ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
48+ if !p.HasOwnerRights() {
49+ return ports.PendingIssuePage{}, domain.ErrNotFound
50+ }
51+ size, err := pageSize(page.PageSize)
52+ if err != nil {
53+ return ports.PendingIssuePage{}, err
54+ }
55+ page.PageSize = size
56+ out, err := m.pending.List(ctx, repo, page)
57+ if err != nil {
58+ return ports.PendingIssuePage{}, fmt.Errorf("list pending issues: %w", err)
59+ }
60+ return out, nil
61+}
62+
63+// Approve gives the pending issue the next number of its repository and opens
64+// it, then publishes IssueOpened. The repository must exist with issues on
65+// (else domain.ErrNotFound), and so must the pending issue.
66+func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
67+ if !p.HasOwnerRights() {
68+ return domain.Issue{}, domain.ErrNotFound
69+ }
70+ r, err := lookupRepo(ctx, m.meta, repo)
71+ if err != nil {
72+ return domain.Issue{}, err
73+ }
74+ if r.Settings.Issues.OrDefault() == domain.IssuesOff {
75+ return domain.Issue{}, domain.ErrNotFound
76+ }
77+ issue, err := m.pending.Approve(ctx, repo, id)
78+ if err != nil {
79+ if errors.Is(err, domain.ErrNotFound) {
80+ return domain.Issue{}, err
81+ }
82+ return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
83+ }
84+ log := zerowrap.FromCtx(ctx)
85+ log.Info().Str("repo", repo.String()).Int64("number", issue.Number).
86+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")
87+ now := m.clock.Now().UTC()
88+ m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})
89+ return issue, nil
90+}
91+
92+// Reject deletes the pending issue. With ban, its submitter is first banned
93+// for domain.BanDuration.
94+func (m *IssueModeration) Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error {
95+ if !p.HasOwnerRights() {
96+ return domain.ErrNotFound
97+ }
98+ pending, err := m.pending.Get(ctx, repo, id)
99+ if err != nil {
100+ return err
101+ }
102+ if ban {
103+ until := m.clock.Now().UTC().Add(domain.BanDuration)
104+ if err := m.bans.Ban(ctx, pending.IPHash, until); err != nil {
105+ return fmt.Errorf("ban submitter of pending issue of %q: %w", repo, err)
106+ }
107+ }
108+ if err := m.pending.Delete(ctx, repo, id); err != nil {
109+ if errors.Is(err, domain.ErrNotFound) {
110+ return err
111+ }
112+ return fmt.Errorf("delete pending issue of %q: %w", repo, err)
113+ }
114+ log := zerowrap.FromCtx(ctx)
115+ log.Info().Str("repo", repo.String()).Bool("ban", ban).
116+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue rejected")
117+ return nil
118+}
@@ -0,0 +1,118 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// IssueModeration is the owner's moderation of the issues submitted through
17+// the public form (ADR 0031): list, approve and reject, with an optional ban of
18+// the submitter. Owner rights only; anyone else gets domain.ErrNotFound, as
19+// for a page that does not exist.
20+type IssueModeration struct {
21+ meta ports.RepoMetaStore
22+ pending ports.PendingIssueStore
23+ bans ports.IssueBanStore
24+ bus ports.EventBus
25+ clock ports.Clock
26+}
27+
28+var _ ports.IssueModerationService = (*IssueModeration)(nil)
29+
30+// IssueModerationConfig holds what the moderation needs. Every field is
31+// required.
32+type IssueModerationConfig struct {
33+ Meta ports.RepoMetaStore
34+ Pending ports.PendingIssueStore
35+ Bans ports.IssueBanStore
36+ Bus ports.EventBus
37+ Clock ports.Clock
38+}
39+
40+// NewIssueModeration builds the moderation use case.
41+func NewIssueModeration(cfg IssueModerationConfig) *IssueModeration {
42+ return &IssueModeration{meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, bus: cfg.Bus, clock: cfg.Clock}
43+}
44+
45+// ListPending lists the pending issues of a repository, or of every repository
46+// when repo is zero, oldest first.
47+func (m *IssueModeration) ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
48+ if !p.HasOwnerRights() {
49+ return ports.PendingIssuePage{}, domain.ErrNotFound
50+ }
51+ size, err := pageSize(page.PageSize)
52+ if err != nil {
53+ return ports.PendingIssuePage{}, err
54+ }
55+ page.PageSize = size
56+ out, err := m.pending.List(ctx, repo, page)
57+ if err != nil {
58+ return ports.PendingIssuePage{}, fmt.Errorf("list pending issues: %w", err)
59+ }
60+ return out, nil
61+}
62+
63+// Approve gives the pending issue the next number of its repository and opens
64+// it, then publishes IssueOpened. The repository must exist with issues on
65+// (else domain.ErrNotFound), and so must the pending issue.
66+func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
67+ if !p.HasOwnerRights() {
68+ return domain.Issue{}, domain.ErrNotFound
69+ }
70+ r, err := lookupRepo(ctx, m.meta, repo)
71+ if err != nil {
72+ return domain.Issue{}, err
73+ }
74+ if r.Settings.Issues.OrDefault() == domain.IssuesOff {
75+ return domain.Issue{}, domain.ErrNotFound
76+ }
77+ issue, err := m.pending.Approve(ctx, repo, id)
78+ if err != nil {
79+ if errors.Is(err, domain.ErrNotFound) {
80+ return domain.Issue{}, err
81+ }
82+ return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
83+ }
84+ log := zerowrap.FromCtx(ctx)
85+ log.Info().Str("repo", repo.String()).Int64("number", issue.Number).
86+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")
87+ now := m.clock.Now().UTC()
88+ m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})
89+ return issue, nil
90+}
91+
92+// Reject deletes the pending issue. With ban, its submitter is first banned
93+// for domain.BanDuration.
94+func (m *IssueModeration) Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error {
95+ if !p.HasOwnerRights() {
96+ return domain.ErrNotFound
97+ }
98+ pending, err := m.pending.Get(ctx, repo, id)
99+ if err != nil {
100+ return err
101+ }
102+ if ban {
103+ until := m.clock.Now().UTC().Add(domain.BanDuration)
104+ if err := m.bans.Ban(ctx, pending.IPHash, until); err != nil {
105+ return fmt.Errorf("ban submitter of pending issue of %q: %w", repo, err)
106+ }
107+ }
108+ if err := m.pending.Delete(ctx, repo, id); err != nil {
109+ if errors.Is(err, domain.ErrNotFound) {
110+ return err
111+ }
112+ return fmt.Errorf("delete pending issue of %q: %w", repo, err)
113+ }
114+ log := zerowrap.FromCtx(ctx)
115+ log.Info().Str("repo", repo.String()).Bool("ban", ban).
116+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue rejected")
117+ return nil
118+}
A internal/usecase/issue_moderation_test.go +209 -0
@@ -0,0 +1,209 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
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+
21+type 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+
31+func 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+
50+func 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+
72+func 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+
84+func TestIssueModerationApprove(t *testing.T) {
85+ t.Parallel()
86+ ctx := context.Background()
87+ t.Run("publishes IssueOpened after the commit", func(t *testing.T) {
88+ u := newModerationUnit(t, domain.IssuesPublic)
89+ issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen}
90+ u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id).Return(issue, nil).Once()
91+ u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()
92+ got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)
93+ require.NoError(t, err)
94+ assert.Equal(t, int64(7), got.Number)
95+ })
96+ t.Run("issues off hides the repository", func(t *testing.T) {
97+ for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
98+ u := newModerationUnit(t, mode)
99+ _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
100+ assert.ErrorIs(t, err, domain.ErrNotFound)
101+ }
102+ })
103+ t.Run("no event when the store refuses", func(t *testing.T) {
104+ u := newModerationUnit(t, domain.IssuesOwner)
105+ u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id).Return(domain.Issue{}, domain.ErrNotFound).Once()
106+ _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
107+ assert.ErrorIs(t, err, domain.ErrNotFound)
108+ })
109+}
110+
111+func TestIssueModerationReject(t *testing.T) {
112+ t.Parallel()
113+ ctx := context.Background()
114+ hash := bytes.Repeat([]byte{3}, domain.IPHashLen)
115+ t.Run("with a ban", func(t *testing.T) {
116+ u := newModerationUnit(t, domain.IssuesPublic)
117+ 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()
118+ u.bans.EXPECT().Ban(mock.Anything, hash, now.Add(domain.BanDuration)).Return(nil).Once()
119+ u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
120+ require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true))
121+ })
122+ t.Run("without a ban", func(t *testing.T) {
123+ u := newModerationUnit(t, domain.IssuesPublic)
124+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
125+ u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
126+ require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, false))
127+ })
128+ t.Run("unknown id", func(t *testing.T) {
129+ u := newModerationUnit(t, domain.IssuesPublic)
130+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{}, domain.ErrNotFound).Once()
131+ assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), domain.ErrNotFound)
132+ })
133+ t.Run("a failed ban keeps the pending issue", func(t *testing.T) {
134+ u := newModerationUnit(t, domain.IssuesPublic)
135+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
136+ u.bans.EXPECT().Ban(mock.Anything, hash, mock.Anything).Return(assert.AnError).Once()
137+ assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), assert.AnError)
138+ })
139+}
140+
141+func TestIssueKeysSubkeys(t *testing.T) {
142+ t.Parallel()
143+ ctx := context.Background()
144+ master := bytes.Repeat([]byte{0xAB}, 32)
145+ store := mocks.NewIssueKeyStore(t)
146+ store.EXPECT().Key(mock.Anything).Return(master, nil)
147+ k1, err := usecase.NewIssueKeys(ctx, store)
148+ require.NoError(t, err)
149+ k2, err := usecase.NewIssueKeys(ctx, store)
150+ require.NoError(t, err)
151+
152+ assert.Equal(t, k1.PoWKey(), k2.PoWKey(), "stable")
153+ assert.Equal(t, k1.FormTokenKey(), k2.FormTokenKey())
154+ assert.Equal(t, k1.IPHash("1.2.3.4"), k2.IPHash("1.2.3.4"))
155+
156+ assert.Len(t, k1.PoWKey(), 32)
157+ assert.NotEqual(t, k1.PoWKey(), k1.FormTokenKey(), "one key per purpose")
158+ assert.NotEqual(t, master, k1.PoWKey())
159+ assert.NotEqual(t, master, k1.FormTokenKey())
160+
161+ h := k1.IPHash("1.2.3.4")
162+ assert.Len(t, h, domain.IPHashLen)
163+ assert.NotEqual(t, h, k1.IPHash("1.2.3.5"))
164+ assert.NotContains(t, string(h), "1.2.3.4")
165+
166+ other := mocks.NewIssueKeyStore(t)
167+ other.EXPECT().Key(mock.Anything).Return(bytes.Repeat([]byte{0xCD}, 32), nil)
168+ k3, err := usecase.NewIssueKeys(ctx, other)
169+ require.NoError(t, err)
170+ assert.NotEqual(t, h, k3.IPHash("1.2.3.4"), "another server key, another hash")
171+
172+ // The returned keys are copies.
173+ pow := k1.PoWKey()
174+ pow[0] ^= 0xFF
175+ assert.NotEqual(t, pow, k1.PoWKey())
176+}
177+
178+func TestIssueSubmittedReachesOnlyTheOwnerTopic(t *testing.T) {
179+ t.Parallel()
180+ synctest.Test(t, func(t *testing.T) {
181+ r := newEventRig(t, 16)
182+ a := r.repo("aaa", domain.VisibilityPublic)
183+ r.meta.EXPECT().Get(mock.Anything, a).Unset()
184+ r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
185+ ctx, cancel := context.WithCancel(context.Background())
186+ defer cancel()
187+
188+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
189+ home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}})
190+ repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}})
191+ rootRepo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
192+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
193+ synctest.Wait()
194+
195+ r.feed <- domain.IssueSubmitted{Repo: a, ID: "x", At: now}
196+ // A sentinel every watcher sees, to prove the first one was skipped.
197+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
198+
199+ sub, ok := owner.next(t).Event.(domain.IssueSubmitted)
200+ require.True(t, ok, "the owner topic gets it")
201+ assert.Equal(t, a, sub.Repo)
202+ _, ok = owner.next(t).Event.(domain.IssueOpened)
203+ require.True(t, ok)
204+ for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "root repo+home": rootRepo, "anonymous": anon} {
205+ e, ok := w.next(t).Event.(domain.IssueOpened)
206+ require.True(t, ok, "%s: the first event is the sentinel, not issue.submitted (got %T)", name, e)
207+ }
208+ })
209+}
@@ -0,0 +1,209 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
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+
21+type 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+
31+func 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+
50+func 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+
72+func 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+
84+func TestIssueModerationApprove(t *testing.T) {
85+ t.Parallel()
86+ ctx := context.Background()
87+ t.Run("publishes IssueOpened after the commit", func(t *testing.T) {
88+ u := newModerationUnit(t, domain.IssuesPublic)
89+ issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen}
90+ u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id).Return(issue, nil).Once()
91+ u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()
92+ got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)
93+ require.NoError(t, err)
94+ assert.Equal(t, int64(7), got.Number)
95+ })
96+ t.Run("issues off hides the repository", func(t *testing.T) {
97+ for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
98+ u := newModerationUnit(t, mode)
99+ _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
100+ assert.ErrorIs(t, err, domain.ErrNotFound)
101+ }
102+ })
103+ t.Run("no event when the store refuses", func(t *testing.T) {
104+ u := newModerationUnit(t, domain.IssuesOwner)
105+ u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id).Return(domain.Issue{}, domain.ErrNotFound).Once()
106+ _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
107+ assert.ErrorIs(t, err, domain.ErrNotFound)
108+ })
109+}
110+
111+func TestIssueModerationReject(t *testing.T) {
112+ t.Parallel()
113+ ctx := context.Background()
114+ hash := bytes.Repeat([]byte{3}, domain.IPHashLen)
115+ t.Run("with a ban", func(t *testing.T) {
116+ u := newModerationUnit(t, domain.IssuesPublic)
117+ 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()
118+ u.bans.EXPECT().Ban(mock.Anything, hash, now.Add(domain.BanDuration)).Return(nil).Once()
119+ u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
120+ require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true))
121+ })
122+ t.Run("without a ban", func(t *testing.T) {
123+ u := newModerationUnit(t, domain.IssuesPublic)
124+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
125+ u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
126+ require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, false))
127+ })
128+ t.Run("unknown id", func(t *testing.T) {
129+ u := newModerationUnit(t, domain.IssuesPublic)
130+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{}, domain.ErrNotFound).Once()
131+ assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), domain.ErrNotFound)
132+ })
133+ t.Run("a failed ban keeps the pending issue", func(t *testing.T) {
134+ u := newModerationUnit(t, domain.IssuesPublic)
135+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
136+ u.bans.EXPECT().Ban(mock.Anything, hash, mock.Anything).Return(assert.AnError).Once()
137+ assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), assert.AnError)
138+ })
139+}
140+
141+func TestIssueKeysSubkeys(t *testing.T) {
142+ t.Parallel()
143+ ctx := context.Background()
144+ master := bytes.Repeat([]byte{0xAB}, 32)
145+ store := mocks.NewIssueKeyStore(t)
146+ store.EXPECT().Key(mock.Anything).Return(master, nil)
147+ k1, err := usecase.NewIssueKeys(ctx, store)
148+ require.NoError(t, err)
149+ k2, err := usecase.NewIssueKeys(ctx, store)
150+ require.NoError(t, err)
151+
152+ assert.Equal(t, k1.PoWKey(), k2.PoWKey(), "stable")
153+ assert.Equal(t, k1.FormTokenKey(), k2.FormTokenKey())
154+ assert.Equal(t, k1.IPHash("1.2.3.4"), k2.IPHash("1.2.3.4"))
155+
156+ assert.Len(t, k1.PoWKey(), 32)
157+ assert.NotEqual(t, k1.PoWKey(), k1.FormTokenKey(), "one key per purpose")
158+ assert.NotEqual(t, master, k1.PoWKey())
159+ assert.NotEqual(t, master, k1.FormTokenKey())
160+
161+ h := k1.IPHash("1.2.3.4")
162+ assert.Len(t, h, domain.IPHashLen)
163+ assert.NotEqual(t, h, k1.IPHash("1.2.3.5"))
164+ assert.NotContains(t, string(h), "1.2.3.4")
165+
166+ other := mocks.NewIssueKeyStore(t)
167+ other.EXPECT().Key(mock.Anything).Return(bytes.Repeat([]byte{0xCD}, 32), nil)
168+ k3, err := usecase.NewIssueKeys(ctx, other)
169+ require.NoError(t, err)
170+ assert.NotEqual(t, h, k3.IPHash("1.2.3.4"), "another server key, another hash")
171+
172+ // The returned keys are copies.
173+ pow := k1.PoWKey()
174+ pow[0] ^= 0xFF
175+ assert.NotEqual(t, pow, k1.PoWKey())
176+}
177+
178+func TestIssueSubmittedReachesOnlyTheOwnerTopic(t *testing.T) {
179+ t.Parallel()
180+ synctest.Test(t, func(t *testing.T) {
181+ r := newEventRig(t, 16)
182+ a := r.repo("aaa", domain.VisibilityPublic)
183+ r.meta.EXPECT().Get(mock.Anything, a).Unset()
184+ r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
185+ ctx, cancel := context.WithCancel(context.Background())
186+ defer cancel()
187+
188+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
189+ home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}})
190+ repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}})
191+ rootRepo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
192+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
193+ synctest.Wait()
194+
195+ r.feed <- domain.IssueSubmitted{Repo: a, ID: "x", At: now}
196+ // A sentinel every watcher sees, to prove the first one was skipped.
197+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
198+
199+ sub, ok := owner.next(t).Event.(domain.IssueSubmitted)
200+ require.True(t, ok, "the owner topic gets it")
201+ assert.Equal(t, a, sub.Repo)
202+ _, ok = owner.next(t).Event.(domain.IssueOpened)
203+ require.True(t, ok)
204+ for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "root repo+home": rootRepo, "anonymous": anon} {
205+ e, ok := w.next(t).Event.(domain.IssueOpened)
206+ require.True(t, ok, "%s: the first event is the sentinel, not issue.submitted (got %T)", name, e)
207+ }
208+ })
209+}

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 41

↑↓ moveenter openesc close