tenseleyflow/shithub / b331ee9

Browse files

tests: GPG HTML + REST handler tests with in-memory fixtures

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b331ee9b48bb5fe29a502ff4fbf2efa25bcf4d82
Parents
a327747
Tree
228d13f

3 changed files

StatusFile+-
A internal/web/handlers/api/gpg_keys_test.go 359 0
M internal/web/handlers/auth/auth_test.go 4 1
A internal/web/handlers/auth/gpgkeys_test.go 246 0
internal/web/handlers/api/gpg_keys_test.goadded
@@ -0,0 +1,359 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api_test
4
+
5
+import (
6
+	"bytes"
7
+	"context"
8
+	"encoding/json"
9
+	"net/http"
10
+	"net/http/httptest"
11
+	"strings"
12
+	"testing"
13
+
14
+	"github.com/ProtonMail/go-crypto/openpgp"
15
+	"github.com/ProtonMail/go-crypto/openpgp/armor"
16
+	"github.com/ProtonMail/go-crypto/openpgp/packet"
17
+	"github.com/jackc/pgx/v5/pgxpool"
18
+
19
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
20
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
21
+)
22
+
23
+// apiGPGKey mirrors the response shape produced by presentGPGKey.
24
+// We only declare the fields the tests actually assert against; gh's
25
+// can_authenticate is intentionally absent (not in the wire response).
26
+type apiGPGKey struct {
27
+	ID                int64          `json:"id"`
28
+	Name              string         `json:"name"`
29
+	PrimaryKeyID      *int64         `json:"primary_key_id"`
30
+	KeyID             string         `json:"key_id"`
31
+	PublicKey         string         `json:"public_key"`
32
+	RawKey            string         `json:"raw_key"`
33
+	Emails            []apiGPGEmail  `json:"emails"`
34
+	Subkeys           []apiGPGSubkey `json:"subkeys"`
35
+	CanSign           bool           `json:"can_sign"`
36
+	CanEncryptComms   bool           `json:"can_encrypt_comms"`
37
+	CanEncryptStorage bool           `json:"can_encrypt_storage"`
38
+	CanCertify        bool           `json:"can_certify"`
39
+	Revoked           bool           `json:"revoked"`
40
+}
41
+
42
+type apiGPGEmail struct {
43
+	Email    string `json:"email"`
44
+	Verified bool   `json:"verified"`
45
+}
46
+
47
+type apiGPGSubkey struct {
48
+	KeyID             string `json:"key_id"`
49
+	PrimaryKeyID      int64  `json:"primary_key_id"`
50
+	CanSign           bool   `json:"can_sign"`
51
+	CanEncryptComms   bool   `json:"can_encrypt_comms"`
52
+	CanEncryptStorage bool   `json:"can_encrypt_storage"`
53
+}
54
+
55
+// gpgArmoredPublic synthesizes an in-memory ed25519 entity and
56
+// returns its armored public-key block.
57
+func gpgArmoredPublic(t *testing.T, email string) string {
58
+	t.Helper()
59
+	e, err := openpgp.NewEntity("test", "", email, &packet.Config{
60
+		Algorithm: packet.PubKeyAlgoEdDSA,
61
+	})
62
+	if err != nil {
63
+		t.Fatalf("NewEntity: %v", err)
64
+	}
65
+	var buf bytes.Buffer
66
+	w, err := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil)
67
+	if err != nil {
68
+		t.Fatalf("armor.Encode: %v", err)
69
+	}
70
+	if err := e.Serialize(w); err != nil {
71
+		t.Fatalf("Serialize: %v", err)
72
+	}
73
+	_ = w.Close()
74
+	return buf.String()
75
+}
76
+
77
+// gpgEncryptOnlyPublic builds a rsa2048 entity with only encryption
78
+// capability flags on the primary's self-sig — the gh-parity
79
+// fixture asserting we accept encrypt-only keys.
80
+func gpgEncryptOnlyPublic(t *testing.T, email string) string {
81
+	t.Helper()
82
+	e, err := openpgp.NewEntity("eo", "", email, &packet.Config{
83
+		Algorithm: packet.PubKeyAlgoRSA, RSABits: 2048,
84
+	})
85
+	if err != nil {
86
+		t.Fatalf("NewEntity: %v", err)
87
+	}
88
+	for _, id := range e.Identities {
89
+		id.SelfSignature.FlagSign = false
90
+		id.SelfSignature.FlagCertify = false
91
+		id.SelfSignature.FlagEncryptCommunications = true
92
+		id.SelfSignature.FlagEncryptStorage = true
93
+		if err := id.SelfSignature.SignUserId(id.UserId.Id, e.PrimaryKey, e.PrivateKey, nil); err != nil {
94
+			t.Fatalf("re-sign: %v", err)
95
+		}
96
+	}
97
+	var buf bytes.Buffer
98
+	w, _ := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil)
99
+	_ = e.Serialize(w)
100
+	_ = w.Close()
101
+	return buf.String()
102
+}
103
+
104
+// seedUserEmail inserts a single email row for the user so the
105
+// orchestrator's verified-email cross-check has something to find.
106
+func seedUserEmail(t *testing.T, pool *pgxpool.Pool, userID int64, email string, verified bool) {
107
+	t.Helper()
108
+	if _, err := pool.Exec(context.Background(),
109
+		`INSERT INTO user_emails (user_id, email, verified) VALUES ($1, $2, $3)`,
110
+		userID, email, verified,
111
+	); err != nil {
112
+		t.Fatalf("seed user_email: %v", err)
113
+	}
114
+}
115
+
116
+// crossCuttingNamedUser is the named-arg variant of crossCuttingUser
117
+// — useful when a test needs two distinct users (alice + bob) on the
118
+// same pool to exercise cross-user isolation.
119
+func crossCuttingNamedUser(t *testing.T, pool *pgxpool.Pool, username string) int64 {
120
+	t.Helper()
121
+	var id int64
122
+	err := pool.QueryRow(context.Background(),
123
+		`INSERT INTO users (username, password_hash, email_verified)
124
+		 VALUES ($1, 'x', true) RETURNING id`,
125
+		username,
126
+	).Scan(&id)
127
+	if err != nil {
128
+		t.Fatalf("create user %s: %v", username, err)
129
+	}
130
+	return id
131
+}
132
+
133
+func TestGPGKeys_CreateListGetDelete(t *testing.T) {
134
+	pool := dbtest.NewTestDB(t)
135
+	router := newCrossCuttingAPIRouter(t, pool)
136
+	userID := crossCuttingUser(t, pool)
137
+	seedUserEmail(t, pool, userID, "alice@shithub.test", true)
138
+	tokenWrite := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserWrite))
139
+	tokenRead := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
140
+
141
+	armored := gpgArmoredPublic(t, "alice@shithub.test")
142
+	body, _ := json.Marshal(map[string]string{
143
+		"name":               "laptop",
144
+		"armored_public_key": armored,
145
+	})
146
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/gpg_keys", bytes.NewReader(body))
147
+	req.Header.Set("Authorization", "Bearer "+tokenWrite)
148
+	req.Header.Set("Content-Type", "application/json")
149
+	rr := httptest.NewRecorder()
150
+	router.ServeHTTP(rr, req)
151
+	if rr.Code != http.StatusCreated {
152
+		t.Fatalf("create: %d %s", rr.Code, rr.Body.String())
153
+	}
154
+	var created apiGPGKey
155
+	if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
156
+		t.Fatalf("decode create: %v", err)
157
+	}
158
+	if created.Name != "laptop" {
159
+		t.Errorf("Name: got %q, want laptop", created.Name)
160
+	}
161
+	if created.PublicKey == "" || created.RawKey == "" {
162
+		t.Error("PublicKey + RawKey should both be populated")
163
+	}
164
+	if created.PublicKey != created.RawKey {
165
+		t.Error("PublicKey and RawKey should carry the same armored block")
166
+	}
167
+	if !created.CanSign && !created.CanCertify {
168
+		t.Error("expected sign or certify on synthesized ed25519 key")
169
+	}
170
+	if len(created.Emails) == 0 || created.Emails[0].Email != "alice@shithub.test" {
171
+		t.Errorf("expected alice@shithub.test in emails; got %+v", created.Emails)
172
+	}
173
+	if !created.Emails[0].Verified {
174
+		t.Error("expected emails[0].verified=true (cross-checked against user_emails)")
175
+	}
176
+
177
+	// List with the user:read PAT.
178
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/user/gpg_keys", nil)
179
+	req.Header.Set("Authorization", "Bearer "+tokenRead)
180
+	rr = httptest.NewRecorder()
181
+	router.ServeHTTP(rr, req)
182
+	if rr.Code != http.StatusOK {
183
+		t.Fatalf("list: %d %s", rr.Code, rr.Body.String())
184
+	}
185
+	var listed []apiGPGKey
186
+	if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil {
187
+		t.Fatalf("decode list: %v", err)
188
+	}
189
+	if len(listed) != 1 || listed[0].ID != created.ID {
190
+		t.Errorf("list: got %+v, want [the new key]", listed)
191
+	}
192
+
193
+	// Single GET with user:read.
194
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/user/gpg_keys/"+itoa(created.ID), nil)
195
+	req.Header.Set("Authorization", "Bearer "+tokenRead)
196
+	rr = httptest.NewRecorder()
197
+	router.ServeHTTP(rr, req)
198
+	if rr.Code != http.StatusOK {
199
+		t.Fatalf("get: %d %s", rr.Code, rr.Body.String())
200
+	}
201
+
202
+	// Delete with user:write → 204.
203
+	req = httptest.NewRequest(http.MethodDelete, "/api/v1/user/gpg_keys/"+itoa(created.ID), nil)
204
+	req.Header.Set("Authorization", "Bearer "+tokenWrite)
205
+	rr = httptest.NewRecorder()
206
+	router.ServeHTTP(rr, req)
207
+	if rr.Code != http.StatusNoContent {
208
+		t.Fatalf("delete: %d %s", rr.Code, rr.Body.String())
209
+	}
210
+
211
+	// Subsequent GET → 404 (revoked rows aren't visible on the
212
+	// user-scoped GET).
213
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/user/gpg_keys/"+itoa(created.ID), nil)
214
+	req.Header.Set("Authorization", "Bearer "+tokenRead)
215
+	rr = httptest.NewRecorder()
216
+	router.ServeHTTP(rr, req)
217
+	if rr.Code != http.StatusNotFound {
218
+		t.Errorf("post-delete GET: %d, want 404", rr.Code)
219
+	}
220
+}
221
+
222
+func TestGPGKeys_CreateRequiresUserWriteScope(t *testing.T) {
223
+	pool := dbtest.NewTestDB(t)
224
+	router := newCrossCuttingAPIRouter(t, pool)
225
+	userID := crossCuttingUser(t, pool)
226
+	tokenRead := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
227
+
228
+	body, _ := json.Marshal(map[string]string{
229
+		"armored_public_key": gpgArmoredPublic(t, "alice@shithub.test"),
230
+	})
231
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/gpg_keys", bytes.NewReader(body))
232
+	req.Header.Set("Authorization", "Bearer "+tokenRead)
233
+	req.Header.Set("Content-Type", "application/json")
234
+	rr := httptest.NewRecorder()
235
+	router.ServeHTTP(rr, req)
236
+	if rr.Code != http.StatusForbidden {
237
+		t.Fatalf("create with read-only PAT: %d, want 403", rr.Code)
238
+	}
239
+}
240
+
241
+func TestGPGKeys_CreateAcceptsEncryptOnly(t *testing.T) {
242
+	pool := dbtest.NewTestDB(t)
243
+	router := newCrossCuttingAPIRouter(t, pool)
244
+	userID := crossCuttingUser(t, pool)
245
+	tokenWrite := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserWrite))
246
+
247
+	body, _ := json.Marshal(map[string]string{
248
+		"armored_public_key": gpgEncryptOnlyPublic(t, "encryptonly@shithub.test"),
249
+	})
250
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/gpg_keys", bytes.NewReader(body))
251
+	req.Header.Set("Authorization", "Bearer "+tokenWrite)
252
+	req.Header.Set("Content-Type", "application/json")
253
+	rr := httptest.NewRecorder()
254
+	router.ServeHTTP(rr, req)
255
+	if rr.Code != http.StatusCreated {
256
+		t.Fatalf("encrypt-only create: %d %s; gh parity says accept", rr.Code, rr.Body.String())
257
+	}
258
+	var created apiGPGKey
259
+	_ = json.Unmarshal(rr.Body.Bytes(), &created)
260
+	if created.CanSign {
261
+		t.Error("expected can_sign=false on encryption-only key")
262
+	}
263
+	if !created.CanEncryptComms && !created.CanEncryptStorage {
264
+		t.Error("expected at least one encrypt-* true on encryption-only key")
265
+	}
266
+}
267
+
268
+func TestGPGKeys_CreateRejectsPrivateKeyBlock(t *testing.T) {
269
+	pool := dbtest.NewTestDB(t)
270
+	router := newCrossCuttingAPIRouter(t, pool)
271
+	userID := crossCuttingUser(t, pool)
272
+	tokenWrite := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserWrite))
273
+
274
+	// Build a private-key armored block via SerializePrivate.
275
+	e, _ := openpgp.NewEntity("priv", "", "priv@shithub.test", &packet.Config{
276
+		Algorithm: packet.PubKeyAlgoEdDSA,
277
+	})
278
+	var buf bytes.Buffer
279
+	w, _ := armor.Encode(&buf, "PGP PRIVATE KEY BLOCK", nil)
280
+	_ = e.SerializePrivate(w, nil)
281
+	_ = w.Close()
282
+
283
+	body, _ := json.Marshal(map[string]string{"armored_public_key": buf.String()})
284
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/gpg_keys", bytes.NewReader(body))
285
+	req.Header.Set("Authorization", "Bearer "+tokenWrite)
286
+	req.Header.Set("Content-Type", "application/json")
287
+	rr := httptest.NewRecorder()
288
+	router.ServeHTTP(rr, req)
289
+	if rr.Code != http.StatusUnprocessableEntity {
290
+		t.Fatalf("private key block: %d %s, want 422", rr.Code, rr.Body.String())
291
+	}
292
+	if !strings.Contains(rr.Body.String(), "private key") {
293
+		t.Errorf("error body missing 'private key' phrase: %s", rr.Body.String())
294
+	}
295
+}
296
+
297
+func TestGPGKeys_CrossUserGetReturns404(t *testing.T) {
298
+	pool := dbtest.NewTestDB(t)
299
+	router := newCrossCuttingAPIRouter(t, pool)
300
+	aliceID := crossCuttingUser(t, pool)
301
+	seedUserEmail(t, pool, aliceID, "alice@shithub.test", true)
302
+	aliceWrite := mintRunnerAPIPAT(t, pool, aliceID, string(pat.ScopeUserWrite))
303
+
304
+	// Alice adds a key.
305
+	body, _ := json.Marshal(map[string]string{
306
+		"armored_public_key": gpgArmoredPublic(t, "alice@shithub.test"),
307
+	})
308
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/gpg_keys", bytes.NewReader(body))
309
+	req.Header.Set("Authorization", "Bearer "+aliceWrite)
310
+	req.Header.Set("Content-Type", "application/json")
311
+	rr := httptest.NewRecorder()
312
+	router.ServeHTTP(rr, req)
313
+	if rr.Code != http.StatusCreated {
314
+		t.Fatalf("alice add: %d %s", rr.Code, rr.Body.String())
315
+	}
316
+	var alicesKey apiGPGKey
317
+	_ = json.Unmarshal(rr.Body.Bytes(), &alicesKey)
318
+
319
+	// Bob tries to GET alice's key by id.
320
+	bobID := crossCuttingNamedUser(t, pool, "bob")
321
+	bobRead := mintRunnerAPIPAT(t, pool, bobID, string(pat.ScopeUserRead))
322
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/user/gpg_keys/"+itoa(alicesKey.ID), nil)
323
+	req.Header.Set("Authorization", "Bearer "+bobRead)
324
+	rr = httptest.NewRecorder()
325
+	router.ServeHTTP(rr, req)
326
+	if rr.Code != http.StatusNotFound {
327
+		t.Errorf("cross-user GET: %d, want 404 (existence-leak-safe)", rr.Code)
328
+	}
329
+}
330
+
331
+func TestGPGKeys_DuplicateFingerprintReturns422(t *testing.T) {
332
+	pool := dbtest.NewTestDB(t)
333
+	router := newCrossCuttingAPIRouter(t, pool)
334
+	userID := crossCuttingUser(t, pool)
335
+	tokenWrite := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserWrite))
336
+
337
+	armored := gpgArmoredPublic(t, "alice@shithub.test")
338
+	body, _ := json.Marshal(map[string]string{"armored_public_key": armored})
339
+
340
+	// First add succeeds.
341
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/gpg_keys", bytes.NewReader(body))
342
+	req.Header.Set("Authorization", "Bearer "+tokenWrite)
343
+	req.Header.Set("Content-Type", "application/json")
344
+	rr := httptest.NewRecorder()
345
+	router.ServeHTTP(rr, req)
346
+	if rr.Code != http.StatusCreated {
347
+		t.Fatalf("first add: %d", rr.Code)
348
+	}
349
+
350
+	// Second add of the same fingerprint → 422.
351
+	req = httptest.NewRequest(http.MethodPost, "/api/v1/user/gpg_keys", bytes.NewReader(body))
352
+	req.Header.Set("Authorization", "Bearer "+tokenWrite)
353
+	req.Header.Set("Content-Type", "application/json")
354
+	rr = httptest.NewRecorder()
355
+	router.ServeHTTP(rr, req)
356
+	if rr.Code != http.StatusUnprocessableEntity {
357
+		t.Fatalf("duplicate add: %d %s, want 422", rr.Code, rr.Body.String())
358
+	}
359
+}
internal/web/handlers/auth/auth_test.gomodified
@@ -199,7 +199,9 @@ func authTemplatesFS() fs.FS {
199199
 	tfaEnable := `{{ define "page" }}<form>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">SECRET={{.Secret}}</form>{{ end }}`
200200
 	tfaDisable := `{{ define "page" }}<form>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}"></form>{{ end }}`
201201
 	tfaRecovery := `{{ define "page" }}<form>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">{{ if .RecoveryCodes }}CODES={{ range .RecoveryCodes }}{{.}};{{ end }}{{ end }}</form>{{ end }}`
202
-	keysTpl := `{{ define "page" }}<form>{{ with .AddError }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">KEYS={{ range .Keys }}{{.ID}}:{{.FingerprintSha256}};{{ end }}</form>{{ end }}`
202
+	keysTpl := `{{ define "page" }}<form>{{ with .AddError }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">KEYS={{ range .Keys }}{{.ID}}:{{.FingerprintSha256}};{{ end }}GPGKEYS={{ range .GPGKeys }}{{.ID}}:{{.Name}}:{{.KeyID}};{{ end }}</form>{{ end }}`
203
+	//nolint:gosec // G101 false positive: test fixture, not a hardcoded credential.
204
+	gpgAddTpl := `{{ define "page" }}<form action="/settings/keys/gpg" method=POST>{{ with .AddError }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}"><input name=title value="{{.AddTitle}}"><textarea name=armored_key>{{.AddBlob}}</textarea></form>{{ end }}`
203205
 	//nolint:gosec // G101 false positive: test fixture, not a hardcoded credential.
204206
 	tokensTpl := `{{ define "page" }}<form>{{ with .CreateError }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">{{ if .JustCreatedRaw }}RAW={{.JustCreatedRaw}}{{ end }}TOKENS={{ range .Tokens }}{{.ID}}:{{.TokenPrefix}}{{ if .RevokedAt.Valid }}:revoked{{ end }};{{ end }}</form>{{ end }}`
205207
 	profileTpl := `{{ define "page" }}<h1>Public profile</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form><input name=csrf_token value="{{.CSRFToken}}">DISPLAY={{.Form.DisplayName}};BIO={{.Form.Bio}};LOCATION={{.Form.Location}};WEBSITE={{.Form.Website}};COMPANY={{.Form.Company}};PRONOUNS={{.Form.Pronouns}};</form>{{ if .HasAvatar }}<form action="/settings/profile/avatar/remove" method=POST><input name=csrf_token value="{{.CSRFToken}}"><button>Remove</button></form>{{ end }}{{ end }}`
@@ -226,6 +228,7 @@ func authTemplatesFS() fs.FS {
226228
 		"settings/2fa_disable.html":   {Data: []byte(tfaDisable)},
227229
 		"settings/2fa_recovery.html":  {Data: []byte(tfaRecovery)},
228230
 		"settings/keys.html":          {Data: []byte(keysTpl)},
231
+		"settings/keys_gpg_add.html":  {Data: []byte(gpgAddTpl)},
229232
 		"settings/tokens.html":        {Data: []byte(tokensTpl)},
230233
 		"settings/profile.html":       {Data: []byte(profileTpl)},
231234
 		"settings/account.html":       {Data: []byte(accountTpl)},
internal/web/handlers/auth/gpgkeys_test.goadded
@@ -0,0 +1,246 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth_test
4
+
5
+import (
6
+	"bytes"
7
+	"io"
8
+	"net/http"
9
+	"net/url"
10
+	"regexp"
11
+	"strings"
12
+	"testing"
13
+
14
+	"github.com/ProtonMail/go-crypto/openpgp"
15
+	"github.com/ProtonMail/go-crypto/openpgp/armor"
16
+	"github.com/ProtonMail/go-crypto/openpgp/packet"
17
+)
18
+
19
+// enrollGPGKeyHelper signs a fresh user in and returns the test
20
+// client. Mirrors enrollSSHKeyHelper from sshkeys_test.go.
21
+func enrollGPGKeyHelper(t *testing.T) *client {
22
+	t.Helper()
23
+	httpsrv, captor := newTestServer(t, false)
24
+	cli := newClient(t, httpsrv)
25
+
26
+	mustSignup(t, cli, "alicegpg", "alicegpg@example.com", "correct horse battery staple")
27
+	tok := extractTokenFromMessage(t, captor.all()[0], "/verify-email")
28
+	_ = cli.get(t, "/verify-email/"+tok).Body.Close()
29
+
30
+	csrf := cli.extractCSRF(t, "/login")
31
+	resp := cli.post(t, "/login", url.Values{
32
+		"csrf_token": {csrf},
33
+		"username":   {"alicegpg"},
34
+		"password":   {"correct horse battery staple"},
35
+	})
36
+	if resp.StatusCode != http.StatusSeeOther {
37
+		t.Fatalf("login: %d", resp.StatusCode)
38
+	}
39
+	_ = resp.Body.Close()
40
+	return cli
41
+}
42
+
43
+// armoredPublicKey builds a fresh in-memory ed25519 entity and
44
+// returns its armored public-key block.
45
+func armoredPublicKey(t *testing.T, email string) string {
46
+	t.Helper()
47
+	e, err := openpgp.NewEntity("test", "", email, &packet.Config{
48
+		Algorithm: packet.PubKeyAlgoEdDSA,
49
+	})
50
+	if err != nil {
51
+		t.Fatalf("NewEntity: %v", err)
52
+	}
53
+	var buf bytes.Buffer
54
+	w, err := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil)
55
+	if err != nil {
56
+		t.Fatalf("armor.Encode: %v", err)
57
+	}
58
+	if err := e.Serialize(w); err != nil {
59
+		t.Fatalf("Serialize: %v", err)
60
+	}
61
+	_ = w.Close()
62
+	return buf.String()
63
+}
64
+
65
+// armoredPrivateKey returns an armored PRIVATE key block (a
66
+// rejection-fixture). The body of the export has the secret-key
67
+// packets that the parser refuses.
68
+func armoredPrivateKey(t *testing.T, email string) string {
69
+	t.Helper()
70
+	e, err := openpgp.NewEntity("test", "", email, &packet.Config{
71
+		Algorithm: packet.PubKeyAlgoEdDSA,
72
+	})
73
+	if err != nil {
74
+		t.Fatalf("NewEntity: %v", err)
75
+	}
76
+	var buf bytes.Buffer
77
+	w, err := armor.Encode(&buf, "PGP PRIVATE KEY BLOCK", nil)
78
+	if err != nil {
79
+		t.Fatalf("armor.Encode: %v", err)
80
+	}
81
+	if err := e.SerializePrivate(w, nil); err != nil {
82
+		t.Fatalf("SerializePrivate: %v", err)
83
+	}
84
+	_ = w.Close()
85
+	return buf.String()
86
+}
87
+
88
+// gpgKeysRE extracts the GPGKEYS= marker entries from the stub
89
+// templates FS (see authTemplatesFS in auth_test.go). Format per
90
+// entry: <id>:<name>:<key_id>;
91
+var gpgKeysRE = regexp.MustCompile(`GPGKEYS=([^<]*)`)
92
+
93
+func TestGPGKey_AddListDelete(t *testing.T) {
94
+	t.Parallel()
95
+	cli := enrollGPGKeyHelper(t)
96
+	pub := armoredPublicKey(t, "alicegpg@example.com")
97
+
98
+	// Add via the add-form POST.
99
+	csrf := cli.extractCSRF(t, "/settings/keys/gpg/new")
100
+	resp := cli.post(t, "/settings/keys/gpg", url.Values{
101
+		"csrf_token":  {csrf},
102
+		"title":       {"laptop"},
103
+		"armored_key": {pub},
104
+	})
105
+	if resp.StatusCode != http.StatusSeeOther {
106
+		body, _ := io.ReadAll(resp.Body)
107
+		t.Fatalf("add: %d %s", resp.StatusCode, body)
108
+	}
109
+	_ = resp.Body.Close()
110
+
111
+	// List page should now carry the GPG key in GPGKEYS=.
112
+	resp = cli.get(t, "/settings/keys")
113
+	body, _ := io.ReadAll(resp.Body)
114
+	_ = resp.Body.Close()
115
+	m := gpgKeysRE.FindStringSubmatch(string(body))
116
+	if m == nil {
117
+		t.Fatalf("no GPGKEYS marker in body: %s", body)
118
+	}
119
+	if !strings.Contains(m[1], ":laptop:") {
120
+		t.Fatalf("title 'laptop' not in GPGKEYS entries: %q", m[1])
121
+	}
122
+	entry := strings.SplitN(m[1], ";", 2)[0]
123
+	parts := strings.SplitN(entry, ":", 3)
124
+	if len(parts) < 3 {
125
+		t.Fatalf("malformed GPGKEYS entry: %q", entry)
126
+	}
127
+	id := parts[0]
128
+
129
+	// Delete.
130
+	csrf = extractCSRFFromBody(t, body)
131
+	resp = cli.post(t, "/settings/keys/gpg/"+id+"/delete", url.Values{
132
+		"csrf_token": {csrf},
133
+	})
134
+	if resp.StatusCode != http.StatusSeeOther {
135
+		body2, _ := io.ReadAll(resp.Body)
136
+		t.Fatalf("delete: %d %s", resp.StatusCode, body2)
137
+	}
138
+	_ = resp.Body.Close()
139
+
140
+	// List should no longer carry the entry.
141
+	resp = cli.get(t, "/settings/keys")
142
+	body, _ = io.ReadAll(resp.Body)
143
+	_ = resp.Body.Close()
144
+	m = gpgKeysRE.FindStringSubmatch(string(body))
145
+	if m != nil && strings.Contains(m[1], ":laptop:") {
146
+		t.Errorf("deleted key still present: %q", m[1])
147
+	}
148
+}
149
+
150
+func TestGPGKey_RejectPrivateKeyBlock(t *testing.T) {
151
+	t.Parallel()
152
+	cli := enrollGPGKeyHelper(t)
153
+	priv := armoredPrivateKey(t, "alicegpg@example.com")
154
+
155
+	csrf := cli.extractCSRF(t, "/settings/keys/gpg/new")
156
+	resp := cli.post(t, "/settings/keys/gpg", url.Values{
157
+		"csrf_token":  {csrf},
158
+		"armored_key": {priv},
159
+	})
160
+	body, _ := io.ReadAll(resp.Body)
161
+	_ = resp.Body.Close()
162
+
163
+	// Server returns the add form with a flash error baked in.
164
+	if resp.StatusCode != http.StatusOK {
165
+		t.Fatalf("expected re-render of add form (200) with error flash; got %d %s", resp.StatusCode, body)
166
+	}
167
+	if !bytes.Contains(body, []byte("private key")) {
168
+		t.Errorf("error flash missing 'private key' text; body=%s", body)
169
+	}
170
+}
171
+
172
+func TestGPGKey_AcceptsEncryptionOnly(t *testing.T) {
173
+	t.Parallel()
174
+	cli := enrollGPGKeyHelper(t)
175
+
176
+	// Build an entity, then strip the primary's signing capability
177
+	// flag so it's encryption-only. gh parity: accept and surface
178
+	// can_sign=false; the parser was changed in S51 to stop rejecting
179
+	// this shape.
180
+	e, err := openpgp.NewEntity("eo", "", "encryptonly@example.com", &packet.Config{
181
+		Algorithm: packet.PubKeyAlgoRSA, RSABits: 2048,
182
+	})
183
+	if err != nil {
184
+		t.Fatalf("NewEntity: %v", err)
185
+	}
186
+	for _, id := range e.Identities {
187
+		id.SelfSignature.FlagSign = false
188
+		id.SelfSignature.FlagCertify = false
189
+		id.SelfSignature.FlagEncryptCommunications = true
190
+		id.SelfSignature.FlagEncryptStorage = true
191
+		if err := id.SelfSignature.SignUserId(id.UserId.Id, e.PrimaryKey, e.PrivateKey, nil); err != nil {
192
+			t.Fatalf("re-sign: %v", err)
193
+		}
194
+	}
195
+	var buf bytes.Buffer
196
+	w, _ := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil)
197
+	_ = e.Serialize(w)
198
+	_ = w.Close()
199
+
200
+	csrf := cli.extractCSRF(t, "/settings/keys/gpg/new")
201
+	resp := cli.post(t, "/settings/keys/gpg", url.Values{
202
+		"csrf_token":  {csrf},
203
+		"armored_key": {buf.String()},
204
+	})
205
+	if resp.StatusCode != http.StatusSeeOther {
206
+		body, _ := io.ReadAll(resp.Body)
207
+		t.Fatalf("encrypt-only should be accepted (gh parity); got %d %s", resp.StatusCode, body)
208
+	}
209
+	_ = resp.Body.Close()
210
+}
211
+
212
+func TestGPGKey_DuplicateRejected(t *testing.T) {
213
+	t.Parallel()
214
+	cli := enrollGPGKeyHelper(t)
215
+	pub := armoredPublicKey(t, "alicegpg@example.com")
216
+
217
+	// First add succeeds.
218
+	csrf := cli.extractCSRF(t, "/settings/keys/gpg/new")
219
+	resp := cli.post(t, "/settings/keys/gpg", url.Values{
220
+		"csrf_token":  {csrf},
221
+		"title":       {"first"},
222
+		"armored_key": {pub},
223
+	})
224
+	if resp.StatusCode != http.StatusSeeOther {
225
+		body, _ := io.ReadAll(resp.Body)
226
+		t.Fatalf("first add: %d %s", resp.StatusCode, body)
227
+	}
228
+	_ = resp.Body.Close()
229
+
230
+	// Second add of the same fingerprint should surface the
231
+	// friendly duplicate error.
232
+	csrf = cli.extractCSRF(t, "/settings/keys/gpg/new")
233
+	resp = cli.post(t, "/settings/keys/gpg", url.Values{
234
+		"csrf_token":  {csrf},
235
+		"title":       {"second"},
236
+		"armored_key": {pub},
237
+	})
238
+	body, _ := io.ReadAll(resp.Body)
239
+	_ = resp.Body.Close()
240
+	if resp.StatusCode != http.StatusOK {
241
+		t.Fatalf("expected re-render of add form; got %d %s", resp.StatusCode, body)
242
+	}
243
+	if !bytes.Contains(body, []byte("already registered")) && !strings.Contains(string(body), "already registered") {
244
+		t.Errorf("missing duplicate-key flash; body=%s", body)
245
+	}
246
+}