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) } }