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