feat: signature badges on merge request commits (#73)
- commit
- df1db3633d13a90a4e152cfc1cd566ae4e863c9a
- author
- brice · · 2026-10-18 18:36 UTC
- committer
- bnema · · 2026-10-18 18:36 UTC
- parent
- 94ae608
- signature
- SSH Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown - refs
- tag: v0.26.0
10 files · +275 -167
M docs/commit-signatures.md +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Commit signatures | |
| 2 | 2 | ||
| 3 | - | ermite checks commit signatures on the commit list and the commit page and shows a badge with the protocol and the result. The API returns the same result in `Commit.verification` from `ListCommits` and `GetCommit`. | |
| 3 | + | ermite checks commit signatures on the commit list, the commit page and the commits of a merge request, and shows a badge with the protocol and the result. The API returns the same result in `Commit.verification` from `ListCommits`, `GetCommit` and `GetMergeRequest`. | |
| 4 | 4 | ||
| 5 | 5 | ## Formats | |
| 6 | 6 | ||
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Commit signatures | 1 | # Commit signatures | ||
| 2 | 2 | ||||
| 3 | - | ermite checks commit signatures on the commit list and the commit page and shows a badge with the protocol and the result. The API returns the same result in `Commit.verification` from `ListCommits` and `GetCommit`. | 3 | + | ermite checks commit signatures on the commit list, the commit page and the commits of a merge request, and shows a badge with the protocol and the result. The API returns the same result in `Commit.verification` from `ListCommits`, `GetCommit` and `GetMergeRequest`. |
| 4 | 4 | ||||
| 5 | ## Formats | 5 | ## Formats | ||
| 6 | 6 | ||||
M internal/adapters/web/mr.go +3 -0
| @@ -148,6 +148,8 @@ type MRCommit struct { | |||
| 148 | 148 | Short, Href, Subject, Author, When, WhenAt string | |
| 149 | 149 | // New is true for a commit pushed after the head the reviewer last read. | |
| 150 | 150 | New bool | |
| 151 | + | // Sig is the signature badge, nil for an unsigned or unchecked commit. | |
| 152 | + | Sig *SignatureBadge | |
| 151 | 153 | } | |
| 152 | 154 | ||
| 153 | 155 | // MergeBox is what the owner does with an open request. | |
| @@ -534,6 +536,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 534 | 536 | row := commitRow(d.name(), c, now) | |
| 535 | 537 | p.Commits = append(p.Commits, MRCommit{ | |
| 536 | 538 | Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt, | |
| 539 | + | Sig: row.Sig, | |
| 537 | 540 | }) | |
| 538 | 541 | } | |
| 539 | 542 | lastRead := res.LastReadID.String() | |
| @@ -148,6 +148,8 @@ type MRCommit struct { | |||||
| 148 | Short, Href, Subject, Author, When, WhenAt string | 148 | Short, Href, Subject, Author, When, WhenAt string | ||
| 149 | // New is true for a commit pushed after the head the reviewer last read. | 149 | // New is true for a commit pushed after the head the reviewer last read. | ||
| 150 | New bool | 150 | New bool | ||
| 151 | + | // Sig is the signature badge, nil for an unsigned or unchecked commit. | |||
| 152 | + | Sig *SignatureBadge | |||
| 151 | } | 153 | } | ||
| 152 | 154 | ||||
| 153 | // MergeBox is what the owner does with an open request. | 155 | // MergeBox is what the owner does with an open request. | ||
| @@ -534,6 +536,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 534 | row := commitRow(d.name(), c, now) | 536 | row := commitRow(d.name(), c, now) | ||
| 535 | p.Commits = append(p.Commits, MRCommit{ | 537 | p.Commits = append(p.Commits, MRCommit{ | ||
| 536 | Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt, | 538 | Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt, | ||
| 539 | + | Sig: row.Sig, | |||
| 537 | }) | 540 | }) | ||
| 538 | } | 541 | } | ||
| 539 | lastRead := res.LastReadID.String() | 542 | lastRead := res.LastReadID.String() | ||
M internal/adapters/web/mr.templ +3 -0
| @@ -318,6 +318,9 @@ templ mrRail(p MRPage) { | |||
| 318 | 318 | <li data-new?={ c.New }> | |
| 319 | 319 | <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a> | |
| 320 | 320 | <span class="s">{ c.Subject }</span> | |
| 321 | + | if c.Sig != nil { | |
| 322 | + | @signaturePill(c.Sig) | |
| 323 | + | } | |
| 321 | 324 | <span class="mute"> | |
| 322 | 325 | { c.Author } | |
| 323 | 326 | if c.WhenAt != "" { | |
| @@ -318,6 +318,9 @@ templ mrRail(p MRPage) { | |||||
| 318 | <li data-new?={ c.New }> | 318 | <li data-new?={ c.New }> | ||
| 319 | <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a> | 319 | <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a> | ||
| 320 | <span class="s">{ c.Subject }</span> | 320 | <span class="s">{ c.Subject }</span> | ||
| 321 | + | if c.Sig != nil { | |||
| 322 | + | @signaturePill(c.Sig) | |||
| 323 | + | } | |||
| 321 | <span class="mute"> | 324 | <span class="mute"> | ||
| 322 | { c.Author } | 325 | { c.Author } | ||
| 323 | if c.WhenAt != "" { | 326 | if c.WhenAt != "" { | ||
M internal/adapters/web/mr_templ.go +174 -164 generated file: collapsed
M internal/adapters/web/signature_test.go +12 -0
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -139,3 +140,14 @@ func TestCommitPageShowsTheSignature(t *testing.T) { | |||
| 139 | 140 | got = page(domain.CommitVerification{}) | |
| 140 | 141 | assert.NotContains(t, got, "signature") | |
| 141 | 142 | } | |
| 143 | + | ||
| 144 | + | func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) { | |
| 145 | + | t.Parallel() | |
| 146 | + | signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false) | |
| 147 | + | got := renderToString(t, mrRail(MRPage{Commits: []MRCommit{ | |
| 148 | + | {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed}, | |
| 149 | + | {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"}, | |
| 150 | + | }})) | |
| 151 | + | assert.Equal(t, 1, strings.Count(got, `class="pill sig sig-ok"`), "only the signed commit has a pill") | |
| 152 | + | assert.Contains(t, got, `<span class="sig-proto">GPG</span> Verified`) | |
| 153 | + | } | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "strings" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -139,3 +140,14 @@ func TestCommitPageShowsTheSignature(t *testing.T) { | |||||
| 139 | got = page(domain.CommitVerification{}) | 140 | got = page(domain.CommitVerification{}) | ||
| 140 | assert.NotContains(t, got, "signature") | 141 | assert.NotContains(t, got, "signature") | ||
| 141 | } | 142 | } | ||
| 143 | + | ||||
| 144 | + | func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) { | |||
| 145 | + | t.Parallel() | |||
| 146 | + | signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false) | |||
| 147 | + | got := renderToString(t, mrRail(MRPage{Commits: []MRCommit{ | |||
| 148 | + | {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed}, | |||
| 149 | + | {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"}, | |||
| 150 | + | }})) | |||
| 151 | + | assert.Equal(t, 1, strings.Count(got, `class="pill sig sig-ok"`), "only the signed commit has a pill") | |||
| 152 | + | assert.Contains(t, got, `<span class="sig-proto">GPG</span> Verified`) | |||
| 153 | + | } | |||
M internal/app/forge.go +1 -0
| @@ -314,6 +314,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 314 | 314 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | |
| 315 | 315 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), | |
| 316 | 316 | }) | |
| 317 | + | f.MergeRequests.WithVerifier(f.Signatures) | |
| 317 | 318 | f.CI.SetPullGate(f.MergeRequests) | |
| 318 | 319 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | |
| 319 | 320 | ||
| @@ -314,6 +314,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 314 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | 314 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | ||
| 315 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), | 315 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), | ||
| 316 | }) | 316 | }) | ||
| 317 | + | f.MergeRequests.WithVerifier(f.Signatures) | |||
| 317 | f.CI.SetPullGate(f.MergeRequests) | 318 | f.CI.SetPullGate(f.MergeRequests) | ||
| 318 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | 319 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | ||
| 319 | 320 | ||||
M internal/app/signatures_e2e_test.go +18 -0
| @@ -135,6 +135,24 @@ func TestCommitSignaturesEndToEnd(t *testing.T) { | |||
| 135 | 135 | assert.Contains(t, body, "GPG") | |
| 136 | 136 | assert.Contains(t, body, "sig-ok") | |
| 137 | 137 | ||
| 138 | + | // A merge request lists its commits with their signature state, over the | |
| 139 | + | // API and on its page. | |
| 140 | + | h.mustGit(wt, "checkout", "-q", "-b", "feat") | |
| 141 | + | h.writeFile(wt, "d.txt", []byte("d\n")) | |
| 142 | + | h.mustGit(wt, "-c", "gpg.format=ssh", "-c", "user.signingkey="+sshKey+".pub", "commit", "-q", "--amend", "-S", "--no-edit") | |
| 143 | + | feat := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD")) | |
| 144 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/sig.git"), "feat") | |
| 145 | + | _, err = root.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "sig", Title: "d", SourceBranch: "feat"})) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | mr, err := root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "sig", Number: 1})) | |
| 148 | + | require.NoError(t, err) | |
| 149 | + | require.Len(t, mr.Msg.GetCommits(), 1) | |
| 150 | + | assert.Equal(t, feat, mr.Msg.GetCommits()[0].GetId()) | |
| 151 | + | assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, mr.Msg.GetCommits()[0].GetVerification().GetStatus()) | |
| 152 | + | code, _, body = h.get("/sig/merge-requests/1") | |
| 153 | + | require.Equal(t, http.StatusOK, code) | |
| 154 | + | assert.Contains(t, body, `<span class="pill sig sig-ok"`) | |
| 155 | + | ||
| 138 | 156 | // Removing the key turns the commit unknown again. | |
| 139 | 157 | _, err = trust.RemoveTrustedKey(ctx, connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: list.Msg.GetKeys()[1].GetId()})) | |
| 140 | 158 | require.NoError(t, err) | |
| @@ -135,6 +135,24 @@ func TestCommitSignaturesEndToEnd(t *testing.T) { | |||||
| 135 | assert.Contains(t, body, "GPG") | 135 | assert.Contains(t, body, "GPG") | ||
| 136 | assert.Contains(t, body, "sig-ok") | 136 | assert.Contains(t, body, "sig-ok") | ||
| 137 | 137 | ||||
| 138 | + | // A merge request lists its commits with their signature state, over the | |||
| 139 | + | // API and on its page. | |||
| 140 | + | h.mustGit(wt, "checkout", "-q", "-b", "feat") | |||
| 141 | + | h.writeFile(wt, "d.txt", []byte("d\n")) | |||
| 142 | + | h.mustGit(wt, "-c", "gpg.format=ssh", "-c", "user.signingkey="+sshKey+".pub", "commit", "-q", "--amend", "-S", "--no-edit") | |||
| 143 | + | feat := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD")) | |||
| 144 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/sig.git"), "feat") | |||
| 145 | + | _, err = root.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "sig", Title: "d", SourceBranch: "feat"})) | |||
| 146 | + | require.NoError(t, err) | |||
| 147 | + | mr, err := root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "sig", Number: 1})) | |||
| 148 | + | require.NoError(t, err) | |||
| 149 | + | require.Len(t, mr.Msg.GetCommits(), 1) | |||
| 150 | + | assert.Equal(t, feat, mr.Msg.GetCommits()[0].GetId()) | |||
| 151 | + | assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, mr.Msg.GetCommits()[0].GetVerification().GetStatus()) | |||
| 152 | + | code, _, body = h.get("/sig/merge-requests/1") | |||
| 153 | + | require.Equal(t, http.StatusOK, code) | |||
| 154 | + | assert.Contains(t, body, `<span class="pill sig sig-ok"`) | |||
| 155 | + | ||||
| 138 | // Removing the key turns the commit unknown again. | 156 | // Removing the key turns the commit unknown again. | ||
| 139 | _, err = trust.RemoveTrustedKey(ctx, connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: list.Msg.GetKeys()[1].GetId()})) | 157 | _, err = trust.RemoveTrustedKey(ctx, connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: list.Msg.GetKeys()[1].GetId()})) | ||
| 140 | require.NoError(t, err) | 158 | require.NoError(t, err) | ||
M internal/usecase/browse.go +8 -2
| @@ -67,10 +67,16 @@ func (b *Browse) WithVerifier(v ports.CommitVerifier) *Browse { | |||
| 67 | 67 | // verify fills the Verification of the commits, in place, with one call. The | |
| 68 | 68 | // slice must not be shared with a cache. | |
| 69 | 69 | func (b *Browse) verify(ctx context.Context, repo domain.RepoName, commits []domain.Commit) { | |
| 70 | - | if b.verifier == nil || len(commits) == 0 { | |
| 70 | + | verifyCommits(ctx, b.verifier, repo, commits) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // verifyCommits fills the Verification of the commits in place, with one call | |
| 74 | + | // to v; a nil v leaves them unchecked. | |
| 75 | + | func verifyCommits(ctx context.Context, v ports.CommitVerifier, repo domain.RepoName, commits []domain.Commit) { | |
| 76 | + | if v == nil || len(commits) == 0 { | |
| 71 | 77 | return | |
| 72 | 78 | } | |
| 73 | - | res := b.verifier.VerifyCommits(ctx, repo, commits) | |
| 79 | + | res := v.VerifyCommits(ctx, repo, commits) | |
| 74 | 80 | for i := range commits { | |
| 75 | 81 | if v, ok := res[commits[i].ID]; ok { | |
| 76 | 82 | commits[i].Verification = v | |
| @@ -67,10 +67,16 @@ func (b *Browse) WithVerifier(v ports.CommitVerifier) *Browse { | |||||
| 67 | // verify fills the Verification of the commits, in place, with one call. The | 67 | // verify fills the Verification of the commits, in place, with one call. The | ||
| 68 | // slice must not be shared with a cache. | 68 | // slice must not be shared with a cache. | ||
| 69 | func (b *Browse) verify(ctx context.Context, repo domain.RepoName, commits []domain.Commit) { | 69 | func (b *Browse) verify(ctx context.Context, repo domain.RepoName, commits []domain.Commit) { | ||
| 70 | - | if b.verifier == nil || len(commits) == 0 { | 70 | + | verifyCommits(ctx, b.verifier, repo, commits) |
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // verifyCommits fills the Verification of the commits in place, with one call | |||
| 74 | + | // to v; a nil v leaves them unchecked. | |||
| 75 | + | func verifyCommits(ctx context.Context, v ports.CommitVerifier, repo domain.RepoName, commits []domain.Commit) { | |||
| 76 | + | if v == nil || len(commits) == 0 { | |||
| 71 | return | 77 | return | ||
| 72 | } | 78 | } | ||
| 73 | - | res := b.verifier.VerifyCommits(ctx, repo, commits) | 79 | + | res := v.VerifyCommits(ctx, repo, commits) |
| 74 | for i := range commits { | 80 | for i := range commits { | ||
| 75 | if v, ok := res[commits[i].ID]; ok { | 81 | if v, ok := res[commits[i].ID]; ok { | ||
| 76 | commits[i].Verification = v | 82 | commits[i].Verification = v | ||
M internal/usecase/mergerequests.go +9 -0
| @@ -55,6 +55,7 @@ type MergeRequests struct { | |||
| 55 | 55 | checks ports.CIChecksReader // nil: no CI, so no check blocks a merge | |
| 56 | 56 | pulls ports.PullRequestTrigger // nil: no pull_request workflows | |
| 57 | 57 | tips ports.CIRefTipStore // nil: who merges is not recorded | |
| 58 | + | verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030) | |
| 58 | 59 | ||
| 59 | 60 | mu sync.Mutex | |
| 60 | 61 | locks map[domain.RepoName]chan struct{} | |
| @@ -105,6 +106,13 @@ func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | |||
| 105 | 106 | } | |
| 106 | 107 | } | |
| 107 | 108 | ||
| 109 | + | // WithVerifier makes GetMergeRequest fill the signature state of the commits | |
| 110 | + | // it lists (ADR 0030). | |
| 111 | + | func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests { | |
| 112 | + | m.verifier = v | |
| 113 | + | return m | |
| 114 | + | } | |
| 115 | + | ||
| 108 | 116 | // lockRepo takes the merge lock of a repository, waiting until ctx ends. The | |
| 109 | 117 | // merges of one repository, and the creation of its merge requests, run one at | |
| 110 | 118 | // a time. The returned function releases the lock. | |
| @@ -490,6 +498,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||
| 490 | 498 | if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil { | |
| 491 | 499 | return ports.MergeRequestDetail{}, err | |
| 492 | 500 | } | |
| 501 | + | verifyCommits(ctx, m.verifier, name, d.Commits) | |
| 493 | 502 | } | |
| 494 | 503 | if opts.SinceLastRead && !d.LastReadID.IsZero() { | |
| 495 | 504 | switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); { | |
| @@ -55,6 +55,7 @@ type MergeRequests struct { | |||||
| 55 | checks ports.CIChecksReader // nil: no CI, so no check blocks a merge | 55 | checks ports.CIChecksReader // nil: no CI, so no check blocks a merge | ||
| 56 | pulls ports.PullRequestTrigger // nil: no pull_request workflows | 56 | pulls ports.PullRequestTrigger // nil: no pull_request workflows | ||
| 57 | tips ports.CIRefTipStore // nil: who merges is not recorded | 57 | tips ports.CIRefTipStore // nil: who merges is not recorded | ||
| 58 | + | verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030) | |||
| 58 | 59 | ||||
| 59 | mu sync.Mutex | 60 | mu sync.Mutex | ||
| 60 | locks map[domain.RepoName]chan struct{} | 61 | locks map[domain.RepoName]chan struct{} | ||
| @@ -105,6 +106,13 @@ func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | |||||
| 105 | } | 106 | } | ||
| 106 | } | 107 | } | ||
| 107 | 108 | ||||
| 109 | + | // WithVerifier makes GetMergeRequest fill the signature state of the commits | |||
| 110 | + | // it lists (ADR 0030). | |||
| 111 | + | func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests { | |||
| 112 | + | m.verifier = v | |||
| 113 | + | return m | |||
| 114 | + | } | |||
| 115 | + | ||||
| 108 | // lockRepo takes the merge lock of a repository, waiting until ctx ends. The | 116 | // lockRepo takes the merge lock of a repository, waiting until ctx ends. The | ||
| 109 | // merges of one repository, and the creation of its merge requests, run one at | 117 | // merges of one repository, and the creation of its merge requests, run one at | ||
| 110 | // a time. The returned function releases the lock. | 118 | // a time. The returned function releases the lock. | ||
| @@ -490,6 +498,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||||
| 490 | if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil { | 498 | if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil { | ||
| 491 | return ports.MergeRequestDetail{}, err | 499 | return ports.MergeRequestDetail{}, err | ||
| 492 | } | 500 | } | ||
| 501 | + | verifyCommits(ctx, m.verifier, name, d.Commits) | |||
| 493 | } | 502 | } | ||
| 494 | if opts.SinceLastRead && !d.LastReadID.IsZero() { | 503 | if opts.SinceLastRead && !d.LastReadID.IsZero() { | ||
| 495 | switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); { | 504 | switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); { | ||
A internal/usecase/mergerequests_signatures_test.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // ADR 0030: the commits of a merge request carry their signature state when a | |
| 18 | + | // verifier is set, in one call; without one they stay unchecked. | |
| 19 | + | func TestGetMergeRequestVerifiesItsCommits(t *testing.T) { | |
| 20 | + | t.Parallel() | |
| 21 | + | r := newMRRig(t) | |
| 22 | + | r.seed() | |
| 23 | + | head := r.feature("feat") | |
| 24 | + | mr := r.open("feat") | |
| 25 | + | ||
| 26 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | require.NotEmpty(t, d.Commits) | |
| 29 | + | assert.Equal(t, domain.StatusUnchecked, d.Commits[0].Verification.Status, "no verifier: unchecked") | |
| 30 | + | ||
| 31 | + | v := mocks.NewCommitVerifier(t) | |
| 32 | + | want := domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusVerified, KeyTitle: "laptop"} | |
| 33 | + | v.EXPECT().VerifyCommits(mock.Anything, r.repo, mock.MatchedBy(func(cs []domain.Commit) bool { | |
| 34 | + | return len(cs) == len(d.Commits) && cs[0].ID == head | |
| 35 | + | })).Return(map[domain.ObjectID]domain.CommitVerification{head: want}).Once() | |
| 36 | + | r.mrs.WithVerifier(v) | |
| 37 | + | ||
| 38 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | assert.Equal(t, want, d.Commits[0].Verification) | |
| 41 | + | ||
| 42 | + | // A diff-only read lists no commits and checks nothing. | |
| 43 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{DiffOnly: true}) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | assert.Empty(t, d.Commits) | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // ADR 0030: the commits of a merge request carry their signature state when a | |||
| 18 | + | // verifier is set, in one call; without one they stay unchecked. | |||
| 19 | + | func TestGetMergeRequestVerifiesItsCommits(t *testing.T) { | |||
| 20 | + | t.Parallel() | |||
| 21 | + | r := newMRRig(t) | |||
| 22 | + | r.seed() | |||
| 23 | + | head := r.feature("feat") | |||
| 24 | + | mr := r.open("feat") | |||
| 25 | + | ||||
| 26 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | require.NotEmpty(t, d.Commits) | |||
| 29 | + | assert.Equal(t, domain.StatusUnchecked, d.Commits[0].Verification.Status, "no verifier: unchecked") | |||
| 30 | + | ||||
| 31 | + | v := mocks.NewCommitVerifier(t) | |||
| 32 | + | want := domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusVerified, KeyTitle: "laptop"} | |||
| 33 | + | v.EXPECT().VerifyCommits(mock.Anything, r.repo, mock.MatchedBy(func(cs []domain.Commit) bool { | |||
| 34 | + | return len(cs) == len(d.Commits) && cs[0].ID == head | |||
| 35 | + | })).Return(map[domain.ObjectID]domain.CommitVerification{head: want}).Once() | |||
| 36 | + | r.mrs.WithVerifier(v) | |||
| 37 | + | ||||
| 38 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | assert.Equal(t, want, d.Commits[0].Verification) | |||
| 41 | + | ||||
| 42 | + | // A diff-only read lists no commits and checks nothing. | |||
| 43 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{DiffOnly: true}) | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | assert.Empty(t, d.Commits) | |||
| 46 | + | } | |||