Implement Phase 4 test coverage

This commit is contained in:
2026-06-08 15:03:41 -05:00
parent fd81f443d6
commit bba767c821
27 changed files with 2780 additions and 2 deletions
+8
View File
@@ -46,6 +46,14 @@ Run checks with:
gofmt -w . gofmt -w .
go test ./... go test ./...
go vet ./... 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 ## Code layout
+4 -1
View File
@@ -7,4 +7,7 @@ require (
golang.org/x/crypto v0.31.0 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
)
+3
View File
@@ -1,6 +1,9 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= 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 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+382
View File
@@ -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)
}
}
+58
View File
@@ -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)
}
}
+186
View File
@@ -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)
}
})
}
}
+107
View File
@@ -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)
}
}
+127
View File
@@ -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)
}
}
+71
View File
@@ -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")
}
}
+56
View File
@@ -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)
}
+34
View File
@@ -2,6 +2,7 @@ package app
import ( import (
"net/http" "net/http"
"net/http/httptest"
"net/url" "net/url"
"testing" "testing"
) )
@@ -17,6 +18,9 @@ func TestParseGitPath(t *testing.T) {
if _, _, _, ok := parseGitPath("/bad/path"); ok { if _, _, _, ok := parseGitPath("/bad/path"); ok {
t.Fatal("expected invalid path") 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) { func TestGitService(t *testing.T) {
@@ -28,4 +32,34 @@ func TestGitService(t *testing.T) {
if got := gitService(req); got != "git-receive-pack" { if got := gitService(req); got != "git-receive-pack" {
t.Fatalf("unexpected receive-pack service: %q", got) 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())
}
} }
+119
View File
@@ -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)
}
}
@@ -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")
}
}
+108
View File
@@ -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)
}
}
+159
View File
@@ -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)
}
}
+90
View File
@@ -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)
}
}
+90
View File
@@ -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)
}
}
+80
View File
@@ -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) }
+131
View File
@@ -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)
}
}
+74
View File
@@ -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")
}
}
+36
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "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) { func TestServerConfigRoundTripAndEnvServer(t *testing.T) {
path := filepath.Join(t.TempDir(), "server.json") path := filepath.Join(t.TempDir(), "server.json")
t.Setenv("GITOOCEAN_SERVER_CONFIG", path) t.Setenv("GITOOCEAN_SERVER_CONFIG", path)
+33
View File
@@ -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)
}
}
+18 -1
View File
@@ -1,6 +1,11 @@
package dbutil package dbutil
import "testing" import (
"errors"
"testing"
"github.com/go-sql-driver/mysql"
)
func TestQuoteMySQLIdentifier(t *testing.T) { func TestQuoteMySQLIdentifier(t *testing.T) {
got := QuoteMySQLIdentifier("git`ocean") got := QuoteMySQLIdentifier("git`ocean")
@@ -8,3 +13,15 @@ func TestQuoteMySQLIdentifier(t *testing.T) {
t.Fatalf("unexpected quote: %q", got) 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")
}
}
+104
View File
@@ -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")
}
}
+85
View File
@@ -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)
}
}
+72
View File
@@ -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
+447
View File
@@ -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.