tenseleyflow/shithub / b87db95

Browse files

S12: end-to-end git-HTTP integration tests against real git CLI

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b87db957d8414487ca01633d2cde2fed1b02a3e1
Parents
240e2f4
Tree
db48762

2 changed files

StatusFile+-
M internal/web/handlers/githttp/auth.go 1 2
A internal/web/handlers/githttp/githttp_test.go 291 0
internal/web/handlers/githttp/auth.gomodified
@@ -9,8 +9,8 @@ import (
9
 	"strings"
9
 	"strings"
10
 	"time"
10
 	"time"
11
 
11
 
12
-	"github.com/tenseleyFlow/shithub/internal/auth/pat"
13
 	"github.com/tenseleyFlow/shithub/internal/auth/password"
12
 	"github.com/tenseleyFlow/shithub/internal/auth/password"
13
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
14
 )
14
 )
15
 
15
 
16
 // resolvedAuth carries the resolved identity for a git-over-HTTPS
16
 // resolvedAuth carries the resolved identity for a git-over-HTTPS
@@ -127,4 +127,3 @@ func (h *Handlers) resolveViaPassword(ctx context.Context, username, secret stri
127
 		UserID: user.ID, Username: user.Username, ViaPAT: false,
127
 		UserID: user.ID, Username: user.Username, ViaPAT: false,
128
 	}, nil
128
 	}, nil
129
 }
129
 }
