Files
codit/backend/tests/api_concurrent_test.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))
}
}