diff --git a/README.md b/README.md index e176b68..0a8188b 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ Run checks with: gofmt -w . go test ./... go vet ./... +go test -cover ./... +``` + +Optional integration tests: + +```bash +go test -tags=integration ./... +GITOOCEAN_TEST_MYSQL_DSN='user:pass@tcp(127.0.0.1:3306)/gitocean_test?parseTime=true' go test -tags=integration ./... ``` ## Code layout diff --git a/go.mod b/go.mod index 0f5aab5..c1dc42b 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,7 @@ require ( golang.org/x/crypto v0.31.0 ) -require filippo.io/edwards25519 v1.1.0 // indirect +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect +) diff --git a/go.sum b/go.sum index b5738ed..a14db02 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,9 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= diff --git a/internal/app/app_more_test.go b/internal/app/app_more_test.go new file mode 100644 index 0000000..9a9e674 --- /dev/null +++ b/internal/app/app_more_test.go @@ -0,0 +1,382 @@ +package app + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "golang.org/x/crypto/bcrypt" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + fn() + _ = w.Close() + os.Stdout = old + out, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + return string(out) +} + +func newMockServer(t *testing.T) (*Server, sqlmock.Sqlmock, func()) { + t.Helper() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + return &Server{db: db, storage: t.TempDir(), publicURL: "http://example.test"}, mock, func() { _ = db.Close() } +} + +func expectBearerUser(mock sqlmock.Sqlmock, token string, user User) { + mock.ExpectQuery("FROM auth_tokens t JOIN users u"). + WithArgs(hashToken(token)). + WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin"}).AddRow(user.ID, user.Email, user.Username, user.IsAdmin)) +} + +func TestRunDispatchUsageAndHelp(t *testing.T) { + out := captureStdout(t, func() { + if err := Run(nil); !errors.Is(err, ErrUsage) { + t.Fatalf("Run(nil) error = %v", err) + } + }) + if !strings.Contains(out, "gitocean - CLI") { + t.Fatalf("usage output missing heading: %s", out) + } + out = captureStdout(t, func() { + if err := Run([]string{"help"}); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, "repo search QUERY") { + t.Fatalf("help output missing commands: %s", out) + } + if err := Run([]string{"does-not-exist"}); err == nil || !strings.Contains(err.Error(), "unknown command") { + t.Fatalf("unexpected unknown command error: %v", err) + } +} + +func TestAPIRequest(t *testing.T) { + var seenAuth, seenContentType string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenAuth = r.Header.Get("Authorization") + seenContentType = r.Header.Get("Content-Type") + if r.URL.Path == "/error" { + writeError(w, http.StatusBadRequest, "bad request") + return + } + if r.URL.Path == "/unauthorized" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + var in map[string]string + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + t.Fatal(err) + } + writeJSON(w, http.StatusOK, map[string]string{"echo": in["name"]}) + })) + defer ts.Close() + + var out map[string]string + if err := apiRequest(context.Background(), ts.URL+"/", http.MethodPost, "/ok", "tok", map[string]string{"name": "demo"}, &out); err != nil { + t.Fatal(err) + } + if out["echo"] != "demo" || seenAuth != "Bearer tok" || seenContentType != "application/json" { + t.Fatalf("unexpected request/result: out=%#v auth=%q ct=%q", out, seenAuth, seenContentType) + } + if err := apiRequest(context.Background(), ts.URL, http.MethodGet, "/error", "", nil, nil); err == nil || err.Error() != "bad request" { + t.Fatalf("unexpected error response: %v", err) + } + if err := apiRequest(context.Background(), ts.URL, http.MethodGet, "/unauthorized", "", nil, nil); err == nil || !strings.Contains(err.Error(), "gitocean login") { + t.Fatalf("expected login hint for unauthorized, got %v", err) + } +} + +func TestAPITextRequest(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" { + t.Fatalf("missing auth header: %q", r.Header.Get("Authorization")) + } + if r.URL.Path == "/fail" { + http.Error(w, "nope", http.StatusTeapot) + return + } + _, _ = w.Write([]byte("hello")) + })) + defer ts.Close() + got, err := apiTextRequest(context.Background(), ts.URL+"/", "/ok", "tok") + if err != nil || got != "hello" { + t.Fatalf("got %q err %v", got, err) + } + if _, err := apiTextRequest(context.Background(), ts.URL, "/fail", "tok"); err == nil || !strings.Contains(err.Error(), "HTTP 418") { + t.Fatalf("expected HTTP error, got %v", err) + } +} + +func TestOutputFormatting(t *testing.T) { + repo := Repository{Owner: "alice", Name: "demo", Visibility: "public", Description: "desc", DefaultBranch: "main", Archived: true} + out := captureStdout(t, func() { printRepoCreated("http://server/", repo) }) + if !strings.Contains(out, "Created public repository alice/demo") || !strings.Contains(out, "git clone http://server/alice/demo.git") { + t.Fatalf("unexpected created output: %s", out) + } + out = captureStdout(t, func() { printRepoView("http://server", repo) }) + for _, want := range []string{"alice/demo", "Visibility: public", "Archived: true", "Description: desc"} { + if !strings.Contains(out, want) { + t.Fatalf("repo view missing %q: %s", want, out) + } + } + out = captureStdout(t, func() { printRepoTable([]Repository{repo}) }) + if !strings.Contains(out, "REPOSITORY") || !strings.Contains(out, "alice/demo") { + t.Fatalf("unexpected table: %s", out) + } + out = captureStdout(t, func() { + printPR(PullRequest{Number: 3, Status: "open", Title: "Fix", Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "bob", TargetRepo: "demo", TargetBranch: "main", Description: "body"}) + }) + if !strings.Contains(out, "#3 open: Fix") || !strings.Contains(out, "From: alice/demo:feature") || !strings.Contains(out, "body") { + t.Fatalf("unexpected PR output: %s", out) + } +} + +func TestAuthPureHelpers(t *testing.T) { + if hashToken("tok") == hashToken("other") || len(hashToken("tok")) != 64 { + t.Fatal("hashToken should produce distinct sha256 hex strings") + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer abc ") + if got := bearerToken(req); got != "abc" { + t.Fatalf("bearerToken = %q", got) + } + req.Header.Set("Authorization", "Basic nope") + if got := bearerToken(req); got != "" { + t.Fatalf("bearerToken should ignore non-bearer header, got %q", got) + } +} + +func TestCreateToken(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + mock.ExpectExec("INSERT INTO auth_tokens").WithArgs(int64(7), sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) + token, expires, err := s.createToken(7) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(token, "go_") { + t.Fatalf("token prefix = %q", token) + } + if time.Until(expires) < tokenTTL-time.Minute || time.Until(expires) > tokenTTL+time.Minute { + t.Fatalf("unexpected expiry: %s", expires) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestUserFromTokenAndBasic(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + user := User{ID: 1, Email: "alice@example.com", Username: "alice", IsAdmin: true} + expectBearerUser(mock, "tok", user) + got, err := s.userFromToken("tok", "") + if err != nil || got != user { + t.Fatalf("got %#v err %v", got, err) + } + mock.ExpectQuery("FROM auth_tokens t JOIN users u").WithArgs(hashToken("basic-token"), "alice").WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin"}).AddRow(user.ID, user.Email, user.Username, user.IsAdmin)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.SetBasicAuth("Alice", "basic-token") + got, ok := s.userFromBasic(req) + if !ok || got != user { + t.Fatalf("basic auth got %#v ok=%v", got, ok) + } + if _, err := s.userFromToken("", ""); err == nil { + t.Fatal("expected missing token to fail") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestHandleRegisterSuccessAndValidation(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + mock.ExpectQuery("SELECT COUNT").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + mock.ExpectExec("INSERT INTO users").WithArgs("alice@example.com", "alice", sqlmock.AnyArg(), true).WillReturnResult(sqlmock.NewResult(42, 1)) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/register", strings.NewReader(`{"email":"Alice@Example.com","username":"Alice","password":"password123"}`)) + s.handleRegister(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String()) + } + var user User + if err := json.Unmarshal(rr.Body.Bytes(), &user); err != nil { + t.Fatal(err) + } + if user.ID != 42 || user.Email != "alice@example.com" || user.Username != "alice" || !user.IsAdmin { + t.Fatalf("unexpected user: %#v", user) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + body string + want string + }{ + {"bad email", `{"email":"bad","username":"bob","password":"password123"}`, "invalid email"}, + {"reserved username", `{"email":"bob@example.com","username":"api","password":"password123"}`, "invalid or reserved username"}, + {"short password", `{"email":"bob@example.com","username":"bob","password":"short"}`, "password"}, + {"unknown field", `{"email":"bob@example.com","username":"bob","password":"password123","extra":true}`, "invalid json"}, + } { + t.Run(tc.name, func(t *testing.T) { + rr := httptest.NewRecorder() + s.handleRegister(rr, httptest.NewRequest(http.MethodPost, "/api/register", strings.NewReader(tc.body))) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), tc.want) { + t.Fatalf("body %q missing %q", rr.Body.String(), tc.want) + } + }) + } +} + +func TestHandleLoginSuccessAndFailure(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + hash, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + mock.ExpectQuery("SELECT id, email, username, is_admin, password_hash FROM users").WithArgs("alice", "alice").WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin", "password_hash"}).AddRow(1, "alice@example.com", "alice", false, string(hash))) + mock.ExpectExec("INSERT INTO auth_tokens").WithArgs(int64(1), sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(9, 1)) + rr := httptest.NewRecorder() + s.handleLogin(rr, httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"login":"Alice","password":"password123"}`))) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String()) + } + var out struct { + User User `json:"user"` + Token string `json:"token"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out.User.Username != "alice" || !strings.HasPrefix(out.Token, "go_") { + t.Fatalf("unexpected login out: %#v", out) + } + + mock.ExpectQuery("SELECT id, email, username, is_admin, password_hash FROM users").WithArgs("alice", "alice").WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin", "password_hash"}).AddRow(1, "alice@example.com", "alice", false, string(hash))) + rr = httptest.NewRecorder() + s.handleLogin(rr, httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"login":"alice","password":"wrong"}`))) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestRoutingErrors(t *testing.T) { + s := &Server{} + for _, tc := range []struct { + method string + path string + code int + }{ + {http.MethodGet, "/nope", http.StatusNotFound}, + {http.MethodGet, "/api/nope", http.StatusNotFound}, + {http.MethodPost, "/api/repos/alice/demo", http.StatusMethodNotAllowed}, + {http.MethodGet, "/api/repos/alice/demo/pulls/not-a-number", http.StatusBadRequest}, + } { + rr := httptest.NewRecorder() + s.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil)) + if rr.Code != tc.code { + t.Fatalf("%s %s status=%d body=%s", tc.method, tc.path, rr.Code, rr.Body.String()) + } + } +} + +func TestRepoPathsAndPermissionHelpers(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + if got := s.repoPath("Alice", "Demo"); !strings.HasSuffix(got, "repos/alice/demo.git") { + t.Fatalf("repoPath = %q", got) + } + owner := User{ID: 1, Username: "alice"} + reader := User{ID: 2, Username: "reader"} + repo := Repository{ID: 10, OwnerUserID: owner.ID, Visibility: "public"} + if !s.canReadRepo(repo, User{}, false) { + t.Fatal("public repos should be readable anonymously") + } + repo.Visibility = "private" + if s.canReadRepo(repo, User{}, false) { + t.Fatal("private repo should not be readable anonymously") + } + if !s.canReadRepo(repo, owner, true) || !s.canWriteRepo(repo, owner) { + t.Fatal("owner should read/write private repo") + } + mock.ExpectQuery("SELECT role FROM repository_collaborators").WithArgs(repo.ID, reader.ID).WillReturnRows(sqlmock.NewRows([]string{"role"}).AddRow("read")) + if !s.canReadRepo(repo, reader, true) { + t.Fatal("read collaborator should read private repo") + } + mock.ExpectQuery("SELECT role FROM repository_collaborators").WithArgs(repo.ID, reader.ID).WillReturnRows(sqlmock.NewRows([]string{"role"}).AddRow("write")) + if !s.canWriteRepo(repo, reader) { + t.Fatal("write collaborator should write private repo") + } + mock.ExpectQuery("SELECT role FROM repository_collaborators").WithArgs(repo.ID, int64(99)).WillReturnError(sql.ErrNoRows) + if role := s.collaboratorRole(repo.ID, 99); role != "" { + t.Fatalf("unexpected missing collaborator role: %q", role) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestHandleRepoCreateSuccessAndErrors(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + user := User{ID: 1, Email: "alice@example.com", Username: "alice"} + token := "tok" + expectBearerUser(mock, token, user) + mock.ExpectExec("INSERT INTO repositories").WithArgs(user.ID, "demo", "public", "desc").WillReturnResult(sqlmock.NewResult(20, 1)) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/repos", strings.NewReader(`{"name":"Demo","visibility":"public","description":"desc"}`)) + req.Header.Set("Authorization", "Bearer "+token) + s.handleRepoCreate(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String()) + } + var repo Repository + if err := json.Unmarshal(rr.Body.Bytes(), &repo); err != nil { + t.Fatal(err) + } + if repo.ID != 20 || repo.Name != "demo" || repo.DefaultBranch != "main" { + t.Fatalf("unexpected repo: %#v", repo) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + + rr = httptest.NewRecorder() + s.handleRepoCreate(rr, httptest.NewRequest(http.MethodPost, "/api/repos", bytes.NewReader(nil))) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized without token, got %d", rr.Code) + } +} diff --git a/internal/app/auth_more_test.go b/internal/app/auth_more_test.go new file mode 100644 index 0000000..91822be --- /dev/null +++ b/internal/app/auth_more_test.go @@ -0,0 +1,58 @@ +package app + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestHandleLogoutAndMe(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + user := User{ID: 1, Email: "alice@example.com", Username: "alice", IsAdmin: true} + token := "tok" + + rr := httptest.NewRecorder() + s.handleLogout(rr, httptest.NewRequest(http.MethodPost, "/api/logout", nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("logout without token status=%d body=%s", rr.Code, rr.Body.String()) + } + + mock.ExpectExec("UPDATE auth_tokens SET revoked_at").WithArgs(hashToken(token)).WillReturnResult(sqlmock.NewResult(0, 1)) + rr = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/logout", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleLogout(rr, req) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "ok") { + t.Fatalf("logout status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, user) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleMe(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("me status=%d body=%s", rr.Code, rr.Body.String()) + } + var got User + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got != user { + t.Fatalf("me got %#v", got) + } + + rr = httptest.NewRecorder() + s.handleMe(rr, httptest.NewRequest(http.MethodGet, "/api/me", nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("me without token status=%d", rr.Code) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/cli_actions_test.go b/internal/app/cli_actions_test.go new file mode 100644 index 0000000..7415d72 --- /dev/null +++ b/internal/app/cli_actions_test.go @@ -0,0 +1,186 @@ +package app + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCLIRepoMutatingHTTPCommands(t *testing.T) { + t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "client.json")) + seen := map[string]int{} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Method + " " + r.URL.Path + seen[key]++ + if r.Header.Get("Authorization") != "Bearer tok" { + t.Fatalf("missing auth for %s: %q", key, r.Header.Get("Authorization")) + } + switch key { + case "POST /api/repos": + writeJSON(w, http.StatusCreated, Repository{Owner: "alice", Name: "demo", Visibility: "public", Description: "desc", DefaultBranch: "main"}) + case "PATCH /api/repos/alice/demo": + writeJSON(w, http.StatusOK, Repository{Owner: "alice", Name: "demo", Visibility: "private", Description: "new", DefaultBranch: "main", Archived: seen[key] > 1}) + case "DELETE /api/repos/alice/demo": + if r.URL.Query().Get("force") != "true" { + t.Fatalf("expected force=true, got %s", r.URL.RawQuery) + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) + case "POST /api/repos/bob/demo/fork": + writeJSON(w, http.StatusCreated, Repository{Owner: "alice", Name: "fork", Visibility: "public", DefaultBranch: "main"}) + case "GET /api/repos/alice/demo/collaborators": + writeJSON(w, http.StatusOK, []Collaborator{{Username: "bob", Role: "write"}}) + case "POST /api/repos/alice/demo/collaborators": + writeJSON(w, http.StatusOK, map[string]string{"username": "bob", "role": "read"}) + case "DELETE /api/repos/alice/demo/collaborators/bob": + writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) + default: + t.Fatalf("unexpected request %s", key) + } + })) + defer ts.Close() + if err := saveConfig(Config{Server: ts.URL, Token: "tok", Username: "alice"}); err != nil { + t.Fatal(err) + } + + checks := []struct { + name string + fn func() error + want string + }{ + {"create", func() error { return cliRepoCreate([]string{"demo", "--public", "--description", "desc"}) }, "Created public repository"}, + {"create json", func() error { return cliRepoCreate([]string{"demo", "--public", "--json"}) }, `"owner": "alice"`}, + {"set", func() error { + return cliRepoSet([]string{"alice/demo", "--description", "new", "--visibility", "private"}) + }, "Visibility: private"}, + {"archive", func() error { return cliRepoArchive([]string{"alice/demo"}, true) }, "Archived: true"}, + {"delete", func() error { return cliRepoDelete([]string{"alice/demo", "--force"}) }, "Deleted alice/demo"}, + {"fork", func() error { return cliRepoFork([]string{"bob/demo", "--name", "fork"}) }, "Forked bob/demo to alice/fork"}, + {"collaborators", func() error { return cliRepoCollaborators([]string{"alice/demo"}) }, "bob"}, + {"collaborator add", func() error { return cliRepoCollaborator([]string{"add", "alice/demo", "bob", "--role", "read"}) }, "Added bob as read"}, + {"collaborator remove", func() error { return cliRepoCollaborator([]string{"remove", "alice/demo", "bob"}) }, ""}, + } + for _, tc := range checks { + t.Run(tc.name, func(t *testing.T) { + out := captureStdout(t, func() { + if err := tc.fn(); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, tc.want) { + t.Fatalf("output missing %q: %s", tc.want, out) + } + }) + } +} + +func TestCLIPRMutatingHTTPCommands(t *testing.T) { + t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "client.json")) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Method + " " + r.URL.Path + switch key { + case "POST /api/repos/alice/demo/pulls": + writeJSON(w, http.StatusCreated, PullRequest{Number: 1, Status: "open", Title: "Fix", Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main"}) + case "GET /api/repos/alice/demo/pulls/1/diff": + w.Header().Set("Content-Type", "text/plain") + _, _ = fmt.Fprint(w, "diff --git a/file b/file\n") + case "POST /api/repos/alice/demo/pulls/1/comments": + writeJSON(w, http.StatusCreated, PRComment{Author: "alice", Body: "hello"}) + case "POST /api/repos/alice/demo/pulls/1/close", "POST /api/repos/alice/demo/pulls/1/merge": + writeJSON(w, http.StatusOK, PullRequest{Number: 1, Status: strings.TrimPrefix(r.URL.Path, "/api/repos/alice/demo/pulls/1/"), Title: "Fix", Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main"}) + default: + t.Fatalf("unexpected request %s", key) + } + })) + defer ts.Close() + if err := saveConfig(Config{Server: ts.URL, Token: "tok", Username: "alice"}); err != nil { + t.Fatal(err) + } + checks := []struct { + name string + fn func() error + want string + }{ + {"create same", func() error { + return cliPRCreate([]string{"--repo", "alice/demo", "--from", "feature", "--to", "main", "--title", "Fix"}) + }, "#1 open: Fix"}, + {"create cross", func() error { + return cliPRCreate([]string{"--from", "alice/demo:feature", "--to", "alice/demo:main", "--title", "Fix"}) + }, "#1 open: Fix"}, + {"diff", func() error { return cliPRDiff([]string{"alice/demo", "1"}) }, "diff --git"}, + {"comment", func() error { return cliPRComment([]string{"alice/demo", "1", "hello"}) }, "Commented as alice"}, + {"close", func() error { return cliPRAction([]string{"alice/demo", "1"}, "close") }, "#1 close: Fix"}, + {"merge", func() error { return cliPRAction([]string{"alice/demo", "1"}, "merge") }, "#1 merge: Fix"}, + } + for _, tc := range checks { + t.Run(tc.name, func(t *testing.T) { + out := captureStdout(t, func() { + if err := tc.fn(); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, tc.want) { + t.Fatalf("output missing %q: %s", tc.want, out) + } + }) + } +} + +func TestCLITokenAdminAndBackupCommands(t *testing.T) { + t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "client.json")) + backupCfgPath := filepath.Join(t.TempDir(), "server.json") + t.Setenv("GITOOCEAN_SERVER_CONFIG", backupCfgPath) + storage := t.TempDir() + if err := os.MkdirAll(filepath.Join(storage, "repos", "alice", "demo.git"), 0755); err != nil { + t.Fatal(err) + } + if err := saveServerConfig(backupCfgPath, ServerConfig{Storage: storage, MySQLDSN: "u:p@tcp(127.0.0.1:3306)/gitocean"}); err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /api/tokens": + writeJSON(w, http.StatusOK, []TokenInfo{{ID: 1, Active: true}}) + case "DELETE /api/tokens/1", "POST /api/tokens/prune", "DELETE /api/admin/users/bob", "POST /api/admin/tokens/prune": + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + case "GET /api/admin/users": + writeJSON(w, http.StatusOK, []User{{Username: "alice", Email: "alice@example.com", IsAdmin: true}}) + case "GET /api/admin/repos": + writeJSON(w, http.StatusOK, []Repository{{Owner: "alice", Name: "demo", Visibility: "public"}}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer ts.Close() + if err := saveConfig(Config{Server: ts.URL, Token: "tok", Username: "alice"}); err != nil { + t.Fatal(err) + } + checks := []struct { + name string + fn func() error + want string + }{ + {"token list", func() error { return cliToken([]string{"list"}) }, "active"}, + {"token revoke", func() error { return cliToken([]string{"revoke", "1"}) }, ""}, + {"token prune", func() error { return cliToken([]string{"prune"}) }, ""}, + {"admin users", func() error { return cliAdmin([]string{"users", "list"}) }, "alice@example.com"}, + {"admin users delete", func() error { return cliAdmin([]string{"users", "delete", "bob"}) }, ""}, + {"admin repos", func() error { return cliAdmin([]string{"repos", "list"}) }, "alice/demo"}, + {"admin storage", func() error { return cliAdmin([]string{"storage", "check"}) }, "demo.git"}, + } + for _, tc := range checks { + t.Run(tc.name, func(t *testing.T) { + out := captureStdout(t, func() { + if err := tc.fn(); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, tc.want) { + t.Fatalf("output missing %q: %s", tc.want, out) + } + }) + } +} diff --git a/internal/app/cli_auth_test.go b/internal/app/cli_auth_test.go new file mode 100644 index 0000000..b8b35d9 --- /dev/null +++ b/internal/app/cli_auth_test.go @@ -0,0 +1,107 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func withStdin(t *testing.T, input string, fn func()) { + t.Helper() + old := os.Stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + if _, err := w.WriteString(input); err != nil { + t.Fatal(err) + } + _ = w.Close() + os.Stdin = r + defer func() { os.Stdin = old }() + fn() +} + +func TestCLIAuthWorkflowCommands(t *testing.T) { + configFile := filepath.Join(t.TempDir(), "client.json") + t.Setenv("GITOOCEAN_CONFIG", configFile) + var logoutAuth string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "POST /api/register": + writeJSON(w, http.StatusCreated, User{ID: 1, Email: "alice@example.com", Username: "alice", IsAdmin: true}) + case "POST /api/login": + writeJSON(w, http.StatusOK, map[string]any{"user": User{ID: 1, Email: "alice@example.com", Username: "alice", IsAdmin: true}, "token": "tok", "expires_at": time.Now().UTC().Add(tokenTTL)}) + case "GET /api/me": + if r.Header.Get("Authorization") != "Bearer tok" { + t.Fatalf("missing whoami auth: %q", r.Header.Get("Authorization")) + } + writeJSON(w, http.StatusOK, User{ID: 1, Email: "alice@example.com", Username: "alice", IsAdmin: true}) + case "POST /api/logout": + logoutAuth = r.Header.Get("Authorization") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + default: + t.Fatalf("unexpected auth request %s %s", r.Method, r.URL.Path) + } + })) + defer ts.Close() + + out := captureStdout(t, func() { + withStdin(t, "alice@example.com\nalice\npassword123\n", func() { + if err := cliRegister([]string{"--server", ts.URL}); err != nil { + t.Fatal(err) + } + }) + }) + if !strings.Contains(out, "Registered alice") || !strings.Contains(out, "admin account") { + t.Fatalf("register output: %s", out) + } + + out = captureStdout(t, func() { + withStdin(t, "alice\npassword123\n", func() { + if err := cliLogin([]string{"--server", ts.URL}); err != nil { + t.Fatal(err) + } + }) + }) + if !strings.Contains(out, "logged in as alice") { + t.Fatalf("login output: %s", out) + } + cfg, err := loadConfig() + if err != nil { + t.Fatal(err) + } + if cfg.Server != ts.URL || cfg.Token != "tok" || cfg.Username != "alice" { + t.Fatalf("unexpected saved config: %#v", cfg) + } + + out = captureStdout(t, func() { + if err := cliWhoami(nil); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, "Username: alice") || !strings.Contains(out, "Admin: true") { + t.Fatalf("whoami output: %s", out) + } + out = captureStdout(t, func() { + if err := cliLogout(nil); err != nil { + t.Fatal(err) + } + }) + if logoutAuth != "Bearer tok" || !strings.Contains(out, "logged out") { + t.Fatalf("logout auth=%q output=%s", logoutAuth, out) + } + if _, err := os.Stat(configFile); !os.IsNotExist(err) { + t.Fatalf("expected config file removed, stat err=%v", err) + } +} + +func TestCLICloneUsage(t *testing.T) { + if err := cliClone(nil); err == nil || !strings.Contains(err.Error(), "usage") { + t.Fatalf("expected clone usage, got %v", err) + } +} diff --git a/internal/app/cli_http_test.go b/internal/app/cli_http_test.go new file mode 100644 index 0000000..08016d9 --- /dev/null +++ b/internal/app/cli_http_test.go @@ -0,0 +1,127 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestCLIRepoHTTPCommandsOutput(t *testing.T) { + t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "client.json")) + var auth string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + switch r.URL.Path { + case "/api/repos/alice/demo": + writeJSON(w, http.StatusOK, Repository{Owner: "alice", Name: "demo", Visibility: "public", DefaultBranch: "main", Description: "desc"}) + case "/api/repos/search": + if r.URL.Query().Get("q") != "demo" || r.URL.Query().Get("scope") != "mine" { + t.Fatalf("unexpected search query: %s", r.URL.RawQuery) + } + writeJSON(w, http.StatusOK, []Repository{{Owner: "alice", Name: "demo", Visibility: "public", Description: "desc"}}) + case "/api/repos/alice/demo/branches": + writeJSON(w, http.StatusOK, []RefInfo{{Name: "main", Commit: "1234567890abcdef", Date: time.Date(2026, 1, 2, 3, 4, 0, 0, time.UTC), Message: "initial"}}) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer ts.Close() + if err := saveConfig(Config{Server: ts.URL, Token: "tok", Username: "alice"}); err != nil { + t.Fatal(err) + } + + out := captureStdout(t, func() { + if err := cliRepoView([]string{"alice/demo"}); err != nil { + t.Fatal(err) + } + }) + if auth != "Bearer tok" || !strings.Contains(out, "alice/demo") || strings.Contains(strings.TrimSpace(out), "{") { + t.Fatalf("unexpected repo view output/auth: auth=%q out=%s", auth, out) + } + + out = captureStdout(t, func() { + if err := cliRepoView([]string{"alice/demo", "--json"}); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, `"owner": "alice"`) { + t.Fatalf("expected JSON output, got %s", out) + } + + out = captureStdout(t, func() { + if err := cliRepoSearch([]string{"demo", "--mine"}); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, "REPOSITORY") || !strings.Contains(out, "alice/demo") { + t.Fatalf("unexpected search output: %s", out) + } + + out = captureStdout(t, func() { + if err := cliRepoRefs([]string{"alice/demo"}, "branches"); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, "MAIN") && !strings.Contains(out, "main") { + t.Fatalf("unexpected refs output: %s", out) + } +} + +func TestCLIPRAndTokenHTTPCommandsOutput(t *testing.T) { + t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "client.json")) + created := time.Date(2026, 1, 2, 3, 4, 0, 0, time.UTC) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/repos/alice/demo/pulls": + writeJSON(w, http.StatusOK, []PullRequest{{Number: 2, Status: "open", Title: "Fix", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main"}}) + case "/api/repos/alice/demo/pulls/2": + writeJSON(w, http.StatusOK, PullRequest{Number: 2, Status: "open", Title: "Fix", Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main"}) + case "/api/repos/alice/demo/pulls/2/comments": + writeJSON(w, http.StatusOK, []PRComment{{ID: 1, Author: "alice", Body: "looks good", CreatedAt: created, UpdatedAt: created}}) + case "/api/tokens": + writeJSON(w, http.StatusOK, []TokenInfo{{ID: 1, Active: true, CreatedAt: created, ExpiresAt: created.Add(tokenTTL)}}) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer ts.Close() + if err := saveConfig(Config{Server: ts.URL, Token: "tok", Username: "alice"}); err != nil { + t.Fatal(err) + } + + out := captureStdout(t, func() { + if err := cliPRList([]string{"alice/demo"}); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, "#2") || strings.Contains(strings.TrimSpace(out), "[") { + t.Fatalf("unexpected PR list output: %s", out) + } + out = captureStdout(t, func() { + if err := cliPRView([]string{"alice/demo", "2", "--json"}); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, `"number": 2`) { + t.Fatalf("unexpected PR view JSON: %s", out) + } + out = captureStdout(t, func() { + if err := cliPRComments([]string{"alice/demo", "2"}); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, "looks good") { + t.Fatalf("unexpected comments output: %s", out) + } + out = captureStdout(t, func() { + if err := cliToken([]string{"list", "--json"}); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, `"id": 1`) { + t.Fatalf("unexpected token JSON: %s", out) + } +} diff --git a/internal/app/cli_validation_test.go b/internal/app/cli_validation_test.go new file mode 100644 index 0000000..a9e68dc --- /dev/null +++ b/internal/app/cli_validation_test.go @@ -0,0 +1,71 @@ +package app + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestCLICommandValidation(t *testing.T) { + t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "missing.json")) + cases := []struct { + name string + fn func() error + want string + }{ + {"repo missing", func() error { return cliRepo(nil) }, "usage"}, + {"repo unknown", func() error { return cliRepo([]string{"wat"}) }, "unknown repo"}, + {"repo create invalid", func() error { return cliRepoCreate([]string{"demo"}) }, "usage"}, + {"repo publish invalid", func() error { return cliRepoPublish([]string{"demo", "--public", "--remote"}) }, "--remote requires"}, + {"repo set invalid", func() error { return cliRepoSet([]string{"alice/demo"}) }, "no settings"}, + {"repo archive invalid", func() error { return cliRepoArchive([]string{}, true) }, "usage"}, + {"repo delete invalid", func() error { return cliRepoDelete([]string{}) }, "usage"}, + {"repo search invalid", func() error { return cliRepoSearch([]string{"one", "two"}) }, "usage"}, + {"repo fork invalid", func() error { return cliRepoFork([]string{}) }, "usage"}, + {"repo collaborators invalid", func() error { return cliRepoCollaborators([]string{}) }, "usage"}, + {"repo collaborator invalid", func() error { return cliRepoCollaborator([]string{"add"}) }, "usage"}, + {"pr missing", func() error { return cliPR(nil) }, "usage"}, + {"pr unknown", func() error { return cliPR([]string{"wat"}) }, "unknown pr"}, + {"pr create invalid", func() error { return cliPRCreate(nil) }, "usage"}, + {"pr diff invalid", func() error { return cliPRDiff([]string{"alice/demo"}) }, "usage"}, + {"pr checkout invalid", func() error { return cliPRCheckout([]string{"alice/demo"}) }, "usage"}, + {"pr comment invalid", func() error { return cliPRComment([]string{"alice/demo", "1"}) }, "usage"}, + {"pr comments invalid", func() error { return cliPRComments([]string{"alice/demo"}) }, "usage"}, + {"pr action invalid", func() error { return cliPRAction([]string{"alice/demo"}, "merge") }, "usage"}, + {"token missing", func() error { return cliToken(nil) }, "usage"}, + {"admin missing", func() error { return cliAdmin(nil) }, "usage"}, + {"backup missing", func() error { return cliBackup(nil) }, "usage"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.fn() + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want containing %q", err, tc.want) + } + }) + } +} + +func TestRepoCloneURLAndWrapperHelpers(t *testing.T) { + if got := repoCloneURL("http://server/", "alice", "demo"); got != "http://server/alice/demo.git" { + t.Fatalf("repoCloneURL = %q", got) + } + if isReservedName("api") != true || isReservedName("alice") != false { + t.Fatal("isReservedName wrapper mismatch") + } + if _, _, err := mysqlCLIArgs("bad dsn"); err == nil { + t.Fatal("expected mysqlCLIArgs wrapper to fail on bad DSN") + } + if err := copyDir(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dst")); err == nil { + t.Fatal("expected copyDir wrapper to fail on missing source") + } + if _, err := gitRefs(filepath.Join(t.TempDir(), "missing.git"), "refs/heads"); err == nil { + t.Fatal("expected gitRefs wrapper to fail on missing repo") + } + if gitBranchExists(filepath.Join(t.TempDir(), "missing.git"), "bad branch") { + t.Fatal("invalid branch should not exist") + } + if err := runGit("", "definitely-not-a-git-command"); err == nil { + t.Fatal("expected runGit wrapper to return git error") + } +} diff --git a/internal/app/git_http_permissions_test.go b/internal/app/git_http_permissions_test.go new file mode 100644 index 0000000..ce21504 --- /dev/null +++ b/internal/app/git_http_permissions_test.go @@ -0,0 +1,56 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestHandleGitHTTPPermissionFailures(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + owner := User{ID: 1, Email: "alice@example.com", Username: "alice"} + privateRepo := Repository{ID: 10, OwnerUserID: owner.ID, Owner: "alice", Name: "demo", Visibility: "private", DefaultBranch: "main"} + + rr := httptest.NewRecorder() + s.handleGitHTTP(rr, httptest.NewRequest(http.MethodGet, "/bad/path", nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("invalid path status=%d", rr.Code) + } + + expectLoadRepo(mock, "alice", "demo", privateRepo) + rr = httptest.NewRecorder() + s.handleGitHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo.git/info/refs?service=git-upload-pack", nil)) + if rr.Code != http.StatusUnauthorized || rr.Header().Get("WWW-Authenticate") == "" { + t.Fatalf("private fetch status=%d headers=%#v body=%s", rr.Code, rr.Header(), rr.Body.String()) + } + + expectLoadRepo(mock, "alice", "demo", privateRepo) + rr = httptest.NewRecorder() + s.handleGitHTTP(rr, httptest.NewRequest(http.MethodPost, "/alice/demo.git/git-receive-pack", nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("push without auth status=%d body=%s", rr.Code, rr.Body.String()) + } + + archived := privateRepo + archived.Archived = true + expectLoadRepo(mock, "alice", "demo", archived) + mock.ExpectQuery("FROM auth_tokens t JOIN users u").WithArgs(hashToken("tok"), "alice").WillReturnRows(sqlmockUserRows(owner)) + rr = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/alice/demo.git/git-receive-pack", nil) + req.SetBasicAuth("alice", "tok") + s.handleGitHTTP(rr, req) + if rr.Code != http.StatusForbidden || !strings.Contains(rr.Body.String(), "archived") { + t.Fatalf("archived push status=%d body=%s", rr.Code, rr.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func sqlmockUserRows(user User) *sqlmock.Rows { + return sqlmock.NewRows([]string{"id", "email", "username", "is_admin"}).AddRow(user.ID, user.Email, user.Username, user.IsAdmin) +} diff --git a/internal/app/git_http_test.go b/internal/app/git_http_test.go index 69440b7..0d55bcc 100644 --- a/internal/app/git_http_test.go +++ b/internal/app/git_http_test.go @@ -2,6 +2,7 @@ package app import ( "net/http" + "net/http/httptest" "net/url" "testing" ) @@ -17,6 +18,9 @@ func TestParseGitPath(t *testing.T) { if _, _, _, ok := parseGitPath("/bad/path"); ok { t.Fatal("expected invalid path") } + if _, _, _, ok := parseGitPath("/too/many/parts.git/info/refs"); ok { + t.Fatal("expected invalid owner/repo path") + } } func TestGitService(t *testing.T) { @@ -28,4 +32,34 @@ func TestGitService(t *testing.T) { if got := gitService(req); got != "git-receive-pack" { t.Fatalf("unexpected receive-pack service: %q", got) } + req = &http.Request{Method: http.MethodGet, URL: &url.URL{Path: "/alice/demo.git/info/refs"}} + if got := gitService(req); got != "git-upload-pack" { + t.Fatalf("default service = %q", got) + } +} + +func TestWriteCGIResponse(t *testing.T) { + rr := httptest.NewRecorder() + writeCGIResponse(rr, []byte("Status: 201 Created\r\nContent-Type: text/plain\r\nX-Test: one\r\n\r\nbody")) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d", rr.Code) + } + if rr.Header().Get("Content-Type") != "text/plain" || rr.Header().Get("X-Test") != "one" { + t.Fatalf("unexpected headers: %#v", rr.Header()) + } + if rr.Body.String() != "body" { + t.Fatalf("body = %q", rr.Body.String()) + } + + rr = httptest.NewRecorder() + writeCGIResponse(rr, []byte("Content-Type: text/plain\n\nplain")) + if rr.Code != http.StatusOK || rr.Body.String() != "plain" { + t.Fatalf("status/body = %d/%q", rr.Code, rr.Body.String()) + } + + rr = httptest.NewRecorder() + writeCGIResponse(rr, []byte("raw")) + if rr.Body.String() != "raw" { + t.Fatalf("raw body = %q", rr.Body.String()) + } } diff --git a/internal/app/git_ops_test.go b/internal/app/git_ops_test.go new file mode 100644 index 0000000..238354d --- /dev/null +++ b/internal/app/git_ops_test.go @@ -0,0 +1,119 @@ +package app + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func requireGitForApp(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } +} + +func seedRepoWithFeatureBranch(t *testing.T, bare string) { + t.Helper() + if err := gitInitBare(bare); err != nil { + t.Fatal(err) + } + work := filepath.Join(t.TempDir(), "work") + if err := runGit("", "clone", bare, work); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"config", "user.name", "Test User"}, {"config", "user.email", "test@example.com"}} { + if err := runGit(work, args...); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\n"), 0644); err != nil { + t.Fatal(err) + } + if err := runGit(work, "add", "README.md"); err != nil { + t.Fatal(err) + } + if err := runGit(work, "commit", "-m", "initial"); err != nil { + t.Fatal(err) + } + if err := runGit(work, "push", "origin", "HEAD:main"); err != nil { + t.Fatal(err) + } + if err := runGit(work, "checkout", "-b", "feature"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(work, "feature.txt"), []byte("feature\n"), 0644); err != nil { + t.Fatal(err) + } + if err := runGit(work, "add", "feature.txt"); err != nil { + t.Fatal(err) + } + if err := runGit(work, "commit", "-m", "feature"); err != nil { + t.Fatal(err) + } + if err := runGit(work, "push", "origin", "HEAD:feature"); err != nil { + t.Fatal(err) + } + if err := runGit(work, "tag", "v1.0.0"); err != nil { + t.Fatal(err) + } + if err := runGit(work, "push", "origin", "v1.0.0"); err != nil { + t.Fatal(err) + } +} + +func TestAppGitRefsDiffAndMerge(t *testing.T) { + requireGitForApp(t) + s, mock, cleanup := newMockServer(t) + defer cleanup() + bare := s.repoPath("alice", "demo") + if err := os.MkdirAll(filepath.Dir(bare), 0755); err != nil { + t.Fatal(err) + } + seedRepoWithFeatureBranch(t, bare) + repo := Repository{ID: 10, OwnerUserID: 1, Owner: "alice", Name: "demo", Visibility: "public", DefaultBranch: "main"} + + expectLoadRepo(mock, "alice", "demo", repo) + rr := httptest.NewRecorder() + s.handleRepoBranches(rr, httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo/branches", nil), "alice", "demo") + if rr.Code != http.StatusOK { + t.Fatalf("branches status=%d body=%s", rr.Code, rr.Body.String()) + } + var refs []RefInfo + if err := json.Unmarshal(rr.Body.Bytes(), &refs); err != nil { + t.Fatal(err) + } + if len(refs) < 2 { + t.Fatalf("expected main and feature refs, got %#v", refs) + } + + expectLoadRepo(mock, "alice", "demo", repo) + rr = httptest.NewRecorder() + s.handleRepoTags(rr, httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo/tags", nil), "alice", "demo") + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "v1.0.0") { + t.Fatalf("tags status=%d body=%s", rr.Code, rr.Body.String()) + } + + pr := PullRequest{Number: 1, TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature"} + diff, err := s.prDiff(pr) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(diff, "feature.txt") { + t.Fatalf("diff missing feature file: %s", diff) + } + if err := s.mergePR(pr); err != nil { + t.Fatal(err) + } + if !gitBranchExists(bare, "main") { + t.Fatal("main branch should exist after merge") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/migration_integration_test.go b/internal/app/migration_integration_test.go new file mode 100644 index 0000000..ff55ff5 --- /dev/null +++ b/internal/app/migration_integration_test.go @@ -0,0 +1,98 @@ +//go:build integration + +package app + +import ( + "database/sql" + "os" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" +) + +func TestIntegrationMigrationsAreIdempotent(t *testing.T) { + dsn := os.Getenv("GITOOCEAN_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set GITOOCEAN_TEST_MYSQL_DSN to run integration migration tests") + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal(err) + } + if cfg.DBName == "" || !strings.Contains(cfg.DBName, "test") { + t.Fatalf("refusing to run integration test against non-test database %q", cfg.DBName) + } + db, err := openMySQLAndCreateDatabaseIfMissing(dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := resetIntegrationDB(db); err != nil { + t.Fatal(err) + } + if err := migrate(db); err != nil { + t.Fatal(err) + } + if err := migrate(db); err != nil { + t.Fatalf("second migrate should be idempotent: %v", err) + } + for _, table := range []string{"users", "auth_tokens", "repositories", "repository_collaborators", "pull_requests", "pull_request_comments"} { + var name string + if err := db.QueryRow("SHOW TABLES LIKE ?", table).Scan(&name); err != nil { + t.Fatalf("expected table %s: %v", table, err) + } + } +} + +func resetIntegrationDB(db *sql.DB) error { + _, _ = db.Exec("SET FOREIGN_KEY_CHECKS=0") + for _, table := range []string{"pull_request_comments", "pull_requests", "repository_collaborators", "auth_tokens", "repositories", "users"} { + _, _ = db.Exec("DROP TABLE IF EXISTS " + quoteMySQLIdentifier(table)) + } + _, err := db.Exec("SET FOREIGN_KEY_CHECKS=1") + return err +} + +func TestIntegrationAuthTokenLifecycle(t *testing.T) { + dsn := os.Getenv("GITOOCEAN_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set GITOOCEAN_TEST_MYSQL_DSN to run integration auth tests") + } + db, err := openMySQLAndCreateDatabaseIfMissing(dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := resetIntegrationDB(db); err != nil { + t.Fatal(err) + } + if err := migrate(db); err != nil { + t.Fatal(err) + } + if err := createUserDirect(db, "alice@example.com", "alice", "password123", true); err != nil { + t.Fatal(err) + } + s := &Server{db: db, storage: t.TempDir(), publicURL: "http://example.test"} + token, expires, err := s.createToken(1) + if err != nil { + t.Fatal(err) + } + if time.Until(expires) < tokenTTL-time.Minute { + t.Fatalf("unexpected expiry: %s", expires) + } + user, err := s.userFromToken(token, "alice") + if err != nil { + t.Fatal(err) + } + if user.Username != "alice" || !user.IsAdmin { + t.Fatalf("unexpected user from token: %#v", user) + } + if _, err := db.Exec("UPDATE auth_tokens SET revoked_at = UTC_TIMESTAMP() WHERE token_hash = ?", hashToken(token)); err != nil { + t.Fatal(err) + } + if _, err := s.userFromToken(token, "alice"); err == nil { + t.Fatal("expected revoked token to be rejected") + } +} diff --git a/internal/app/pr_handlers_test.go b/internal/app/pr_handlers_test.go new file mode 100644 index 0000000..d0e7d0f --- /dev/null +++ b/internal/app/pr_handlers_test.go @@ -0,0 +1,108 @@ +package app + +import ( + "database/sql" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" +) + +func prRows(pr PullRequest) *sqlmock.Rows { + created := pr.CreatedAt + if created.IsZero() { + created = time.Now().UTC() + } + updated := pr.UpdatedAt + if updated.IsZero() { + updated = created + } + closed := sql.NullTime{} + if pr.ClosedAt != nil { + closed = sql.NullTime{Time: *pr.ClosedAt, Valid: true} + } + merged := sql.NullTime{} + if pr.MergedAt != nil { + merged = sql.NullTime{Time: *pr.MergedAt, Valid: true} + } + return sqlmock.NewRows([]string{"id", "number", "target_repository_id", "source_repository_id", "author_user_id", "author", "source_owner", "source_repo", "source_branch", "target_owner", "target_repo", "target_branch", "title", "description", "status", "created_at", "updated_at", "closed_at", "merged_at"}). + AddRow(pr.ID, pr.Number, pr.TargetRepositoryID, pr.SourceRepositoryID, pr.AuthorUserID, pr.Author, pr.SourceOwner, pr.SourceRepo, pr.SourceBranch, pr.TargetOwner, pr.TargetRepo, pr.TargetBranch, pr.Title, pr.Description, pr.Status, created, updated, closed, merged) +} + +func TestPRListViewCloseAndComments(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + owner := User{ID: 1, Email: "alice@example.com", Username: "alice"} + token := "tok" + repo := Repository{ID: 10, OwnerUserID: owner.ID, Owner: "alice", Name: "demo", Visibility: "public", DefaultBranch: "main"} + pr := PullRequest{ID: 30, Number: 2, TargetRepositoryID: repo.ID, SourceRepositoryID: repo.ID, AuthorUserID: owner.ID, Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main", Title: "Fix", Description: "desc", Status: "open"} + + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID).WillReturnRows(prRows(pr)) + rr := httptest.NewRecorder() + s.handlePRList(rr, httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo/pulls", nil), "alice", "demo") + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Fix") { + t.Fatalf("list status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, pr.Number).WillReturnRows(prRows(pr)) + rr = httptest.NewRecorder() + s.handlePRView(rr, httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo/pulls/2", nil), "alice", "demo", 2) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Fix") { + t.Fatalf("view status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, owner) + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectExec("UPDATE pull_requests SET status = 'closed'").WithArgs(repo.ID, pr.Number).WillReturnResult(sqlmock.NewResult(0, 1)) + closedPR := pr + closedPR.Status = "closed" + mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, pr.Number).WillReturnRows(prRows(closedPR)) + rr = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/pulls/2/close", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handlePRClose(rr, req, "alice", "demo", 2) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "closed") { + t.Fatalf("close status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, pr.Number).WillReturnRows(prRows(pr)) + mock.ExpectQuery("SELECT c.id, u.username").WithArgs(pr.ID).WillReturnRows(sqlmock.NewRows([]string{"id", "username", "body", "created_at", "updated_at"}).AddRow(int64(1), "alice", "hello", time.Now().UTC(), time.Now().UTC())) + rr = httptest.NewRecorder() + s.handlePRCommentsList(rr, httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo/pulls/2/comments", nil), "alice", "demo", 2) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "hello") { + t.Fatalf("comments list status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, owner) + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, pr.Number).WillReturnRows(prRows(pr)) + mock.ExpectExec("INSERT INTO pull_request_comments").WithArgs(pr.ID, owner.ID, "hello").WillReturnResult(sqlmock.NewResult(5, 1)) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/pulls/2/comments", strings.NewReader(`{"body":" hello "}`)) + req.Header.Set("Authorization", "Bearer "+token) + s.handlePRCommentCreate(rr, req, "alice", "demo", 2) + if rr.Code != http.StatusCreated || !strings.Contains(rr.Body.String(), "hello") { + t.Fatalf("comment create status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, owner) + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, pr.Number).WillReturnRows(prRows(pr)) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/pulls/2/comments", strings.NewReader(`{"body":" "}`)) + req.Header.Set("Authorization", "Bearer "+token) + s.handlePRCommentCreate(rr, req, "alice", "demo", 2) + if rr.Code != http.StatusBadRequest { + t.Fatalf("empty comment status=%d body=%s", rr.Code, rr.Body.String()) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/repo_handlers_test.go b/internal/app/repo_handlers_test.go new file mode 100644 index 0000000..e1ee60d --- /dev/null +++ b/internal/app/repo_handlers_test.go @@ -0,0 +1,159 @@ +package app + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" +) + +func repoRows(repo Repository) *sqlmock.Rows { + var fork any = sql.NullInt64{} + if repo.ForkedFromID != nil { + fork = sql.NullInt64{Int64: *repo.ForkedFromID, Valid: true} + } + created := repo.CreatedAt + if created.IsZero() { + created = time.Now().UTC() + } + updated := repo.UpdatedAt + if updated.IsZero() { + updated = created + } + return sqlmock.NewRows([]string{"id", "owner_user_id", "owner", "name", "visibility", "description", "default_branch", "archived", "forked_from_repository_id", "created_at", "updated_at"}). + AddRow(repo.ID, repo.OwnerUserID, repo.Owner, repo.Name, repo.Visibility, repo.Description, repo.DefaultBranch, repo.Archived, fork, created, updated) +} + +func expectLoadRepo(mock sqlmock.Sqlmock, owner, name string, repo Repository) { + mock.ExpectQuery("FROM repositories r JOIN users u").WithArgs(strings.ToLower(owner), strings.ToLower(name)).WillReturnRows(repoRows(repo)) +} + +func TestRepoGetSearchAndUpdate(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + user := User{ID: 1, Email: "alice@example.com", Username: "alice"} + token := "tok" + repo := Repository{ID: 10, OwnerUserID: user.ID, Owner: "alice", Name: "demo", Visibility: "public", Description: "old", DefaultBranch: "main"} + + expectLoadRepo(mock, "alice", "demo", repo) + rr := httptest.NewRecorder() + s.handleRepoGet(rr, httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo", nil), "alice", "demo") + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "demo") { + t.Fatalf("get status=%d body=%s", rr.Code, rr.Body.String()) + } + + mock.ExpectQuery("FROM repositories r JOIN users u").WithArgs("demo", "%demo%", "%demo%").WillReturnRows(repoRows(repo)) + rr = httptest.NewRecorder() + s.handleRepoSearch(rr, httptest.NewRequest(http.MethodGet, "/api/repos/search?q=demo&scope=all", nil)) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "demo") { + t.Fatalf("search status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, user) + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectExec("UPDATE repositories SET description").WithArgs("new", "private", "main", false, repo.ID).WillReturnResult(sqlmock.NewResult(0, 1)) + desc := "new" + body := `{"description":"` + desc + `","visibility":"private"}` + rr = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/api/repos/alice/demo", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + s.handleRepoUpdate(rr, req, "alice", "demo") + if rr.Code != http.StatusOK { + t.Fatalf("update status=%d body=%s", rr.Code, rr.Body.String()) + } + var updated Repository + if err := json.Unmarshal(rr.Body.Bytes(), &updated); err != nil { + t.Fatal(err) + } + if updated.Description != "new" || updated.Visibility != "private" { + t.Fatalf("unexpected updated repo: %#v", updated) + } + + expectBearerUser(mock, token, user) + expectLoadRepo(mock, "alice", "demo", repo) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPatch, "/api/repos/alice/demo", strings.NewReader(`{"visibility":"hidden"}`)) + req.Header.Set("Authorization", "Bearer "+token) + s.handleRepoUpdate(rr, req, "alice", "demo") + if rr.Code != http.StatusBadRequest { + t.Fatalf("bad visibility status=%d body=%s", rr.Code, rr.Body.String()) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestRepoDeleteAndCollaborators(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + owner := User{ID: 1, Email: "alice@example.com", Username: "alice"} + token := "tok" + repo := Repository{ID: 10, OwnerUserID: owner.ID, Owner: "alice", Name: "demo", Visibility: "private", DefaultBranch: "main"} + + expectBearerUser(mock, token, owner) + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectQuery("SELECT COUNT").WithArgs(repo.ID, repo.ID).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/repos/alice/demo", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleRepoDelete(rr, req, "alice", "demo") + if rr.Code != http.StatusConflict { + t.Fatalf("delete open PR status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, owner) + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectQuery("SELECT COUNT").WithArgs(repo.ID, repo.ID).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + mock.ExpectExec("DELETE FROM repositories").WithArgs(repo.ID).WillReturnResult(sqlmock.NewResult(0, 1)) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodDelete, "/api/repos/alice/demo?force=true", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleRepoDelete(rr, req, "alice", "demo") + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "deleted") { + t.Fatalf("delete status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, owner) + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectQuery("SELECT rc.id, u.username").WithArgs(repo.ID).WillReturnRows(sqlmock.NewRows([]string{"id", "username", "role", "created_at"}).AddRow(int64(2), "bob", "write", time.Now().UTC())) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo/collaborators", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleCollaborators(rr, req, "alice", "demo", nil) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "bob") { + t.Fatalf("collab list status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, owner) + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectQuery("SELECT id FROM users").WithArgs("bob").WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(2))) + mock.ExpectExec("INSERT INTO repository_collaborators").WithArgs(repo.ID, int64(2), "read").WillReturnResult(sqlmock.NewResult(3, 1)) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/collaborators", strings.NewReader(`{"username":"Bob","role":"read"}`)) + req.Header.Set("Authorization", "Bearer "+token) + s.handleCollaborators(rr, req, "alice", "demo", nil) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "bob") { + t.Fatalf("collab add status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, owner) + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectExec("DELETE rc FROM repository_collaborators").WithArgs(repo.ID, "bob").WillReturnResult(sqlmock.NewResult(0, 1)) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodDelete, "/api/repos/alice/demo/collaborators/bob", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleCollaborators(rr, req, "alice", "demo", []string{"bob"}) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "removed") { + t.Fatalf("collab remove status=%d body=%s", rr.Code, rr.Body.String()) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/repo_pr_create_test.go b/internal/app/repo_pr_create_test.go new file mode 100644 index 0000000..b17e3c2 --- /dev/null +++ b/internal/app/repo_pr_create_test.go @@ -0,0 +1,90 @@ +package app + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestHandleRepoForkSuccess(t *testing.T) { + requireGitForApp(t) + s, mock, cleanup := newMockServer(t) + defer cleanup() + owner := User{ID: 1, Email: "alice@example.com", Username: "alice"} + src := Repository{ID: 10, OwnerUserID: owner.ID, Owner: "alice", Name: "demo", Visibility: "public", Description: "desc", DefaultBranch: "main"} + bare := s.repoPath(src.Owner, src.Name) + if err := os.MkdirAll(filepath.Dir(bare), 0755); err != nil { + t.Fatal(err) + } + seedRepoWithFeatureBranch(t, bare) + expectBearerUser(mock, "tok", owner) + expectLoadRepo(mock, "alice", "demo", src) + mock.ExpectExec("INSERT INTO repositories").WithArgs(owner.ID, "fork", src.Visibility, src.Description, src.DefaultBranch, src.ID).WillReturnResult(sqlmock.NewResult(30, 1)) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/fork", strings.NewReader(`{"name":"fork"}`)) + req.Header.Set("Authorization", "Bearer tok") + s.handleRepoFork(rr, req, "alice", "demo") + if rr.Code != http.StatusCreated { + t.Fatalf("fork status=%d body=%s", rr.Code, rr.Body.String()) + } + var repo Repository + if err := json.Unmarshal(rr.Body.Bytes(), &repo); err != nil { + t.Fatal(err) + } + if repo.ID != 30 || repo.Name != "fork" || repo.ForkedFromID == nil || *repo.ForkedFromID != src.ID { + t.Fatalf("unexpected fork repo: %#v", repo) + } + if _, err := os.Stat(s.repoPath("alice", "fork")); err != nil { + t.Fatalf("expected fork repo on disk: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestHandlePRCreateSuccessAndValidation(t *testing.T) { + requireGitForApp(t) + s, mock, cleanup := newMockServer(t) + defer cleanup() + owner := User{ID: 1, Email: "alice@example.com", Username: "alice"} + repo := Repository{ID: 10, OwnerUserID: owner.ID, Owner: "alice", Name: "demo", Visibility: "public", DefaultBranch: "main"} + bare := s.repoPath(repo.Owner, repo.Name) + if err := os.MkdirAll(filepath.Dir(bare), 0755); err != nil { + t.Fatal(err) + } + seedRepoWithFeatureBranch(t, bare) + pr := PullRequest{ID: 50, Number: 1, TargetRepositoryID: repo.ID, SourceRepositoryID: repo.ID, AuthorUserID: owner.ID, Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main", Title: "Fix", Status: "open"} + + expectBearerUser(mock, "tok", owner) + expectLoadRepo(mock, "alice", "demo", repo) + expectLoadRepo(mock, "alice", "demo", repo) + mock.ExpectQuery("SELECT COALESCE").WithArgs(repo.ID).WillReturnRows(sqlmock.NewRows([]string{"number"}).AddRow(1)) + mock.ExpectExec("INSERT INTO pull_requests").WithArgs(repo.ID, 1, owner.ID, repo.ID, "feature", "main", "Fix", "desc").WillReturnResult(sqlmock.NewResult(50, 1)) + mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, 1).WillReturnRows(prRows(pr)) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/pulls", strings.NewReader(`{"source_branch":"feature","target_branch":"main","title":"Fix","description":"desc"}`)) + req.Header.Set("Authorization", "Bearer tok") + s.handlePRCreate(rr, req, "alice", "demo") + if rr.Code != http.StatusCreated || !strings.Contains(rr.Body.String(), "Fix") { + t.Fatalf("PR create status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, "tok", owner) + expectLoadRepo(mock, "alice", "demo", repo) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/pulls", strings.NewReader(`{"source_branch":"bad branch","target_branch":"main","title":"Fix"}`)) + req.Header.Set("Authorization", "Bearer tok") + s.handlePRCreate(rr, req, "alice", "demo") + if rr.Code != http.StatusBadRequest { + t.Fatalf("bad PR status=%d body=%s", rr.Code, rr.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/scan_test.go b/internal/app/scan_test.go new file mode 100644 index 0000000..170b997 --- /dev/null +++ b/internal/app/scan_test.go @@ -0,0 +1,90 @@ +package app + +import ( + "database/sql" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" +) + +type stubScanner struct { + values []any + err error +} + +func (s stubScanner) Scan(dest ...any) error { + if s.err != nil { + return s.err + } + for i := range dest { + switch d := dest[i].(type) { + case *int64: + *d = s.values[i].(int64) + case *int: + *d = s.values[i].(int) + case *string: + *d = s.values[i].(string) + case *time.Time: + *d = s.values[i].(time.Time) + case *sql.NullTime: + *d = s.values[i].(sql.NullTime) + default: + panic("unsupported scan dest") + } + } + return nil +} + +func TestScanOnePR(t *testing.T) { + now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + closed := sql.NullTime{Time: now.Add(time.Hour), Valid: true} + pr, err := scanOnePR(stubScanner{values: []any{ + int64(1), 2, int64(3), int64(4), int64(5), + "author", "source-owner", "source-repo", "feature", "target-owner", "target-repo", "main", + "title", "desc", "closed", now, now, closed, sql.NullTime{}, + }}) + if err != nil { + t.Fatal(err) + } + if pr.ID != 1 || pr.Number != 2 || pr.Author != "author" || pr.ClosedAt == nil || pr.MergedAt != nil { + t.Fatalf("unexpected PR: %#v", pr) + } +} + +func TestScanReposAndPRRows(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + now := time.Now().UTC() + mock.ExpectQuery("repos").WillReturnRows(sqlmock.NewRows([]string{"id", "owner_user_id", "owner", "name", "visibility", "description", "default_branch", "archived", "forked_from_repository_id", "created_at", "updated_at"}).AddRow(int64(1), int64(2), "alice", "demo", "public", "desc", "main", false, sql.NullInt64{Int64: 9, Valid: true}, now, now)) + rows, err := db.Query("repos") + if err != nil { + t.Fatal(err) + } + repos, err := scanRepos(rows) + if err != nil { + t.Fatal(err) + } + if len(repos) != 1 || repos[0].ForkedFromID == nil || *repos[0].ForkedFromID != 9 { + t.Fatalf("unexpected repos: %#v", repos) + } + + mock.ExpectQuery("prs").WillReturnRows(sqlmock.NewRows([]string{"id", "number", "target_repository_id", "source_repository_id", "author_user_id", "author", "source_owner", "source_repo", "source_branch", "target_owner", "target_repo", "target_branch", "title", "description", "status", "created_at", "updated_at", "closed_at", "merged_at"}).AddRow(int64(1), 2, int64(3), int64(4), int64(5), "author", "so", "sr", "feature", "to", "tr", "main", "title", "desc", "open", now, now, sql.NullTime{}, sql.NullTime{})) + prRows, err := db.Query("prs") + if err != nil { + t.Fatal(err) + } + prs, err := scanPRs(prRows) + if err != nil { + t.Fatal(err) + } + if len(prs) != 1 || prs[0].Number != 2 || prs[0].Status != "open" { + t.Fatalf("unexpected prs: %#v", prs) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/server_test.go b/internal/app/server_test.go new file mode 100644 index 0000000..c3ee792 --- /dev/null +++ b/internal/app/server_test.go @@ -0,0 +1,80 @@ +package app + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" +) + +func TestRunServerRequiresDSN(t *testing.T) { + t.Setenv("GITOOCEAN_DSN", "") + cfgPath := filepath.Join(t.TempDir(), "missing.json") + err := runServer([]string{"--config", cfgPath, "--addr", ":0"}) + if err == nil || !strings.Contains(err.Error(), "mysql_dsn is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestQuoteMySQLIdentifierWrapper(t *testing.T) { + if got := quoteMySQLIdentifier("a`b"); got != "`a``b`" { + t.Fatalf("quoteMySQLIdentifier = %q", got) + } +} + +func TestMigrateSuccessAndDuplicateAlterations(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + for i := 0; i < 6; i++ { + mock.ExpectExec("CREATE TABLE IF NOT EXISTS").WillReturnResult(sqlmock.NewResult(0, 0)) + } + for i := 0; i < 4; i++ { + mock.ExpectExec("ALTER TABLE").WillReturnError(&mysql.MySQLError{Number: 1060, Message: "Duplicate column"}) + } + if err := migrate(db); err != nil { + t.Fatalf("migrate returned error for duplicate column: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestMigrateReturnsCreateAndAlterErrors(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectExec("CREATE TABLE IF NOT EXISTS").WillReturnError(assertErr("create failed")) + if err := migrate(db); err == nil || !strings.Contains(err.Error(), "create failed") { + t.Fatalf("expected create error, got %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + + db, mock, err = sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + for i := 0; i < 6; i++ { + mock.ExpectExec("CREATE TABLE IF NOT EXISTS").WillReturnResult(sqlmock.NewResult(0, 0)) + } + mock.ExpectExec("ALTER TABLE").WillReturnError(assertErr("alter failed")) + if err := migrate(db); err == nil || !strings.Contains(err.Error(), "alter failed") { + t.Fatalf("expected alter error, got %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +type assertErr string + +func (e assertErr) Error() string { return string(e) } diff --git a/internal/app/token_admin_test.go b/internal/app/token_admin_test.go new file mode 100644 index 0000000..1adaa36 --- /dev/null +++ b/internal/app/token_admin_test.go @@ -0,0 +1,131 @@ +package app + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestTokenRoutes(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + user := User{ID: 1, Email: "alice@example.com", Username: "alice"} + token := "tok" + now := time.Now().UTC() + + expectBearerUser(mock, token, user) + mock.ExpectQuery("SELECT id, created_at, expires_at, revoked_at").WithArgs(user.ID).WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "expires_at", "revoked_at", "active"}).AddRow(int64(9), now, now.Add(time.Hour), sql.NullTime{}, true)) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/tokens", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleTokenRoutes(rr, req, "") + if rr.Code != http.StatusOK { + t.Fatalf("list status=%d body=%s", rr.Code, rr.Body.String()) + } + var infos []TokenInfo + if err := json.Unmarshal(rr.Body.Bytes(), &infos); err != nil { + t.Fatal(err) + } + if len(infos) != 1 || infos[0].ID != 9 || !infos[0].Active { + t.Fatalf("unexpected token infos: %#v", infos) + } + + expectBearerUser(mock, token, user) + mock.ExpectExec("UPDATE auth_tokens SET revoked_at").WithArgs(int64(9), user.ID).WillReturnResult(sqlmock.NewResult(0, 1)) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodDelete, "/api/tokens/9", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleTokenRoutes(rr, req, "/9") + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "revoked") { + t.Fatalf("revoke status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, user) + mock.ExpectExec("DELETE FROM auth_tokens WHERE user_id").WithArgs(user.ID).WillReturnResult(sqlmock.NewResult(0, 2)) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/tokens/prune", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleTokenRoutes(rr, req, "/prune") + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "pruned") { + t.Fatalf("prune status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, user) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodDelete, "/api/tokens/nope", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleTokenRoutes(rr, req, "/nope") + if rr.Code != http.StatusBadRequest { + t.Fatalf("invalid id status=%d body=%s", rr.Code, rr.Body.String()) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestAdminRoutes(t *testing.T) { + s, mock, cleanup := newMockServer(t) + defer cleanup() + admin := User{ID: 1, Email: "admin@example.com", Username: "admin", IsAdmin: true} + plain := User{ID: 2, Email: "bob@example.com", Username: "bob"} + token := "tok" + + expectBearerUser(mock, token, plain) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleAdminRoutes(rr, req, "/users") + if rr.Code != http.StatusForbidden { + t.Fatalf("non-admin status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, admin) + mock.ExpectQuery("SELECT id, email, username, is_admin FROM users").WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin"}).AddRow(int64(1), "admin@example.com", "admin", true).AddRow(int64(2), "bob@example.com", "bob", false)) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleAdminRoutes(rr, req, "/users") + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "bob@example.com") { + t.Fatalf("users status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, admin) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodDelete, "/api/admin/users/admin", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleAdminRoutes(rr, req, "/users/admin") + if rr.Code != http.StatusBadRequest { + t.Fatalf("delete self status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, admin) + mock.ExpectExec("DELETE FROM users").WithArgs("bob").WillReturnResult(sqlmock.NewResult(0, 1)) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodDelete, "/api/admin/users/bob", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleAdminRoutes(rr, req, "/users/bob") + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "deleted") { + t.Fatalf("delete status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectBearerUser(mock, token, admin) + mock.ExpectExec("DELETE FROM auth_tokens WHERE expires_at").WillReturnResult(sqlmock.NewResult(0, 1)) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/admin/tokens/prune", nil) + req.Header.Set("Authorization", "Bearer "+token) + s.handleAdminRoutes(rr, req, "/tokens/prune") + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "pruned") { + t.Fatalf("admin prune status=%d body=%s", rr.Code, rr.Body.String()) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go new file mode 100644 index 0000000..c261d00 --- /dev/null +++ b/internal/backup/backup_test.go @@ -0,0 +1,74 @@ +package backup + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "testing" +) + +func TestMySQLCLIArgsTCP(t *testing.T) { + args, db, err := MySQLCLIArgs("user:pass@tcp(127.0.0.1:3307)/gitocean_test?parseTime=true") + if err != nil { + t.Fatal(err) + } + want := []string{"-uuser", "-ppass", "-h127.0.0.1", "-P3307"} + if !reflect.DeepEqual(args, want) || db != "gitocean_test" { + t.Fatalf("args=%#v db=%q, want %#v %q", args, db, want, "gitocean_test") + } +} + +func TestMySQLCLIArgsUnix(t *testing.T) { + args, db, err := MySQLCLIArgs("user@unix(/tmp/mysql.sock)/gitocean") + if err != nil { + t.Fatal(err) + } + want := []string{"-uuser", "--socket", "/tmp/mysql.sock"} + if !reflect.DeepEqual(args, want) || db != "gitocean" { + t.Fatalf("args=%#v db=%q, want %#v %q", args, db, want, "gitocean") + } +} + +func TestMySQLCLIArgsInvalidDSN(t *testing.T) { + if _, _, err := MySQLCLIArgs("bad dsn"); err == nil { + t.Fatal("expected invalid DSN to fail") + } +} + +func TestCopyDir(t *testing.T) { + src := t.TempDir() + dst := filepath.Join(t.TempDir(), "dst") + if err := os.MkdirAll(filepath.Join(src, "nested"), 0750); err != nil { + t.Fatal(err) + } + file := filepath.Join(src, "nested", "file.txt") + if err := os.WriteFile(file, []byte("hello"), 0640); err != nil { + t.Fatal(err) + } + if err := CopyDir(src, dst); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(dst, "nested", "file.txt")) + if err != nil { + t.Fatal(err) + } + if string(got) != "hello" { + t.Fatalf("unexpected copied content: %q", got) + } + if runtime.GOOS != "windows" { + info, err := os.Stat(filepath.Join(dst, "nested", "file.txt")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0640 { + t.Fatalf("mode = %v, want 0640", info.Mode().Perm()) + } + } +} + +func TestCopyDirMissingSource(t *testing.T) { + if err := CopyDir(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dst")); err == nil { + t.Fatal("expected missing source to fail") + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c7788c9..b34fcb8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "os" "path/filepath" "testing" "time" @@ -23,6 +24,41 @@ func TestClientConfigRoundTrip(t *testing.T) { } } +func TestLoadClientFailures(t *testing.T) { + t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "missing.json")) + if _, err := LoadClient(); err == nil { + t.Fatal("expected missing client config to fail") + } + + path := filepath.Join(t.TempDir(), "client.json") + t.Setenv("GITOOCEAN_CONFIG", path) + if err := os.WriteFile(path, []byte(`{"server":"http://example.test"}`), 0600); err != nil { + t.Fatal(err) + } + if _, err := LoadClient(); err == nil { + t.Fatal("expected missing token to fail") + } + if err := os.WriteFile(path, []byte(`{`), 0600); err != nil { + t.Fatal(err) + } + if _, err := LoadClient(); err == nil { + t.Fatal("expected invalid JSON to fail") + } +} + +func TestEnvServerOverrides(t *testing.T) { + t.Setenv("GITOOCEAN_SERVER", "http://server.test/") + if got := EnvServer(); got != "http://server.test" { + t.Fatalf("EnvServer() = %q", got) + } + + t.Setenv("GITOOCEAN_SERVER", "") + t.Setenv("GITOOCEAN_SERVER_CONFIG", filepath.Join(t.TempDir(), "missing.json")) + if got := EnvServer(); got != DefaultServerURL { + t.Fatalf("EnvServer() = %q, want default", got) + } +} + func TestServerConfigRoundTripAndEnvServer(t *testing.T) { path := filepath.Join(t.TempDir(), "server.json") t.Setenv("GITOOCEAN_SERVER_CONFIG", path) diff --git a/internal/dbutil/mysql_integration_test.go b/internal/dbutil/mysql_integration_test.go new file mode 100644 index 0000000..6926640 --- /dev/null +++ b/internal/dbutil/mysql_integration_test.go @@ -0,0 +1,33 @@ +//go:build integration + +package dbutil + +import ( + "os" + "strings" + "testing" + + "github.com/go-sql-driver/mysql" +) + +func TestIntegrationOpenMySQLAndCreateDatabaseIfMissing(t *testing.T) { + dsn := os.Getenv("GITOOCEAN_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set GITOOCEAN_TEST_MYSQL_DSN to run integration DB tests") + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal(err) + } + if cfg.DBName == "" || !strings.Contains(cfg.DBName, "test") { + t.Fatalf("refusing to run integration test against non-test database %q", cfg.DBName) + } + db, err := OpenMySQLAndCreateDatabaseIfMissing(dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := db.Ping(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/dbutil/mysql_test.go b/internal/dbutil/mysql_test.go index a4c606e..56c8fec 100644 --- a/internal/dbutil/mysql_test.go +++ b/internal/dbutil/mysql_test.go @@ -1,6 +1,11 @@ package dbutil -import "testing" +import ( + "errors" + "testing" + + "github.com/go-sql-driver/mysql" +) func TestQuoteMySQLIdentifier(t *testing.T) { got := QuoteMySQLIdentifier("git`ocean") @@ -8,3 +13,15 @@ func TestQuoteMySQLIdentifier(t *testing.T) { t.Fatalf("unexpected quote: %q", got) } } + +func TestIsDuplicateColumnError(t *testing.T) { + if !IsDuplicateColumnError(&mysql.MySQLError{Number: 1060, Message: "Duplicate column name"}) { + t.Fatal("expected duplicate column mysql error") + } + if IsDuplicateColumnError(&mysql.MySQLError{Number: 1049, Message: "Unknown database"}) { + t.Fatal("did not expect unknown database to be duplicate column") + } + if IsDuplicateColumnError(errors.New("not mysql")) { + t.Fatal("did not expect ordinary error") + } +} diff --git a/internal/gitutil/git_test.go b/internal/gitutil/git_test.go new file mode 100644 index 0000000..e602a4e --- /dev/null +++ b/internal/gitutil/git_test.go @@ -0,0 +1,104 @@ +package gitutil + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } +} + +func TestRunFailureIncludesCommandOutput(t *testing.T) { + requireGit(t) + err := Run("", "definitely-not-a-git-command") + if err == nil { + t.Fatal("expected git command to fail") + } + if !strings.Contains(err.Error(), "git definitely-not-a-git-command failed") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInitBareBranchExistsAndRefs(t *testing.T) { + requireGit(t) + root := t.TempDir() + bare := filepath.Join(root, "repo.git") + if err := InitBare(bare); err != nil { + t.Fatal(err) + } + head, err := os.ReadFile(filepath.Join(bare, "HEAD")) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(head)) != "ref: refs/heads/main" { + t.Fatalf("HEAD = %q", string(head)) + } + if BranchExists(bare, "main") { + t.Fatal("new empty bare repo should not have a main branch commit yet") + } + if BranchExists(bare, "bad branch") { + t.Fatal("invalid branch should not exist") + } + + work := filepath.Join(root, "work") + if err := Run("", "clone", bare, work); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"config", "user.name", "Test User"}, + {"config", "user.email", "test@example.com"}, + } { + if err := Run(work, args...); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\n"), 0644); err != nil { + t.Fatal(err) + } + if err := Run(work, "add", "README.md"); err != nil { + t.Fatal(err) + } + if err := Run(work, "commit", "-m", "initial commit"); err != nil { + t.Fatal(err) + } + if err := Run(work, "push", "origin", "HEAD:main"); err != nil { + t.Fatal(err) + } + if err := Run(work, "tag", "v1.0.0"); err != nil { + t.Fatal(err) + } + if err := Run(work, "push", "origin", "v1.0.0"); err != nil { + t.Fatal(err) + } + if !BranchExists(bare, "main") { + t.Fatal("main branch should exist after push") + } + branches, err := Refs(bare, "refs/heads") + if err != nil { + t.Fatal(err) + } + if len(branches) != 1 || branches[0].Name != "main" || branches[0].Message != "initial commit" { + t.Fatalf("unexpected branches: %#v", branches) + } + tags, err := Refs(bare, "refs/tags") + if err != nil { + t.Fatal(err) + } + if len(tags) != 1 || tags[0].Name != "v1.0.0" { + t.Fatalf("unexpected tags: %#v", tags) + } +} + +func TestRefsInvalidRepo(t *testing.T) { + requireGit(t) + if _, err := Refs(filepath.Join(t.TempDir(), "missing.git"), "refs/heads"); err == nil { + t.Fatal("expected missing repo refs to fail") + } +} diff --git a/internal/httputil/json_test.go b/internal/httputil/json_test.go new file mode 100644 index 0000000..5696ed5 --- /dev/null +++ b/internal/httputil/json_test.go @@ -0,0 +1,85 @@ +package httputil + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDecodeJSONStrict(t *testing.T) { + t.Run("valid", func(t *testing.T) { + var got struct { + Name string `json:"name"` + } + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"demo"}`)) + rr := httptest.NewRecorder() + if !DecodeJSON(rr, req, &got) { + t.Fatalf("DecodeJSON returned false: %s", rr.Body.String()) + } + if got.Name != "demo" { + t.Fatalf("unexpected decoded value: %#v", got) + } + }) + + t.Run("unknown field", func(t *testing.T) { + var got struct { + Name string `json:"name"` + } + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"demo","extra":true}`)) + rr := httptest.NewRecorder() + if DecodeJSON(rr, req, &got) { + t.Fatal("DecodeJSON returned true for unknown field") + } + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest) + } + if !strings.Contains(rr.Body.String(), "unknown field") { + t.Fatalf("unexpected body: %s", rr.Body.String()) + } + }) + + t.Run("invalid json", func(t *testing.T) { + var got map[string]string + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{`)) + rr := httptest.NewRecorder() + if DecodeJSON(rr, req, &got) { + t.Fatal("DecodeJSON returned true for invalid JSON") + } + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest) + } + }) +} + +func TestWriteJSONAndError(t *testing.T) { + rr := httptest.NewRecorder() + WriteJSON(rr, http.StatusCreated, map[string]string{"status": "ok"}) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("content-type = %q", ct) + } + var body map[string]string + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["status"] != "ok" { + t.Fatalf("unexpected body: %#v", body) + } + + rr = httptest.NewRecorder() + WriteError(rr, http.StatusTeapot, "short and stout") + if rr.Code != http.StatusTeapot { + t.Fatalf("status = %d", rr.Code) + } + body = map[string]string{} + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["error"] != "short and stout" { + t.Fatalf("unexpected error body: %#v", body) + } +} diff --git a/notes/PHASE_4_COVERAGE_NOTES.md b/notes/PHASE_4_COVERAGE_NOTES.md new file mode 100644 index 0000000..c3ff048 --- /dev/null +++ b/notes/PHASE_4_COVERAGE_NOTES.md @@ -0,0 +1,72 @@ +# Phase 4 Coverage Notes + +Phase 4 added broad unit coverage plus opt-in integration coverage for database-dependent behavior. + +## Default checks + +These run without MySQL or external services: + +```bash +go test ./... +go vet ./... +go test -cover ./... +``` + +Current default coverage snapshot after Phase 4 implementation: + +```text +internal/app 64.3% +internal/backup 42.3% +internal/config 79.5% +internal/dbutil 8.6% +internal/gitutil 89.3% +internal/httputil 100.0% +internal/validate 100.0% +``` + +`cmd/gitocean` remains effectively untestable in-process because `main()` intentionally calls `os.Exit`; command behavior is covered through `internal/app.Run` and CLI helper tests instead. + +## Integration checks + +Integration tests are behind the `integration` build tag and skip unless required environment variables are set: + +```bash +go test -tags=integration ./... +``` + +For MySQL-backed tests: + +```bash +GITOOCEAN_TEST_MYSQL_DSN='user:pass@tcp(127.0.0.1:3306)/gitocean_test?parseTime=true' go test -tags=integration ./... +``` + +The integration tests refuse to run against a database name that does not contain `test`. + +## Intentionally environment-dependent areas + +The following areas are covered either by opt-in integration tests or by focused wrapper/helper tests rather than exhaustive default unit coverage: + +- MySQL database auto-creation (`internal/dbutil.OpenMySQLAndCreateDatabaseIfMissing`) +- Full migration execution against a real MySQL database +- `mysqldump`, `mysql`, and `tar` subprocess backup/restore execution +- `git http-backend` CGI execution success paths +- Full OS process behavior of `cmd/gitocean/main.go` + +## Behavior covered by default tests + +Default tests now cover: + +- Validators and reserved names +- Client/server config load/save and env overrides +- Strict JSON helpers +- MySQL identifier quoting and duplicate-column detection +- Git utility behavior with temp bare repos +- CLI parsing, dispatch errors, human-readable output, and JSON opt-ins +- CLI HTTP workflows for auth, repos, PRs, tokens, admin, and storage checks +- API auth handlers, token handlers, admin handlers +- Repository create/get/search/update/delete/fork/collaborator flows +- Permission helpers for public/private/owner/collaborator access +- PR list/view/create/close/comment flows +- Git path/service parsing, CGI response parsing, and Git HTTP permission denials +- PR diff and merge logic using temp Git repositories +- Migration statement execution with sqlmock diff --git a/plans/PHASE_4_PLAN.md b/plans/PHASE_4_PLAN.md new file mode 100644 index 0000000..fa60b51 --- /dev/null +++ b/plans/PHASE_4_PLAN.md @@ -0,0 +1,447 @@ +# Phase 4 Plan: Full-Codebase Test Coverage + +## Goal + +Build comprehensive automated tests across the full Gitocean codebase so that core behavior is protected by repeatable checks. The test suite should give high confidence that Gitocean works when the external environment is correct: MySQL is available, Git is installed, network ports are free, credentials are valid, and filesystem permissions are sane. + +Phase 4 is focused on correctness and regression protection. Product behavior should remain unchanged unless a test exposes a real bug that must be fixed. + +## Non-goals + +- Do not add a Web UI. +- Do not add SSH Git transport. +- Do not replace MySQL. +- Do not redesign APIs or CLI commands for test convenience. +- Do not mock away all meaningful behavior; tests should exercise real logic where practical. +- Do not require MySQL or network services for the default fast unit test suite. + +## Testing principles + +1. **Fast tests by default** + - `go test ./...` should remain quick and should not require MySQL, long-running servers, or external network access. + +2. **Integration tests are explicit** + - Tests requiring MySQL, Git subprocesses, or real HTTP listeners should be opt-in via build tags or environment variables. + +3. **Cover logic, not incidental implementation** + - Prefer behavior-focused tests over brittle line-by-line implementation tests. + - Preserve public API routes, CLI UX, auth rules, permissions, and storage behavior. + +4. **Table-driven tests** + - Use table-driven tests for validators, parsers, permission checks, route handling, config parsing, output formatting, and error mapping. + +5. **Hermetic filesystem use** + - Use `t.TempDir()` for storage, config, bare repositories, worktrees, and backup fixtures. + +6. **No production data** + - Integration tests must create isolated databases, repos, users, and tokens. + - Tests must clean up after themselves. + +7. **Regression tests for bugs** + - Every bug fixed during Phase 4 should get a regression test before or with the fix. + +8. **Coverage with intent** + - Track coverage using `go test -cover ./...`. + - Aim for effectively full coverage of pure/domain logic and high meaningful coverage of handlers and CLI parsing. + - External process wrappers and unavoidable OS-specific failure paths may be documented as excluded from practical full coverage. + +## Test tiers + +### Tier 1: Unit tests + +Run with: + +```bash +go test ./... +``` + +Must not require MySQL, a running Gitocean server, or external network access. + +Covers: + +- Validators +- CLI argument parsing +- Config load/save +- JSON helpers +- MySQL DSN and identifier helpers +- Git route parsing +- Permission helpers where dependencies can be isolated +- Output formatting +- Token/password helper behavior where possible +- Pure backup helper behavior such as path/argument construction + +### Tier 2: Integration tests + +Run explicitly, for example: + +```bash +go test -tags=integration ./... +``` + +or with environment gates such as: + +```bash +GITOOCEAN_TEST_MYSQL_DSN='user:pass@tcp(127.0.0.1:3306)/gitocean_test?parseTime=true' go test -tags=integration ./... +``` + +Covers: + +- MySQL migrations +- Register/login/logout/whoami against a real DB +- Token expiry/revoke/prune flows +- Repository create/get/update/delete/search/fork flows +- Collaborator read/write rules +- Pull request create/list/view/close/comment/diff/merge flows +- HTTP Smart Git clone/fetch/push authorization paths +- Backup/restore with real metadata and repository files when tools are present + +### Tier 3: End-to-end CLI tests + +Run explicitly because they spawn commands and may be slower: + +```bash +go test -tags=e2e ./... +``` + +Covers user workflows through the actual CLI binary or `app.Run`: + +- `init -> server config` +- `register -> login -> whoami -> logout` +- `repo create -> clone -> push/fetch` +- `repo publish` +- `repo collaborator add/remove` +- `pr create -> comment -> diff -> merge` +- `token list/revoke/prune` +- `backup create/restore` + +## Coverage targets + +### Required before Phase 4 is considered complete + +- `go test ./...` passes. +- `go vet ./...` passes. +- Unit coverage exists for every internal package. +- Every HTTP API handler has at least one success-path test and meaningful failure-path tests. +- Every CLI command parser has success and failure tests. +- Permission-sensitive behavior has positive and negative tests: + - public read + - private read denied + - owner write + - collaborator read + - collaborator write + - archived repo write denied + - admin-only route allowed/denied +- Auth-sensitive behavior has positive and negative tests: + - valid token + - revoked token + - expired token + - malformed bearer token + - basic auth for Git +- Git HTTP behavior has read/write route coverage. +- Migrations can run against a clean database and are idempotent. +- Coverage reports are generated and reviewed. + +### Practical coverage expectation + +Use coverage as a signal, not a vanity number. The target is full meaningful coverage of project logic: + +- Pure functions and parsers: near 100%. +- Domain rules and permission checks: near 100%. +- HTTP handlers: high coverage across success and expected errors. +- CLI dispatch/parsing/output: high coverage. +- External command execution wrappers: covered through integration/e2e where possible; hard-to-trigger OS failure branches may be documented. + +## Proposed package-by-package work + +### `internal/validate` + +Add/expand tests for: + +- Valid usernames +- Invalid usernames +- Valid repo names +- Invalid repo names +- Valid branches +- Invalid branches +- Reserved names + +### `internal/config` + +Add/expand tests for: + +- Default server URL +- Environment overrides +- Client config path override +- Server config path override +- Client config load/save +- Server config load/save +- Invalid JSON +- Missing token/server cases + +### `internal/dbutil` + +Add/expand unit tests for: + +- MySQL identifier quoting +- Duplicate column error detection +- DSN parsing edge cases where possible + +Add integration tests for: + +- Auto-create missing database behavior +- Migration idempotency +- Schema contains expected tables/columns/indexes + +### `internal/gitutil` + +Add unit/integration tests for: + +- Bare repository initialization +- Default HEAD points to `refs/heads/main` +- Branch existence true/false +- Ref listing for branches and tags +- Git command error messages + +These tests require Git but should use `t.TempDir()` only. + +### `internal/httputil` + +Add tests for: + +- Strict JSON decoding rejects unknown fields +- Invalid JSON returns HTTP 400 +- `WriteJSON` content type/status/body +- `WriteError` body format + +### `internal/backup` + +Add unit tests for: + +- MySQL CLI arg generation for tcp DSNs +- MySQL CLI arg generation for unix socket DSNs +- Copy directory behavior +- File mode preservation where practical + +Add integration tests for: + +- Backup archive creation +- Restore into a clean temp storage directory +- Failure when required external tools are unavailable, if practical + +### `internal/app` + +Split tests by behavior area: + +#### CLI dispatch and parsing + +- Unknown root command +- Help output +- `server` flag parsing defaults/overrides +- Repo create/publish parser combinations +- Repo refs/view/search JSON flag behavior +- PR create/list/view parser combinations +- Token/admin command validation +- Human-readable output by default +- `--json` output where supported + +#### HTTP API routing + +Use `httptest` where possible. + +- Unknown route returns 404 +- Wrong method returns 404 or expected error +- Register/login/logout/me routes +- Token routes +- Admin routes +- Repo routes +- PR routes +- Git HTTP route dispatch + +#### Auth behavior + +- First registered user becomes admin +- Later users are not admin +- Login with username +- Login with email +- Invalid password rejected +- Token generated with 7-day expiry +- Logout revokes token +- Expired/revoked token rejected +- Basic auth maps username/token correctly for Git + +#### Repo behavior + +- Create public repo +- Create private repo +- Reject invalid repo name +- Reject reserved repo/owner names +- Duplicate repo rejected +- Get public repo anonymously +- Private repo denied anonymously +- Private repo allowed to owner +- Private repo allowed to collaborator according to role +- Update description/visibility/default branch +- Archive/unarchive +- Delete requires force when non-empty if current behavior requires it +- Search public/all/mine scopes +- Fork public repo +- Fork private repo access rules + +#### Pull request behavior + +- Create same-repo PR as owner +- Create cross-repo PR from fork +- Reject invalid branches +- Reject unauthorized PR source/target combinations +- List PRs +- View PR +- Close PR +- Comment on PR +- List comments +- Diff PR +- Merge PR +- Reject merge when not authorized +- Reject merge for closed PR + +#### Git HTTP behavior + +Use temp bare repos and `httptest` where possible. + +- Parse git paths +- Upload-pack public access +- Upload-pack private denied without auth +- Upload-pack private allowed for owner/collaborator read +- Receive-pack denied without auth +- Receive-pack allowed for owner/write collaborator +- Receive-pack denied for read-only collaborator +- Receive-pack denied when repo is archived +- Required CGI environment variables are set for `git http-backend` + +## Test harness improvements + +Phase 4 will likely need small internal test helpers: + +```text +internal/app/test_helpers_test.go +internal/testutil/ +``` + +Potential helpers: + +- Create temp server storage +- Create test DB handle or skip integration tests if env var absent +- Run migrations +- Create users/tokens/repos directly for tests +- Create temp Git repos and commits +- Start `httptest.Server` +- Capture stdout/stderr for CLI output tests +- Build authenticated API requests + +Helpers should live in `_test.go` files unless they are useful production abstractions. + +## Test data and isolation + +- Use unique usernames/repo names per test when a real DB is used. +- Use `t.Cleanup` for filesystem and DB cleanup. +- Prefer transactions for DB tests when possible. +- For migration tests, use a uniquely named temporary database. +- Never use real `storage/` or real user config files in tests. +- Override config paths with environment variables: + - `GITOOCEAN_CONFIG` + - `GITOOCEAN_SERVER_CONFIG` + +## Commands to run during Phase 4 + +Fast loop: + +```bash +gofmt -w . +go test ./... +go vet ./... +``` + +Coverage: + +```bash +go test -cover ./... +go test -coverprofile=coverage.out ./... +go tool cover -func=coverage.out +``` + +Integration loop: + +```bash +go test -tags=integration ./... +``` + +Optional race checks: + +```bash +go test -race ./... +``` + +## Milestones + +### Milestone 1: Establish coverage baseline + +- Run `go test -cover ./...`. +- Record low-coverage packages. +- Add missing tests for current pure helpers. +- Add `internal/httputil`, `internal/gitutil`, `internal/backup`, and parser tests where missing. + +### Milestone 2: Add HTTP/API unit tests + +- Build `httptest` helpers. +- Add route and handler tests for auth, tokens, repos, PRs, collaborators, and admin. +- Mock or isolate DB state where possible; otherwise mark tests integration. + +### Milestone 3: Add DB/migration integration tests + +- Add opt-in integration test setup for MySQL. +- Test clean migrations and idempotency. +- Test DB-backed auth/repo/PR flows. + +### Milestone 4: Add Git behavior tests + +- Test Git utility functions with temp repos. +- Test HTTP Smart Git permissions. +- Test push/fetch behavior through temp repositories where practical. + +### Milestone 5: Add CLI workflow tests + +- Capture CLI output for human-readable defaults. +- Test `--json` opt-ins. +- Add end-to-end workflows behind explicit `e2e` tag. + +### Milestone 6: Coverage review and hardening + +- Generate coverage profile. +- Add targeted tests for missed branches. +- Document any intentionally uncovered external/environmental branches. +- Run final checks: + +```bash +gofmt -w . +go test ./... +go vet ./... +go test -cover ./... +``` + +## Completion checklist + +- [x] Coverage baseline captured. +- [x] Unit tests added for all packages. +- [x] HTTP route and handler tests added. +- [x] CLI parser and output tests added. +- [x] Auth/token behavior tests added. +- [x] Repo permission tests added. +- [x] Collaborator permission tests added. +- [x] PR behavior tests added. +- [x] Git utility and Git HTTP tests added. +- [x] Backup helper tests added. +- [x] MySQL migration integration tests added. +- [ ] Optional e2e CLI workflow tests added. +- [x] `go test ./...` passes. +- [x] `go vet ./...` passes. +- [x] Coverage report reviewed. +- [x] Any intentionally uncovered environment-dependent paths documented.