tenseleyflow/shithub / da0e8f5

Browse files

worker/jobs: gpg:backfill end-to-end tests

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
da0e8f5e84805cdce36dddc3ede0a9bc895f6db1
Parents
266ba41
Tree
4e2d378

1 changed file

StatusFile+-
A internal/worker/jobs/gpg_backfill_test.go 284 0
internal/worker/jobs/gpg_backfill_test.goadded
@@ -0,0 +1,284 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package jobs_test
4
+
5
+import (
6
+	"bytes"
7
+	"context"
8
+	"encoding/hex"
9
+	"encoding/json"
10
+	"fmt"
11
+	"io"
12
+	"log/slog"
13
+	"os"
14
+	"os/exec"
15
+	"path/filepath"
16
+	"strings"
17
+	"testing"
18
+	"time"
19
+
20
+	"github.com/ProtonMail/go-crypto/openpgp"
21
+	"github.com/ProtonMail/go-crypto/openpgp/armor"
22
+	"github.com/ProtonMail/go-crypto/openpgp/packet"
23
+	"github.com/jackc/pgx/v5/pgxpool"
24
+
25
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
26
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
27
+	"github.com/tenseleyFlow/shithub/internal/worker/jobs"
28
+)
29
+
30
+// TestGPGBackfill_HappyPath exercises the worker handler end-to-end:
31
+// seed a bare git repo with a signed commit, seed the matching key
32
+// rows in the DB, invoke the handler, and confirm
33
+// commit_verification_cache contains a `valid` row afterwards.
34
+//
35
+// This is an integration test (requires SHITHUB_TEST_DATABASE_URL).
36
+// It exercises the full sqlc + upsert path, not a mock.
37
+func TestGPGBackfill_HappyPath(t *testing.T) {
38
+	pool := dbtest.NewTestDB(t)
39
+	ctx := context.Background()
40
+
41
+	// 1. Seed user + user_emails.
42
+	var userID int64
43
+	err := pool.QueryRow(ctx,
44
+		`INSERT INTO users (username, password_hash, email_verified)
45
+		 VALUES ($1, 'x', true) RETURNING id`,
46
+		"alice",
47
+	).Scan(&userID)
48
+	if err != nil {
49
+		t.Fatalf("seed users: %v", err)
50
+	}
51
+	if _, err := pool.Exec(ctx,
52
+		`INSERT INTO user_emails (user_id, email, verified) VALUES ($1, $2, true)`,
53
+		userID, "alice@shithub.test",
54
+	); err != nil {
55
+		t.Fatalf("seed user_emails: %v", err)
56
+	}
57
+
58
+	// 2. Build a bare repo at the RepoFS-expected path.
59
+	rfsRoot := t.TempDir()
60
+	rfs, err := storage.NewRepoFS(rfsRoot)
61
+	if err != nil {
62
+		t.Fatalf("NewRepoFS: %v", err)
63
+	}
64
+	gitDir, err := rfs.RepoPath("alice", "demo")
65
+	if err != nil {
66
+		t.Fatalf("RepoPath: %v", err)
67
+	}
68
+	if err := os.MkdirAll(filepath.Dir(gitDir), 0o755); err != nil {
69
+		t.Fatalf("mkdir owner: %v", err)
70
+	}
71
+	if err := exec.Command("git", "init", "--quiet", "--bare", gitDir).Run(); err != nil {
72
+		t.Fatalf("git init: %v", err)
73
+	}
74
+	entity := newEd25519(t, "alice@shithub.test")
75
+	commitOID := writeSignedCommit(t, gitDir, entity, "signed commit")
76
+
77
+	// 3. Seed a repos row pointing at the bare repo on disk.
78
+	var repoID int64
79
+	err = pool.QueryRow(ctx,
80
+		`INSERT INTO repos (owner_user_id, name, visibility, default_branch)
81
+		 VALUES ($1, $2, 'public', $3) RETURNING id`,
82
+		userID, "demo", "trunk",
83
+	).Scan(&repoID)
84
+	if err != nil {
85
+		t.Fatalf("seed repos: %v", err)
86
+	}
87
+
88
+	// 4. Seed user_gpg_keys + user_gpg_subkeys rows so the orchestrator
89
+	//    can resolve the signature.
90
+	primaryFP := hex.EncodeToString(entity.PrimaryKey.Fingerprint)
91
+	primaryKID := fmt.Sprintf("%016x", entity.PrimaryKey.KeyId)
92
+	var gpgKeyID int64
93
+	err = pool.QueryRow(ctx,
94
+		`INSERT INTO user_gpg_keys (
95
+		    user_id, fingerprint, key_id, armored,
96
+		    can_sign, can_encrypt_comms, can_encrypt_storage, can_certify, can_authenticate,
97
+		    primary_algo
98
+		 ) VALUES ($1, $2, $3, $4, true, false, false, true, false, 'ed25519')
99
+		 RETURNING id`,
100
+		userID, primaryFP, primaryKID, armoredPublic(t, entity),
101
+	).Scan(&gpgKeyID)
102
+	if err != nil {
103
+		t.Fatalf("seed user_gpg_keys: %v", err)
104
+	}
105
+
106
+	// Register one subkey record per signing subkey; if no subkey
107
+	// is signing (e.g. all subkeys are encryption-only and the
108
+	// primary signs directly), register the primary itself.
109
+	registered := 0
110
+	for i := range entity.Subkeys {
111
+		sk := &entity.Subkeys[i]
112
+		if sk.Sig == nil || !sk.Sig.FlagSign {
113
+			continue
114
+		}
115
+		if _, err := pool.Exec(ctx,
116
+			`INSERT INTO user_gpg_subkeys (
117
+			    gpg_key_id, fingerprint, key_id,
118
+			    can_sign, can_encrypt_comms, can_encrypt_storage, can_certify
119
+			 ) VALUES ($1, $2, $3, true, false, false, false)`,
120
+			gpgKeyID,
121
+			hex.EncodeToString(sk.PublicKey.Fingerprint),
122
+			fmt.Sprintf("%016x", sk.PublicKey.KeyId),
123
+		); err != nil {
124
+			t.Fatalf("seed subkey: %v", err)
125
+		}
126
+		registered++
127
+	}
128
+	if registered == 0 {
129
+		if _, err := pool.Exec(ctx,
130
+			`INSERT INTO user_gpg_subkeys (
131
+			    gpg_key_id, fingerprint, key_id,
132
+			    can_sign, can_encrypt_comms, can_encrypt_storage, can_certify
133
+			 ) VALUES ($1, $2, $3, true, false, false, true)`,
134
+			gpgKeyID, primaryFP, primaryKID,
135
+		); err != nil {
136
+			t.Fatalf("seed primary as subkey: %v", err)
137
+		}
138
+	}
139
+
140
+	// 5. Invoke the handler.
141
+	handler := jobs.GPGBackfill(jobs.GPGBackfillDeps{
142
+		Pool:   pool,
143
+		RepoFS: rfs,
144
+		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
145
+	})
146
+	payload, _ := json.Marshal(jobs.GPGBackfillPayload{RepoID: repoID})
147
+	if err := handler(ctx, payload); err != nil {
148
+		t.Fatalf("handler: %v", err)
149
+	}
150
+
151
+	// 6. Confirm cache row exists with reason='valid'.
152
+	var reason string
153
+	var verified bool
154
+	err = pool.QueryRow(ctx,
155
+		`SELECT reason, verified FROM commit_verification_cache
156
+		 WHERE repo_id = $1 AND commit_oid = $2`,
157
+		repoID, commitOID,
158
+	).Scan(&reason, &verified)
159
+	if err != nil {
160
+		t.Fatalf("cache row missing: %v", err)
161
+	}
162
+	if reason != "valid" || !verified {
163
+		t.Errorf("cache row: reason=%q verified=%t; want reason=valid verified=true", reason, verified)
164
+	}
165
+}
166
+
167
+// TestGPGBackfill_BadPayload returns ErrPoison on malformed input so
168
+// the worker pool doesn't retry forever.
169
+func TestGPGBackfill_BadPayload(t *testing.T) {
170
+	handler := jobs.GPGBackfill(jobs.GPGBackfillDeps{
171
+		Pool:   nil, // not consulted on the poison path
172
+		RepoFS: nil,
173
+		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
174
+	})
175
+	err := handler(context.Background(), json.RawMessage(`{bogus`))
176
+	if err == nil {
177
+		t.Fatal("expected poison error on bad payload; got nil")
178
+	}
179
+	if !isPoisonError(err) {
180
+		t.Errorf("expected wrapped ErrPoison; got %v", err)
181
+	}
182
+}
183
+
184
+// TestGPGBackfill_MissingRepoID poisons rather than retries because
185
+// the empty repo_id can never resolve.
186
+func TestGPGBackfill_MissingRepoID(t *testing.T) {
187
+	handler := jobs.GPGBackfill(jobs.GPGBackfillDeps{
188
+		Pool:   nil,
189
+		RepoFS: nil,
190
+		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
191
+	})
192
+	err := handler(context.Background(), json.RawMessage(`{"repo_id": 0}`))
193
+	if err == nil {
194
+		t.Fatal("expected poison error on missing repo_id; got nil")
195
+	}
196
+	if !isPoisonError(err) {
197
+		t.Errorf("expected wrapped ErrPoison; got %v", err)
198
+	}
199
+}
200
+
201
+// ─── helpers ────────────────────────────────────────────────────────
202
+
203
+// isPoisonError unwraps and matches against worker.ErrPoison without
204
+// importing the worker package here — the test only cares that the
205
+// error chain reaches a poison sentinel.
206
+func isPoisonError(err error) bool {
207
+	return err != nil && strings.Contains(err.Error(), "poison")
208
+}
209
+
210
+func newEd25519(t *testing.T, email string) *openpgp.Entity {
211
+	t.Helper()
212
+	e, err := openpgp.NewEntity("backfill-test", "", email, &packet.Config{
213
+		Algorithm: packet.PubKeyAlgoEdDSA,
214
+	})
215
+	if err != nil {
216
+		t.Fatalf("NewEntity: %v", err)
217
+	}
218
+	return e
219
+}
220
+
221
+func armoredPublic(t *testing.T, e *openpgp.Entity) string {
222
+	t.Helper()
223
+	var buf bytes.Buffer
224
+	w, err := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil)
225
+	if err != nil {
226
+		t.Fatalf("armor.Encode: %v", err)
227
+	}
228
+	if err := e.Serialize(w); err != nil {
229
+		t.Fatalf("Serialize: %v", err)
230
+	}
231
+	w.Close()
232
+	return buf.String()
233
+}
234
+
235
+// writeSignedCommit builds a signed commit body, writes it via
236
+// `git hash-object`, and updates refs/heads/trunk so rev-list finds it.
237
+func writeSignedCommit(t *testing.T, gitDir string, entity *openpgp.Entity, message string) string {
238
+	t.Helper()
239
+	const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
240
+	now := time.Now().Unix()
241
+	unsignedBody := fmt.Sprintf(
242
+		"tree %s\nauthor Alice <alice@shithub.test> %d +0000\ncommitter Alice <alice@shithub.test> %d +0000\n\n%s\n",
243
+		emptyTree, now, now, message,
244
+	)
245
+
246
+	var sigBuf bytes.Buffer
247
+	armorWriter, err := armor.Encode(&sigBuf, "PGP SIGNATURE", nil)
248
+	if err != nil {
249
+		t.Fatalf("armor.Encode: %v", err)
250
+	}
251
+	if err := openpgp.DetachSign(armorWriter, entity, strings.NewReader(unsignedBody), nil); err != nil {
252
+		t.Fatalf("DetachSign: %v", err)
253
+	}
254
+	armorWriter.Close()
255
+
256
+	sigStr := strings.TrimRight(sigBuf.String(), "\n")
257
+	sigLines := strings.Split(sigStr, "\n")
258
+	indented := []string{"gpgsig " + sigLines[0]}
259
+	for _, line := range sigLines[1:] {
260
+		indented = append(indented, " "+line)
261
+	}
262
+	gpgsigHeader := strings.Join(indented, "\n")
263
+	signedBody := strings.Replace(
264
+		unsignedBody,
265
+		fmt.Sprintf("committer Alice <alice@shithub.test> %d +0000\n\n", now),
266
+		fmt.Sprintf("committer Alice <alice@shithub.test> %d +0000\n%s\n\n", now, gpgsigHeader),
267
+		1,
268
+	)
269
+
270
+	cmd := exec.Command("git", "-C", gitDir, "hash-object", "-w", "-t", "commit", "--stdin")
271
+	cmd.Stdin = bytes.NewReader([]byte(signedBody))
272
+	out, err := cmd.Output()
273
+	if err != nil {
274
+		t.Fatalf("hash-object: %v", err)
275
+	}
276
+	oid := strings.TrimSpace(string(out))
277
+
278
+	if err := exec.Command("git", "-C", gitDir, "update-ref", "refs/heads/trunk", oid).Run(); err != nil {
279
+		t.Fatalf("update-ref trunk: %v", err)
280
+	}
281
+	return oid
282
+}
283
+
284
+var _ = pgxpool.Pool{} // keep the import used; pool type appears in t-Helper signatures elsewhere