383 lines
14 KiB
Go
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)
|
|
}
|
|
}
|