559 lines
17 KiB
Go
559 lines
17 KiB
Go
package tests
|
|
|
|
import "bytes"
|
|
import "context"
|
|
import "encoding/json"
|
|
import "fmt"
|
|
import "io"
|
|
import "net/http"
|
|
import "net/http/httptest"
|
|
import "sync"
|
|
import "testing"
|
|
import "time"
|
|
|
|
import "codit/config"
|
|
import "codit/internal/auth"
|
|
import "codit/internal/db"
|
|
import "codit/internal/git"
|
|
import codit_http "codit/internal/http"
|
|
import "codit/internal/handlers"
|
|
import "codit/internal/middleware"
|
|
import "codit/internal/models"
|
|
import "codit/internal/repolock"
|
|
import "codit/internal/rpm"
|
|
import "codit/internal/storage"
|
|
import codit_logger "codit/logger"
|
|
|
|
// noopLogger satisfies codit_logger.Logger but discards all output.
|
|
type noopLogger struct{}
|
|
|
|
func (noopLogger) Write(_ string, _ codit_logger.LogLevel, _ string, _ ...interface{}) {}
|
|
func (noopLogger) WriteWithCallDepth(_ string, _ codit_logger.LogLevel, _ int, _ string, _ ...interface{}) {
|
|
}
|
|
func (noopLogger) Close() {}
|
|
func (noopLogger) Rotate() {}
|
|
|
|
// sessionTransport is an http.RoundTripper that injects a session cookie on
|
|
// every request. net/http/cookiejar does not work with IP-based httptest
|
|
// servers, so we inject the cookie manually.
|
|
type sessionTransport struct {
|
|
base http.RoundTripper
|
|
cookie *http.Cookie
|
|
}
|
|
|
|
func (st *sessionTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
var cloned *http.Request
|
|
cloned = req.Clone(req.Context())
|
|
if st.cookie != nil {
|
|
cloned.AddCookie(st.cookie)
|
|
}
|
|
return st.base.RoundTrip(cloned)
|
|
}
|
|
|
|
// testAPIServer holds the live httptest.Server and helpers for the API
|
|
// concurrency tests.
|
|
type testAPIServer struct {
|
|
Srv *httptest.Server
|
|
Client *http.Client
|
|
Store *db.Store
|
|
RepoLocks *repolock.Manager
|
|
AdminUser models.User
|
|
}
|
|
|
|
// newTestAPIServer builds a minimal but realistic API test server:
|
|
// - real SQLite store with migrations applied
|
|
// - real repolock.Manager, rpm.MetaManager, rpm.MirrorManager
|
|
// - the API router with txRead/txWrite wrappers, same as initHandlers()
|
|
// - middleware.WithRequestStore + middleware.WithUserCookie
|
|
// - an admin user already logged in (session cookie in Client transport)
|
|
func newTestAPIServer(t *testing.T) *testAPIServer {
|
|
var dir string
|
|
var store *db.Store
|
|
var locks *repolock.Manager
|
|
var meta *rpm.MetaManager
|
|
var mirror *rpm.MirrorManager
|
|
var api *handlers.API
|
|
var router *codit_http.Router
|
|
var txRead func(codit_http.HandlerFunc) codit_http.HandlerFunc
|
|
var txWrite func(codit_http.HandlerFunc) codit_http.HandlerFunc
|
|
var handler http.Handler
|
|
var srv *httptest.Server
|
|
var adminUser models.User
|
|
var sessionCookie *http.Cookie
|
|
var client *http.Client
|
|
|
|
t.Helper()
|
|
|
|
dir = t.TempDir()
|
|
store = openTestStore(t)
|
|
t.Cleanup(func() { store.Close() })
|
|
|
|
locks = repolock.NewManager()
|
|
meta = rpm.NewMetaManager()
|
|
mirror = rpm.NewMirrorManager(store, noopLogger{}, meta)
|
|
|
|
api = handlers.NewAPI(handlers.APIOptions{
|
|
Store: store,
|
|
Repos: git.RepoManager{BaseDir: dir},
|
|
RpmBase: dir,
|
|
RpmMeta: meta,
|
|
RpmMirror: mirror,
|
|
DockerBase: dir,
|
|
Uploads: storage.FileStore{BaseDir: dir},
|
|
RepoLocks: locks,
|
|
Logger: noopLogger{},
|
|
Cfg: config.Config{SessionTTL: config.Duration(24 * time.Hour)},
|
|
})
|
|
|
|
router = codit_http.NewRouter()
|
|
txRead = func(h codit_http.HandlerFunc) codit_http.HandlerFunc {
|
|
return middleware.TxRoute(store, middleware.TxDeferred, middleware.TxUnbuffered, h)
|
|
}
|
|
txWrite = func(h codit_http.HandlerFunc) codit_http.HandlerFunc {
|
|
return middleware.TxRoute(store, middleware.TxImmediate, middleware.TxBuffered, h)
|
|
}
|
|
|
|
router.Handle("POST", "/api/login", api.Login)
|
|
router.Handle("GET", "/api/projects", txRead(api.ListProjects))
|
|
router.Handle("POST", "/api/projects", txWrite(api.CreateProject))
|
|
router.Handle("DELETE", "/api/projects/:id", api.DeleteProject)
|
|
router.Handle("GET", "/api/projects/:projectId/boards", txRead(api.ListBoards))
|
|
router.Handle("POST", "/api/projects/:projectId/boards", txWrite(api.CreateBoard))
|
|
router.Handle("GET", "/api/boards/:boardId/field-values/:field", txRead(api.ListBoardFieldValues))
|
|
router.Handle("POST", "/api/boards/:boardId/field-values/:field", txWrite(api.CreateBoardFieldValue))
|
|
router.Handle("DELETE", "/api/boards/:boardId/field-values/:field/:valueId", txWrite(api.DeleteBoardFieldValue))
|
|
|
|
handler = middleware.WithUserCookie(store, "codit_session", router)
|
|
handler = middleware.WithRequestStore(store, handler)
|
|
|
|
srv = httptest.NewServer(handler)
|
|
t.Cleanup(srv.Close)
|
|
|
|
adminUser = createLoginableAdminUser(t, store, "api-admin")
|
|
sessionCookie = obtainSessionCookie(t, srv.URL, "api-admin", "pass-123")
|
|
|
|
client = &http.Client{
|
|
Transport: &sessionTransport{
|
|
base: http.DefaultTransport,
|
|
cookie: sessionCookie,
|
|
},
|
|
}
|
|
|
|
return &testAPIServer{
|
|
Srv: srv,
|
|
Client: client,
|
|
Store: store,
|
|
RepoLocks: locks,
|
|
AdminUser: adminUser,
|
|
}
|
|
}
|
|
|
|
// createLoginableAdminUser creates an admin user that can log in via
|
|
// POST /api/login. AuthProviderID must be "db" (the builtin provider) for
|
|
// the Login handler to verify the password.
|
|
func createLoginableAdminUser(t *testing.T, store *db.Store, username string) models.User {
|
|
var passwordHash string
|
|
var err error
|
|
var user models.User
|
|
|
|
t.Helper()
|
|
passwordHash, err = auth.HashPassword("pass-123")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
user = models.User{
|
|
Username: username,
|
|
DisplayName: username,
|
|
Email: username + "@local",
|
|
IsAdmin: true,
|
|
Disabled: false,
|
|
AuthProviderID: db.BuiltinDBProviderPublicID,
|
|
}
|
|
user, err = store.CreateUser(user, passwordHash)
|
|
if err != nil {
|
|
t.Fatalf("create admin user: %v", err)
|
|
}
|
|
return user
|
|
}
|
|
|
|
// obtainSessionCookie sends a single unauthenticated POST /api/login request
|
|
// and returns the session cookie. The plain http.Client (no jar) is used so
|
|
// that the response Set-Cookie can be read directly from the response header.
|
|
func obtainSessionCookie(t *testing.T, baseURL string, username string, password string) *http.Cookie {
|
|
var body []byte
|
|
var resp *http.Response
|
|
var err error
|
|
var c *http.Cookie
|
|
|
|
t.Helper()
|
|
body, err = json.Marshal(map[string]string{"username": username, "password": password})
|
|
if err != nil {
|
|
t.Fatalf("marshal login body: %v", err)
|
|
}
|
|
// bare client — no cookie jar — so we can read the Set-Cookie header directly
|
|
resp, err = (&http.Client{}).Post(baseURL+"/api/login", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("POST /api/login: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("login failed: status %d", resp.StatusCode)
|
|
}
|
|
for _, c = range resp.Cookies() {
|
|
if c.Name == "codit_session" {
|
|
return c
|
|
}
|
|
}
|
|
t.Fatalf("login response did not set codit_session cookie")
|
|
return nil
|
|
}
|
|
|
|
// newAuthenticatedClient creates a new http.Client that injects the given
|
|
// session cookie on every request. Each concurrent goroutine needs its own
|
|
// client because http.Client is safe for concurrent use but sharing a single
|
|
// sessionTransport pointer would race on the Transport field.
|
|
func newAuthenticatedClient(sessionCookie *http.Cookie) *http.Client {
|
|
return &http.Client{
|
|
Transport: &sessionTransport{
|
|
base: http.DefaultTransport,
|
|
cookie: sessionCookie,
|
|
},
|
|
}
|
|
}
|
|
|
|
// createAPIProject POSTs to /api/projects and returns the created project ID.
|
|
func createAPIProject(t *testing.T, client *http.Client, baseURL string, slug string) string {
|
|
var body []byte
|
|
var resp *http.Response
|
|
var respBody []byte
|
|
var m map[string]interface{}
|
|
var err error
|
|
|
|
t.Helper()
|
|
body, err = json.Marshal(map[string]string{
|
|
"slug": slug,
|
|
"name": slug,
|
|
"description": slug + " project",
|
|
"home_page": models.ProjectPageSettings,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal project body: %v", err)
|
|
}
|
|
resp, err = client.Post(baseURL+"/api/projects", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("POST /api/projects: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, err = io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read create project response: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create project failed: status %d body=%s", resp.StatusCode, respBody)
|
|
}
|
|
err = json.Unmarshal(respBody, &m)
|
|
if err != nil {
|
|
t.Fatalf("unmarshal project response: %v", err)
|
|
}
|
|
var id string
|
|
var ok bool
|
|
id, ok = m["id"].(string)
|
|
if !ok || id == "" {
|
|
t.Fatalf("project response missing id field: %s", respBody)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// createAPIBoardFieldValue POSTs one board field value and returns the HTTP
|
|
// status code.
|
|
func createAPIBoardFieldValue(t *testing.T, client *http.Client, baseURL string, boardID string, field string, label string) int {
|
|
var body []byte
|
|
var resp *http.Response
|
|
var err error
|
|
|
|
t.Helper()
|
|
body, err = json.Marshal(map[string]string{
|
|
"field": field,
|
|
"value": label,
|
|
"label": label,
|
|
"color": "#000000",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal field value body: %v", err)
|
|
}
|
|
resp, err = client.Post(
|
|
fmt.Sprintf("%s/api/boards/%s/field-values/%s", baseURL, boardID, field),
|
|
"application/json",
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("POST field value: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.ReadAll(resp.Body)
|
|
return resp.StatusCode
|
|
}
|
|
|
|
// createTestGitRepo inserts a repo row without a filesystem path so that
|
|
// ListReposOwned finds it (providing a lock target) but moveToTrash is never
|
|
// called (empty paths are skipped by the delete handler).
|
|
func createTestGitRepo(t *testing.T, store *db.Store, projectID string, userID string, name string) models.Repo {
|
|
var repo models.Repo
|
|
var err error
|
|
|
|
t.Helper()
|
|
repo = models.Repo{
|
|
ProjectID: projectID,
|
|
Name: name,
|
|
Type: models.RepoTypeGit,
|
|
Path: "",
|
|
CreatedBy: userID,
|
|
}
|
|
repo, err = store.CreateRepo(repo)
|
|
if err != nil {
|
|
t.Fatalf("create test repo: %v", err)
|
|
}
|
|
return repo
|
|
}
|
|
|
|
// createAPIServerBoard creates a board directly in the store (faster than
|
|
// round-tripping through HTTP for setup work).
|
|
func createAPIServerBoard(t *testing.T, store *db.Store, userID string, projectID string, title string) string {
|
|
var board models.Board
|
|
var err error
|
|
|
|
t.Helper()
|
|
board = models.Board{
|
|
ProjectID: projectID,
|
|
Title: title,
|
|
CreatedBy: userID,
|
|
UpdatedBy: userID,
|
|
}
|
|
board, err = store.CreateBoard(context.Background(), board)
|
|
if err != nil {
|
|
t.Fatalf("create board: %v", err)
|
|
}
|
|
return board.ID
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TestSessionCookieAuthenticatesRequests: authenticated requests succeed;
|
|
// unauthenticated requests receive 401.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSessionCookieAuthenticatesRequests(t *testing.T) {
|
|
var ts *testAPIServer
|
|
var anonClient *http.Client
|
|
var resp *http.Response
|
|
var err error
|
|
|
|
ts = newTestAPIServer(t)
|
|
|
|
// authenticated request must succeed
|
|
resp, err = ts.Client.Get(ts.Srv.URL + "/api/projects")
|
|
if err != nil {
|
|
t.Fatalf("GET /api/projects (authed): %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 for authenticated request, got %d", resp.StatusCode)
|
|
}
|
|
|
|
// unauthenticated request must be rejected
|
|
anonClient = &http.Client{}
|
|
resp, err = anonClient.Get(ts.Srv.URL + "/api/projects")
|
|
if err != nil {
|
|
t.Fatalf("GET /api/projects (anon): %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for unauthenticated request, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TestDeleteProjectReturnsWith409WhenRepoLocked: DeleteProject returns 409
|
|
// when the repo lock is already held by another goroutine.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDeleteProjectReturnsWith409WhenRepoLocked(t *testing.T) {
|
|
var ts *testAPIServer
|
|
var project models.Project
|
|
var repo models.Repo
|
|
var unlock func()
|
|
var req *http.Request
|
|
var resp *http.Response
|
|
var respBody []byte
|
|
var err error
|
|
|
|
ts = newTestAPIServer(t)
|
|
|
|
project = createTestProject(t, ts.Store, ts.AdminUser, "lock-test-proj")
|
|
repo = createTestGitRepo(t, ts.Store, project.ID, ts.AdminUser.ID, "lock-test-repo")
|
|
|
|
// hold the repo lock before the DELETE request arrives at the handler
|
|
unlock = ts.RepoLocks.Lock(repo.ID)
|
|
defer unlock()
|
|
|
|
req, err = http.NewRequest(http.MethodDelete, ts.Srv.URL+"/api/projects/"+project.ID, nil)
|
|
if err != nil {
|
|
t.Fatalf("build request: %v", err)
|
|
}
|
|
resp, err = ts.Client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("DELETE /api/projects/%s: %v", project.ID, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, err = io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read response: %v", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("expected 409 Conflict when repo is locked, got %d body=%s",
|
|
resp.StatusCode, respBody)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TestConcurrentDeleteProjectReturnsWith409ForLoser: two concurrent DELETE
|
|
// requests targeting the same project race for the repo lock.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConcurrentDeleteProjectReturnsWith409ForLoser(t *testing.T) {
|
|
var ts *testAPIServer
|
|
var project models.Project
|
|
var sessionCookie *http.Cookie
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
var statuses []int
|
|
var ready = make(chan struct{})
|
|
var i int
|
|
|
|
ts = newTestAPIServer(t)
|
|
|
|
project = createTestProject(t, ts.Store, ts.AdminUser, "concurrent-del-proj")
|
|
// repo provides the lock target; empty path means moveToTrash is skipped
|
|
createTestGitRepo(t, ts.Store, project.ID, ts.AdminUser.ID, "concurrent-del-repo")
|
|
|
|
// obtain a session cookie once and share it across goroutines — each
|
|
// goroutine uses its own client but the same session token is valid for
|
|
// concurrent requests (sessions are not single-use)
|
|
sessionCookie = obtainSessionCookie(t, ts.Srv.URL, "api-admin", "pass-123")
|
|
|
|
for i = 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
var req *http.Request
|
|
var resp *http.Response
|
|
var c *http.Client
|
|
var err error
|
|
|
|
<-ready
|
|
|
|
req, err = http.NewRequest(http.MethodDelete, ts.Srv.URL+"/api/projects/"+project.ID, nil)
|
|
if err != nil {
|
|
t.Errorf("build request: %v", err)
|
|
return
|
|
}
|
|
c = newAuthenticatedClient(sessionCookie)
|
|
resp, err = c.Do(req)
|
|
if err != nil {
|
|
t.Errorf("DELETE project: %v", err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.ReadAll(resp.Body)
|
|
|
|
mu.Lock()
|
|
statuses = append(statuses, resp.StatusCode)
|
|
mu.Unlock()
|
|
}()
|
|
}
|
|
|
|
close(ready)
|
|
wg.Wait()
|
|
|
|
if len(statuses) != 2 {
|
|
t.Fatalf("expected 2 responses, got %d", len(statuses))
|
|
}
|
|
|
|
var successCount int
|
|
for _, code := range statuses {
|
|
if code == http.StatusNoContent {
|
|
successCount++
|
|
}
|
|
}
|
|
// at most one goroutine may win the repo lock and delete the project
|
|
if successCount > 1 {
|
|
t.Errorf("at most one DELETE should return 204, but got %d (statuses: %v)",
|
|
successCount, statuses)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TestConcurrentBoardFieldValueCreateViaHTTP: goroutines concurrently POST
|
|
// to the same board field via the full HTTP+txWrite stack. Every request
|
|
// uses a unique value so there are no UNIQUE constraint conflicts. All must
|
|
// succeed and all distinct field values must exist after completion.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConcurrentBoardFieldValueCreateViaHTTP(t *testing.T) {
|
|
var ts *testAPIServer
|
|
var project models.Project
|
|
var boardID string
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
var codes []int
|
|
var ready = make(chan struct{})
|
|
var values []models.BoardFieldValue
|
|
var sessionCookie *http.Cookie
|
|
var err error
|
|
const goroutines = 8
|
|
|
|
ts = newTestAPIServer(t)
|
|
project = createTestProject(t, ts.Store, ts.AdminUser, "concurrent-fv-proj")
|
|
boardID = createAPIServerBoard(t, ts.Store, ts.AdminUser.ID, project.ID, "concurrent-fv-board")
|
|
sessionCookie = obtainSessionCookie(t, ts.Srv.URL, "api-admin", "pass-123")
|
|
|
|
var i int
|
|
for i = 0; i < goroutines; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
var c *http.Client
|
|
var code int
|
|
|
|
<-ready
|
|
|
|
c = newAuthenticatedClient(sessionCookie)
|
|
code = createAPIBoardFieldValue(t, c, ts.Srv.URL, boardID, "status",
|
|
fmt.Sprintf("value-%d", idx))
|
|
mu.Lock()
|
|
codes = append(codes, code)
|
|
mu.Unlock()
|
|
}(i)
|
|
}
|
|
|
|
close(ready)
|
|
wg.Wait()
|
|
|
|
for idx, code := range codes {
|
|
if code != http.StatusOK && code != http.StatusCreated {
|
|
t.Errorf("goroutine %d: expected 2xx, got %d", idx, code)
|
|
}
|
|
}
|
|
|
|
values, err = ts.Store.ListBoardFieldValues(boardID, "status")
|
|
if err != nil {
|
|
t.Fatalf("list field values: %v", err)
|
|
}
|
|
if len(values) != goroutines {
|
|
t.Errorf("expected %d committed field values, got %d", goroutines, len(values))
|
|
}
|
|
}
|