Files
GitOcean-Old/internal/app/app_more_test.go
T
2026-06-08 15:03:41 -05:00

383 lines
14 KiB
Go

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