Go · 7463 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package protocol_test
4
5 import (
6 "context"
7 "errors"
8 "strings"
9 "testing"
10
11 "github.com/jackc/pgx/v5/pgtype"
12 "github.com/jackc/pgx/v5/pgxpool"
13
14 "github.com/tenseleyFlow/shithub/internal/auth/audit"
15 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
16 "github.com/tenseleyFlow/shithub/internal/git/protocol"
17 "github.com/tenseleyFlow/shithub/internal/infra/storage"
18 "github.com/tenseleyFlow/shithub/internal/repos"
19 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
20 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
21 )
22
23 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
24 "AAAAAAAAAAAAAAAA$" +
25 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
26
27 // dispatchEnv constructs deps + 2 users (alice owns repos, eve is a
28 // non-owner) + a public repo + a private repo against a fresh test DB.
29 type dispatchEnv struct {
30 pool *pgxpool.Pool
31 deps protocol.SSHDispatchDeps
32 alice int64
33 eve int64
34 root string
35 }
36
37 func setupDispatch(t *testing.T) *dispatchEnv {
38 t.Helper()
39 pool := dbtest.NewTestDB(t)
40 root := t.TempDir()
41 rfs, err := storage.NewRepoFS(root)
42 if err != nil {
43 t.Fatalf("NewRepoFS: %v", err)
44 }
45 uq := usersdb.New()
46
47 makeUser := func(name string) usersdb.User {
48 u, err := uq.CreateUser(context.Background(), pool, usersdb.CreateUserParams{
49 Username: name, DisplayName: name, PasswordHash: fixtureHash,
50 })
51 if err != nil {
52 t.Fatalf("CreateUser %s: %v", name, err)
53 }
54 em, err := uq.CreateUserEmail(context.Background(), pool, usersdb.CreateUserEmailParams{
55 UserID: u.ID, Email: name + "@example.com", IsPrimary: true, Verified: true,
56 })
57 if err != nil {
58 t.Fatalf("CreateUserEmail %s: %v", name, err)
59 }
60 if err := uq.LinkUserPrimaryEmail(context.Background(), pool, usersdb.LinkUserPrimaryEmailParams{
61 ID: u.ID, PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
62 }); err != nil {
63 t.Fatalf("LinkUserPrimaryEmail %s: %v", name, err)
64 }
65 return u
66 }
67 alice := makeUser("alice")
68 eve := makeUser("eve")
69
70 rdeps := repos.Deps{
71 Pool: pool, RepoFS: rfs, Audit: audit.NewRecorder(), Limiter: throttle.NewLimiter(),
72 }
73 if _, err := repos.Create(context.Background(), rdeps, repos.Params{
74 OwnerUserID: alice.ID, OwnerUsername: alice.Username,
75 Name: "public", Visibility: "public", InitReadme: true,
76 }); err != nil {
77 t.Fatalf("create public: %v", err)
78 }
79 if _, err := repos.Create(context.Background(), rdeps, repos.Params{
80 OwnerUserID: alice.ID, OwnerUsername: alice.Username,
81 Name: "private", Visibility: "private", InitReadme: true,
82 }); err != nil {
83 t.Fatalf("create private: %v", err)
84 }
85
86 return &dispatchEnv{
87 pool: pool, deps: protocol.SSHDispatchDeps{Pool: pool, RepoFS: rfs},
88 alice: alice.ID, eve: eve.ID, root: root,
89 }
90 }
91
92 func TestDispatch_PublicCloneByOwner(t *testing.T) {
93 t.Parallel()
94 env := setupDispatch(t)
95 res, parsed, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
96 OriginalCommand: "git-upload-pack 'alice/public'",
97 UserID: env.alice,
98 RemoteIP: "127.0.0.1",
99 })
100 if err != nil {
101 t.Fatalf("PrepareDispatch: %v", err)
102 }
103 if parsed.Service != protocol.UploadPack {
104 t.Errorf("Service = %q", parsed.Service)
105 }
106 if !strings.HasSuffix(res.Argv0Args[1], "/public.git") {
107 t.Errorf("Argv0Args[1] = %q", res.Argv0Args[1])
108 }
109 wantEnvSubstrings := []string{
110 "SHITHUB_USER_ID=", "SHITHUB_USERNAME=alice",
111 "SHITHUB_REPO_FULL_NAME=alice/public", "SHITHUB_PROTOCOL=ssh",
112 "SHITHUB_REMOTE_IP=127.0.0.1",
113 }
114 envBlob := strings.Join(res.Env, "\n")
115 for _, w := range wantEnvSubstrings {
116 if !strings.Contains(envBlob, w) {
117 t.Errorf("env missing %q in:\n%s", w, envBlob)
118 }
119 }
120 }
121
122 func TestDispatch_PublicCloneByOther(t *testing.T) {
123 t.Parallel()
124 env := setupDispatch(t)
125 _, _, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
126 OriginalCommand: "git-upload-pack 'alice/public'",
127 UserID: env.eve, // not owner
128 })
129 if err != nil {
130 t.Fatalf("non-owner pull of public: %v", err)
131 }
132 }
133
134 func TestDispatch_PrivateCloneByOtherIsNotFound(t *testing.T) {
135 t.Parallel()
136 env := setupDispatch(t)
137 _, _, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
138 OriginalCommand: "git-upload-pack 'alice/private'",
139 UserID: env.eve,
140 })
141 if !errors.Is(err, protocol.ErrSSHRepoNotFound) {
142 t.Fatalf("err = %v, want ErrSSHRepoNotFound", err)
143 }
144 }
145
146 func TestDispatch_PushByNonOwnerIsPermDenied(t *testing.T) {
147 t.Parallel()
148 env := setupDispatch(t)
149 _, _, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
150 OriginalCommand: "git-receive-pack 'alice/public'",
151 UserID: env.eve,
152 })
153 if !errors.Is(err, protocol.ErrSSHPermDenied) {
154 t.Fatalf("err = %v, want ErrSSHPermDenied", err)
155 }
156 }
157
158 func TestDispatch_PushToArchivedIsArchived(t *testing.T) {
159 t.Parallel()
160 env := setupDispatch(t)
161 if _, err := env.pool.Exec(context.Background(),
162 "UPDATE repos SET is_archived = true WHERE name = 'public'"); err != nil {
163 t.Fatalf("archive: %v", err)
164 }
165 _, _, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
166 OriginalCommand: "git-receive-pack 'alice/public'",
167 UserID: env.alice,
168 })
169 if !errors.Is(err, protocol.ErrSSHArchived) {
170 t.Fatalf("err = %v, want ErrSSHArchived", err)
171 }
172 }
173
174 func TestDispatch_SuspendedUserSuspended(t *testing.T) {
175 t.Parallel()
176 env := setupDispatch(t)
177 if _, err := env.pool.Exec(context.Background(),
178 "UPDATE users SET suspended_at = now(), suspended_reason = 'test' WHERE id = $1",
179 env.alice,
180 ); err != nil {
181 t.Fatalf("suspend: %v", err)
182 }
183 _, _, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
184 OriginalCommand: "git-upload-pack 'alice/public'",
185 UserID: env.alice,
186 })
187 if !errors.Is(err, protocol.ErrSSHSuspended) {
188 t.Fatalf("err = %v, want ErrSSHSuspended", err)
189 }
190 }
191
192 func TestDispatch_UnknownCommandIsRejected(t *testing.T) {
193 t.Parallel()
194 env := setupDispatch(t)
195 _, _, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
196 OriginalCommand: "ls -la /etc",
197 UserID: env.alice,
198 })
199 if !errors.Is(err, protocol.ErrUnknownSSHCommand) {
200 t.Fatalf("err = %v, want ErrUnknownSSHCommand", err)
201 }
202 }
203
204 func TestFriendlyMessageFor(t *testing.T) {
205 t.Parallel()
206 cases := []struct {
207 err error
208 want string
209 }{
210 {protocol.ErrSSHRepoNotFound, "shithub: repository not found"},
211 {protocol.ErrSSHPermDenied, "shithub: permission denied"},
212 {protocol.ErrSSHArchived, "shithub: this repository is archived; pushes are disabled"},
213 {protocol.ErrSSHSuspended, "shithub: your account is suspended"},
214 {protocol.ErrUnknownSSHCommand, "shithub does not allow shell access"},
215 {protocol.ErrInvalidSSHPath, "shithub: repository not found"},
216 }
217 for _, c := range cases {
218 if got := protocol.FriendlyMessageFor(c.err, "abc123"); got != c.want {
219 t.Errorf("FriendlyMessageFor(%v) = %q, want %q", c.err, got, c.want)
220 }
221 }
222 }
223
224 func TestParseRemoteIP(t *testing.T) {
225 t.Parallel()
226 cases := map[string]string{
227 "": "",
228 "203.0.113.7 12345 192.0.2.1 22": "203.0.113.7",
229 " 203.0.113.8 ": "203.0.113.8",
230 }
231 for in, want := range cases {
232 if got := protocol.ParseRemoteIP(in); got != want {
233 t.Errorf("ParseRemoteIP(%q) = %q, want %q", in, got, want)
234 }
235 }
236 }
237