tenseleyflow/shithub / 7f3aed3

Browse files

web/handlers/api: emit gh-shape verification object on commits responses

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
7f3aed37f45de5ae091763bb71127c9f8e72a26f
Parents
332cef0
Tree
20a33e3

2 changed files

StatusFile+-
M internal/web/handlers/api/commits.go 69 8
M internal/web/handlers/api/commits_test.go 69 5
internal/web/handlers/api/commits.gomodified
@@ -9,10 +9,12 @@ import (
99
 	"time"
1010
 
1111
 	"github.com/go-chi/chi/v5"
12
+	"github.com/jackc/pgx/v5"
1213
 
1314
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
1415
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
1516
 	"github.com/tenseleyFlow/shithub/internal/repos/git"
17
+	"github.com/tenseleyFlow/shithub/internal/repos/sigverify"
1618
 	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apipage"
1719
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
1820
 )
@@ -41,11 +43,49 @@ type commitAuthor struct {
4143
 }
4244
 
4345
 type commitListItem struct {
44
-	SHA      string       `json:"sha"`
45
-	ShortSHA string       `json:"short_sha"`
46
-	Subject  string       `json:"subject"`
47
-	Author   commitAuthor `json:"author"`
48
-	Body     string       `json:"body,omitempty"`
46
+	SHA          string               `json:"sha"`
47
+	ShortSHA     string               `json:"short_sha"`
48
+	Subject      string               `json:"subject"`
49
+	Author       commitAuthor         `json:"author"`
50
+	Body         string               `json:"body,omitempty"`
51
+	Verification verificationResponse `json:"verification"`
52
+}
53
+
54
+// verificationResponse mirrors gh's documented commit verification
55
+// object. Empty/never-verified commits emit the same shape gh uses
56
+// for unsigned commits:
57
+//
58
+//	{verified: false, reason: "unsigned", signature: null, payload: null, verified_at: null}
59
+//
60
+// `payload` is the bytes the signature was computed over (commit
61
+// object body minus the gpgsig header). Surfaced as a string in gh's
62
+// JSON; we follow suit. nil bytes → JSON null via the pointer trick.
63
+type verificationResponse struct {
64
+	Verified   bool    `json:"verified"`
65
+	Reason     string  `json:"reason"`
66
+	Signature  *string `json:"signature"`
67
+	Payload    *string `json:"payload"`
68
+	VerifiedAt *string `json:"verified_at"`
69
+}
70
+
71
+func presentVerification(v sigverify.View) verificationResponse {
72
+	resp := verificationResponse{
73
+		Verified: v.Verified,
74
+		Reason:   string(v.Reason),
75
+	}
76
+	if v.Signature != "" {
77
+		s := v.Signature
78
+		resp.Signature = &s
79
+	}
80
+	if len(v.Payload) > 0 {
81
+		s := string(v.Payload)
82
+		resp.Payload = &s
83
+	}
84
+	if v.VerifiedAt != nil {
85
+		s := v.VerifiedAt.UTC().Format(time.RFC3339)
86
+		resp.VerifiedAt = &s
87
+	}
88
+	return resp
4989
 }
5090
 
