Go · 9021 bytes Raw Blame History
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
285