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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 190 | 190 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 191 | 191 | case domain.IssueCommented: | |
| 192 | 192 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 193 | + | case domain.IssueSubmitted: | |
| 194 | + | return bufferedEvent{known: true} // owner topic only: no repository snapshot | |
| 193 | 195 | case domain.RunUpdated: | |
| 194 | 196 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 195 | 197 | case domain.JobUpdated: | |
| @@ -499,6 +501,23 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 499 | 501 | return nil | |
| 500 | 502 | } | |
| 501 | 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 | 521 | case domain.RepoCreated: | |
| 503 | 522 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | |
| 504 | 523 | 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 nil | 501 | 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 { | |||
| 22 | 22 | meta ports.RepoMetaStore | |
| 23 | 23 | mrs ports.MergeRequestStore | |
| 24 | 24 | issues ports.IssueStore | |
| 25 | + | // pending is nil when the public form is not wired. | |
| 26 | + | pending ports.PendingIssueStore | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | var _ ports.InboxService = (*Inbox)(nil) | |
| @@ -31,11 +33,14 @@ type InboxConfig struct { | |||
| 31 | 33 | Meta ports.RepoMetaStore | |
| 32 | 34 | MergeRequests ports.MergeRequestStore | |
| 33 | 35 | 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 | 41 | // NewInbox builds the inbox use case. | |
| 37 | 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 | 46 | // 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 | 129 | slices.SortStableFunc(out.MergeRequests, func(a, b ports.InboxMergeRequest) int { | |
| 106 | 130 | return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number) | |
| 107 | 131 | }) | |
| @@ -22,6 +22,8 @@ type Inbox struct { | |||||
| 22 | meta ports.RepoMetaStore | 22 | meta ports.RepoMetaStore | ||
| 23 | mrs ports.MergeRequestStore | 23 | mrs ports.MergeRequestStore | ||
| 24 | issues ports.IssueStore | 24 | 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.RepoMetaStore | 33 | Meta ports.RepoMetaStore | ||
| 32 | MergeRequests ports.MergeRequestStore | 34 | MergeRequests ports.MergeRequestStore | ||
| 33 | Issues ports.IssueStore | 35 | 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 counts | 46 | // 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) { | |||
| 85 | 85 | assert.Equal(t, "alpha", got.Issues[0].Repo.String()) | |
| 86 | 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 | + | } | |
| @@ -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 | + | } | |||