5191
 type commitFile struct {
@@ -72,7 +112,7 @@ type commitStats struct {
72112
 	Total     int `json:"total"`
73113
 }
74114
 
75
-func presentCommit(c git.Commit) commitListItem {
115
+func presentCommit(c git.Commit, v sigverify.View) commitListItem {
76116
 	return commitListItem{
77117
 		SHA:      c.OID,
78118
 		ShortSHA: c.ShortOID,
@@ -83,6 +123,7 @@ func presentCommit(c git.Commit) commitListItem {
83123
 			Email: c.AuthorEmail,
84124
 			Date:  c.AuthorWhen.UTC().Format(time.RFC3339),
85125
 		},
126
+		Verification: presentVerification(v),
86127
 	}
87128
 }
88129
 
@@ -134,9 +175,23 @@ func (h *Handlers) commitsList(w http.ResponseWriter, r *http.Request) {
134175
 		writeJSON(w, http.StatusOK, []commitListItem{})
135176
 		return
136177
 	}
178
+
179
+	// Batch-load verification cache rows for the page's OIDs. Failure
180
+	// here is non-fatal — we fall back to UnsignedView per row so the
181
+	// payload still renders, just without verification metadata.
182
+	oids := make([]string, len(commits))
183
+	for i, c := range commits {
184
+		oids[i] = c.OID
185
+	}
186
+	verifications, err := sigverify.LoadViewsForOIDs(r.Context(), h.d.Pool, repo.ID, oids)
187
+	if err != nil {
188
+		h.d.Logger.WarnContext(r.Context(), "api: load verifications", "error", err, "repo_id", repo.ID)
189
+		verifications = map[string]sigverify.View{}
190
+	}
191
+
137192
 	out := make([]commitListItem, 0, len(commits))
138193
 	for _, c := range commits {
139
-		out = append(out, presentCommit(c))
194
+		out = append(out, presentCommit(c, sigverify.LookupView(verifications, c.OID)))
140195
 	}
141196
 	// We don't have a cheap O(1) total count from `git log` without a
142197
 	// second walk, so emit `next`/`prev` only when a follow-on page
@@ -174,8 +229,14 @@ func (h *Handlers) commitGet(w http.ResponseWriter, r *http.Request) {
174229
 		writeAPIError(w, http.StatusInternalServerError, "lookup failed")
175230
 		return
176231
 	}
232
+	view, vErr := sigverify.LoadView(r.Context(), h.d.Pool, repo.ID, cd.OID)
233
+	if vErr != nil && !errors.Is(vErr, pgx.ErrNoRows) {
234
+		h.d.Logger.WarnContext(r.Context(), "api: load verification", "error", vErr, "repo_id", repo.ID, "oid", cd.OID)
235
+		view = sigverify.UnsignedView()
236
+	}
237
+
177238
 	out := commitDetail{
178
-		commitListItem: presentCommit(cd.Commit),
239
+		commitListItem: presentCommit(cd.Commit, view),
179240
 		Committer: commitAuthor{
180241
 			Name:  cd.CommitterName,
181242
 			Email: cd.CommitterEmail,
internal/web/handlers/api/commits_test.gomodified
@@ -21,11 +21,20 @@ type apiCommitAuthor struct {
2121
 }
2222
 
2323
 type apiCommit struct {
24
-	SHA      string          `json:"sha"`
25
-	ShortSHA string          `json:"short_sha"`
26
-	Subject  string          `json:"subject"`
27
-	Body     string          `json:"body"`
28
-	Author   apiCommitAuthor `json:"author"`
24
+	SHA          string                `json:"sha"`
25
+	ShortSHA     string                `json:"short_sha"`
26
+	Subject      string                `json:"subject"`
27
+	Body         string                `json:"body"`
28
+	Author       apiCommitAuthor       `json:"author"`
29
+	Verification apiCommitVerification `json:"verification"`
30
+}
31
+
32
+type apiCommitVerification struct {
33
+	Verified   bool    `json:"verified"`
34
+	Reason     string  `json:"reason"`
35
+	Signature  *string `json:"signature"`
36
+	Payload    *string `json:"payload"`
37
+	VerifiedAt *string `json:"verified_at"`
2938
 }
3039
 
3140
 type apiCommitFile struct {
@@ -203,3 +212,58 @@ func TestCommits_RequiresReadScope(t *testing.T) {
203212
 		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
204213
 	}
205214
 }
215
+
216
+// TestCommits_VerificationDefaultsUnsigned ensures every commit's
217
+// JSON carries the verification object even when no cache row
218
+// exists. gh emits `{verified: false, reason: "unsigned", signature:
219
+// null, payload: null, verified_at: null}` for commits with no
220
+// signature; we match that exactly.
221
+func TestCommits_VerificationDefaultsUnsigned(t *testing.T) {
222
+	router, token, headSHA := commitsEnv(t)
223
+
224
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/commits", nil)
225
+	req.Header.Set("Authorization", "Bearer "+token)
226
+	rr := httptest.NewRecorder()
227
+	router.ServeHTTP(rr, req)
228
+	if rr.Code != http.StatusOK {
229
+		t.Fatalf("list: %d %s", rr.Code, rr.Body.String())
230
+	}
231
+	var listed []apiCommit
232
+	if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil {
233
+		t.Fatalf("decode: %v", err)
234
+	}
235
+	if len(listed) != 1 {
236
+		t.Fatalf("len: got %d, want 1", len(listed))
237
+	}
238
+	if listed[0].SHA != headSHA {
239
+		t.Errorf("sha: got %q, want %q", listed[0].SHA, headSHA)
240
+	}
241
+	if listed[0].Verification.Reason != "unsigned" {
242
+		t.Errorf("reason: got %q, want unsigned", listed[0].Verification.Reason)
243
+	}
244
+	if listed[0].Verification.Verified {
245
+		t.Error("expected Verified=false on an unsigned commit")
246
+	}
247
+	if listed[0].Verification.Signature != nil {
248
+		t.Error("expected Signature=null on an unsigned commit")
249
+	}
250
+	if listed[0].Verification.Payload != nil {
251
+		t.Error("expected Payload=null on an unsigned commit")
252
+	}
253
+
254
+	// Single-commit GET surfaces the same object shape.
255
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/commits/"+headSHA, nil)
256
+	req.Header.Set("Authorization", "Bearer "+token)
257
+	rr = httptest.NewRecorder()
258
+	router.ServeHTTP(rr, req)
259
+	if rr.Code != http.StatusOK {
260
+		t.Fatalf("get: %d %s", rr.Code, rr.Body.String())
261
+	}
262
+	var single apiCommit
263
+	if err := json.Unmarshal(rr.Body.Bytes(), &single); err != nil {
264
+		t.Fatalf("decode get: %v", err)
265
+	}
266
+	if single.Verification.Reason != "unsigned" {
267
+		t.Errorf("get reason: got %q, want unsigned", single.Verification.Reason)
268
+	}
269
+}