130
-
internal/web/handlers/githttp/githttp_test.goadded
@@ -0,0 +1,291 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package githttp_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"log/slog"
9
+	"net/http/httptest"
10
+	"net/url"
11
+	"os"
12
+	"os/exec"
13
+	"path/filepath"
14
+	"strings"
15
+	"testing"
16
+	"time"
17
+
18
+	"github.com/go-chi/chi/v5"
19
+	"github.com/jackc/pgx/v5/pgtype"
20
+	"github.com/jackc/pgx/v5/pgxpool"
21
+
22
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
23
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
24
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
25
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
26
+	"github.com/tenseleyFlow/shithub/internal/repos"
27
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
28
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
29
+	githttph "github.com/tenseleyFlow/shithub/internal/web/handlers/githttp"
30
+)
31
+
32
+// fixtureHash is a static PHC test fixture (zero salt, zero key) — not a credential.
33
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
34
+	"AAAAAAAAAAAAAAAA$" +
35
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
36
+
37
+// gitCmd wraps exec.Command — test paths are all t.TempDir.
38
+func gitCmd(args ...string) *exec.Cmd {
39
+	//nolint:gosec // G204: test fixture, t.TempDir paths.
40
+	return exec.Command("git", args...)
41
+}
42
+
43
+// env wires a verified user + a public repo (with init commit) + a
44
+// private repo, mounts the smart-HTTP handlers on a httptest server,
45
+// and returns everything callers need.
46
+type env struct {
47
+	srv      *httptest.Server
48
+	pool     *pgxpool.Pool
49
+	userID   int64
50
+	user     string
51
+	pwd      string
52
+	patRaw   string
53
+	pubRepo  string
54
+	privRepo string
55
+	root     string
56
+}
57
+
58
+func setupEnv(t *testing.T) *env {
59
+	t.Helper()
60
+	pool := dbtest.NewTestDB(t)
61
+	root := t.TempDir()
62
+	rfs, err := storage.NewRepoFS(root)
63
+	if err != nil {
64
+		t.Fatalf("NewRepoFS: %v", err)
65
+	}
66
+
67
+	uq := usersdb.New()
68
+	user, err := uq.CreateUser(context.Background(), pool, usersdb.CreateUserParams{
69
+		Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
70
+	})
71
+	if err != nil {
72
+		t.Fatalf("CreateUser: %v", err)
73
+	}
74
+	em, err := uq.CreateUserEmail(context.Background(), pool, usersdb.CreateUserEmailParams{
75
+		UserID: user.ID, Email: "alice@example.com", IsPrimary: true, Verified: true,
76
+	})
77
+	if err != nil {
78
+		t.Fatalf("CreateUserEmail: %v", err)
79
+	}
80
+	if err := uq.LinkUserPrimaryEmail(context.Background(), pool, usersdb.LinkUserPrimaryEmailParams{
81
+		ID: user.ID, PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
82
+	}); err != nil {
83
+		t.Fatalf("LinkUserPrimaryEmail: %v", err)
84
+	}
85
+
86
+	// Public repo with one initial commit (so info/refs returns refs).
87
+	rdeps := repos.Deps{
88
+		Pool: pool, RepoFS: rfs, Audit: audit.NewRecorder(), Limiter: throttle.NewLimiter(),
89
+		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
90
+	}
91
+	if _, err := repos.Create(context.Background(), rdeps, repos.Params{
92
+		OwnerUserID: user.ID, OwnerUsername: user.Username,
93
+		Name: "public-repo", Visibility: "public", InitReadme: true,
94
+	}); err != nil {
95
+		t.Fatalf("create public: %v", err)
96
+	}
97
+	if _, err := repos.Create(context.Background(), rdeps, repos.Params{
98
+		OwnerUserID: user.ID, OwnerUsername: user.Username,
99
+		Name: "private-repo", Visibility: "private", InitReadme: true,
100
+	}); err != nil {
101
+		t.Fatalf("create private: %v", err)
102
+	}
103
+
104
+	// Mint a PAT for alice.
105
+	raw, hash, prefix, err := pat.Mint()
106
+	if err != nil {
107
+		t.Fatalf("mint PAT: %v", err)
108
+	}
109
+	expires := pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true}
110
+	if _, err := uq.InsertUserToken(context.Background(), pool, usersdb.InsertUserTokenParams{
111
+		UserID:      user.ID,
112
+		Name:        "test",
113
+		TokenHash:   hash,
114
+		TokenPrefix: prefix,
115
+		ExpiresAt:   expires,
116
+		Scopes:      []string{"repo"},
117
+	}); err != nil {
118
+		t.Fatalf("create PAT row: %v", err)
119
+	}
120
+
121
+	h, err := githttph.New(githttph.Deps{
122
+		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
123
+		Pool:   pool, RepoFS: rfs,
124
+	})
125
+	if err != nil {
126
+		t.Fatalf("New: %v", err)
127
+	}
128
+	r := chi.NewRouter()
129
+	h.MountSmartHTTP(r)
130
+	srv := httptest.NewServer(r)
131
+	t.Cleanup(srv.Close)
132
+
133
+	return &env{
134
+		srv: srv, pool: pool, userID: user.ID,
135
+		user: "alice", pwd: "wrong-not-real-password", patRaw: raw,
136
+		pubRepo: "public-repo", privRepo: "private-repo", root: root,
137
+	}
138
+}
139
+
140
+// authedURL embeds Basic credentials in the URL. git's libcurl honors
141
+// the userinfo prefix for HTTP Basic.
142
+func authedURL(srvURL, user, secret, repoPath string) string {
143
+	u, _ := url.Parse(srvURL)
144
+	u.User = url.UserPassword(user, secret)
145
+	u.Path = repoPath
146
+	return u.String()
147
+}
148
+
149
+func TestGitHTTP_AnonClonePublic(t *testing.T) {
150
+	t.Parallel()
151
+	env := setupEnv(t)
152
+	dst := filepath.Join(t.TempDir(), "clone")
153
+	out, err := gitCmd("clone", env.srv.URL+"/alice/public-repo.git", dst).CombinedOutput()
154
+	if err != nil {
155
+		t.Fatalf("clone: %v\n%s", err, out)
156
+	}
157
+	// HEAD must resolve to the initial commit's tree.
158
+	out, err = gitCmd("-C", dst, "rev-list", "--count", "HEAD").CombinedOutput()
159
+	if err != nil {
160
+		t.Fatalf("rev-list: %v\n%s", err, out)
161
+	}
162
+	if got := strings.TrimSpace(string(out)); got != "1" {
163
+		t.Fatalf("rev-list = %q, want 1", got)
164
+	}
165
+}
166
+
167
+func TestGitHTTP_AnonClonePrivateFails(t *testing.T) {
168
+	t.Parallel()
169
+	env := setupEnv(t)
170
+	dst := filepath.Join(t.TempDir(), "clone")
171
+	cmd := gitCmd("clone", env.srv.URL+"/alice/private-repo.git", dst)
172
+	// Suppress git's interactive credential prompt during the test.
173
+	cmd.Env = append(cmd.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false")
174
+	out, err := cmd.CombinedOutput()
175
+	if err == nil {
176
+		t.Fatalf("expected clone to fail; output: %s", out)
177
+	}
178
+}
179
+
180
+func TestGitHTTP_PATClonePrivate(t *testing.T) {
181
+	t.Parallel()
182
+	env := setupEnv(t)
183
+	dst := filepath.Join(t.TempDir(), "clone")
184
+	cloneURL := authedURL(env.srv.URL, env.user, env.patRaw, "/alice/private-repo.git")
185
+	out, err := gitCmd("clone", cloneURL, dst).CombinedOutput()
186
+	if err != nil {
187
+		t.Fatalf("clone: %v\n%s", err, out)
188
+	}
189
+	out, err = gitCmd("-C", dst, "rev-list", "--count", "HEAD").CombinedOutput()
190
+	if err != nil {
191
+		t.Fatalf("rev-list: %v\n%s", err, out)
192
+	}
193
+	if got := strings.TrimSpace(string(out)); got != "1" {
194
+		t.Fatalf("rev-list = %q, want 1", got)
195
+	}
196
+}
197
+
198
+func TestGitHTTP_PATPushRoundtrip(t *testing.T) {
199
+	t.Parallel()
200
+	env := setupEnv(t)
201
+	dst := filepath.Join(t.TempDir(), "clone")
202
+	cloneURL := authedURL(env.srv.URL, env.user, env.patRaw, "/alice/private-repo.git")
203
+
204
+	// Clone.
205
+	if out, err := gitCmd("clone", cloneURL, dst).CombinedOutput(); err != nil {
206
+		t.Fatalf("clone: %v\n%s", err, out)
207
+	}
208
+
209
+	// Configure committer + make a new commit.
210
+	for _, c := range [][]string{
211
+		{"-C", dst, "config", "user.name", "Alice"},
212
+		{"-C", dst, "config", "user.email", "alice@example.com"},
213
+	} {
214
+		if out, err := gitCmd(c...).CombinedOutput(); err != nil {
215
+			t.Fatalf("config: %v\n%s", err, out)
216
+		}
217
+	}
218
+	newFile := filepath.Join(dst, "newfile.txt")
219
+	if err := writeFile(newFile, "hello\n"); err != nil {
220
+		t.Fatalf("write: %v", err)
221
+	}
222
+	for _, c := range [][]string{
223
+		{"-C", dst, "add", "newfile.txt"},
224
+		{"-C", dst, "commit", "-m", "add newfile"},
225
+	} {
226
+		if out, err := gitCmd(c...).CombinedOutput(); err != nil {
227
+			t.Fatalf("git %v: %v\n%s", c, err, out)
228
+		}
229
+	}
230
+
231
+	// Push.
232
+	out, err := gitCmd("-C", dst, "push", "origin", "trunk").CombinedOutput()
233
+	if err != nil {
234
+		t.Fatalf("push: %v\n%s", err, out)
235
+	}
236
+
237
+	// Re-clone to a fresh dir; the new commit must be visible.
238
+	dst2 := filepath.Join(t.TempDir(), "clone2")
239
+	if out, err := gitCmd("clone", cloneURL, dst2).CombinedOutput(); err != nil {
240
+		t.Fatalf("clone2: %v\n%s", err, out)
241
+	}
242
+	if out, err := gitCmd("-C", dst2, "log", "--oneline", "trunk").CombinedOutput(); err != nil {
243
+		t.Fatalf("log: %v\n%s", err, out)
244
+	} else if !strings.Contains(string(out), "add newfile") {
245
+		t.Fatalf("expected pushed commit in re-clone log; got: %s", out)
246
+	}
247
+}
248
+
249
+func TestGitHTTP_PushToArchivedRejected(t *testing.T) {
250
+	t.Parallel()
251
+	env := setupEnv(t)
252
+	// Archive the public repo.
253
+	if _, err := env.pool.Exec(context.Background(),
254
+		"UPDATE repos SET is_archived = true WHERE name = 'public-repo'"); err != nil {
255
+		t.Fatalf("archive: %v", err)
256
+	}
257
+
258
+	// Authed clone is fine (read still allowed).
259
+	dst := filepath.Join(t.TempDir(), "clone")
260
+	cloneURL := authedURL(env.srv.URL, env.user, env.patRaw, "/alice/public-repo.git")
261
+	if out, err := gitCmd("clone", cloneURL, dst).CombinedOutput(); err != nil {
262
+		t.Fatalf("clone: %v\n%s", err, out)
263
+	}
264
+
265
+	// Make a commit and try to push.
266
+	for _, c := range [][]string{
267
+		{"-C", dst, "config", "user.name", "Alice"},
268
+		{"-C", dst, "config", "user.email", "alice@example.com"},
269
+	} {
270
+		_, _ = gitCmd(c...).CombinedOutput()
271
+	}
272
+	_ = writeFile(filepath.Join(dst, "blocked.txt"), "x\n")
273
+	for _, c := range [][]string{
274
+		{"-C", dst, "add", "blocked.txt"},
275
+		{"-C", dst, "commit", "-m", "blocked"},
276
+	} {
277
+		_, _ = gitCmd(c...).CombinedOutput()
278
+	}
279
+
280
+	out, err := gitCmd("-C", dst, "push", "origin", "trunk").CombinedOutput()
281
+	if err == nil {
282
+		t.Fatalf("expected push to fail; output: %s", out)
283
+	}
284
+	if !strings.Contains(string(out), "archived") {
285
+		t.Fatalf("expected friendly archived-message in stderr, got: %s", out)
286
+	}
287
+}
288
+
289
+func writeFile(path, body string) error {
290
+	return os.WriteFile(path, []byte(body), 0o600)
291
+}