Compare commits

...

6 Commits

13 changed files with 2591 additions and 435 deletions
+9 -22
View File
@@ -10,6 +10,7 @@ import "strconv"
import "strings"
import storedb "codit/internal/db"
import "codit/internal/storage"
import _ "modernc.org/sqlite"
import "github.com/gdamore/tcell/v2"
import "github.com/rivo/tview"
@@ -415,9 +416,7 @@ func (b *browser) resolvePathHint(name string) string {
var rel string
var parts []string
var service string
var projectID int64
var repoID int64
var project projectInfo
var repo repoInfo
var err error
rel, err = filepath.Rel(b.DataDir, filepath.Join(b.Cwd, name))
@@ -429,20 +428,10 @@ func (b *browser) resolvePathHint(name string) string {
return ""
}
service = parts[0]
projectID, err = parseStorageIDSegment(parts[1])
if err == nil {
project = b.ProjectsByID[projectID]
if project.ID > 0 && len(parts) == 2 {
return fmt.Sprintf("project: %s (%s)", project.Slug, project.PublicID)
}
}
if len(parts) < 3 {
return ""
}
if service == "git" {
parts[2] = strings.TrimSuffix(parts[2], ".git")
parts[1] = strings.TrimSuffix(parts[1], storage.GitRepoDirSuffix)
}
repoID, err = parseStorageIDSegment(parts[2])
repoID, err = parseStorageIDSegment(parts[1])
if err != nil {
return ""
}
@@ -468,7 +457,7 @@ func (b *browser) countMismatches() int {
count = 0
for _, repoID = range ids {
repo = b.ReposByID[repoID]
expected = expectedRepoPath(b.DataDir, repo.RepoType, repo.ProjectID, repo.ID)
expected = expectedRepoPath(b.DataDir, repo.RepoType, repo.ID)
_, err = os.Stat(expected)
if err != nil {
count = count + 1
@@ -512,7 +501,7 @@ func (b *browser) collectMismatchLines() []string {
lines = []string{}
for _, repoID = range ids {
repo = b.ReposByID[repoID]
expected = expectedRepoPath(b.DataDir, repo.RepoType, repo.ProjectID, repo.ID)
expected = expectedRepoPath(b.DataDir, repo.RepoType, repo.ID)
_, err = os.Stat(expected)
if err == nil {
continue
@@ -529,19 +518,17 @@ func (b *browser) collectMismatchLines() []string {
return lines
}
func expectedRepoPath(dataDir string, repoType string, projectID int64, repoID int64) string {
var p string
func expectedRepoPath(dataDir string, repoType string, repoID int64) string {
var r string
p = formatStorageIDSegment(projectID)
r = formatStorageIDSegment(repoID)
if repoType == "git" {
return filepath.Join(dataDir, "git", p, r+".git")
return filepath.Join(dataDir, storage.RepoDirGit, r+storage.GitRepoDirSuffix)
}
if repoType == "rpm" {
return filepath.Join(dataDir, "rpm", p, r)
return filepath.Join(dataDir, storage.RepoDirRPM, r)
}
if repoType == "docker" {
return filepath.Join(dataDir, "docker", p, r)
return filepath.Join(dataDir, storage.RepoDirDocker, r)
}
return ""
}
+5 -6
View File
@@ -481,14 +481,13 @@ func (s *Store) DeleteRPMMirrorRuns(repoID string, path string) (int64, error) {
var res sql.Result
var count int64
var err error
res, err = s.Exec(`DELETE FROM rpm_mirror_runs WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path = ?`, repoID, path)
if err != nil {
return 0, err
}
if err != nil { return 0, err }
count, err = res.RowsAffected()
if err != nil {
return 0, err
}
if err != nil { return 0, err }
return count, nil
}
+27 -5
View File
@@ -113,6 +113,9 @@ func (s *HTTPServer) handle(w http.ResponseWriter, r *http.Request) *http.Reques
var unlock func()
var locked bool
var err error
var lockedRepo models.Repo
var lockedProject models.Project
var lockedImageName string
path = r.URL.Path
store = s.requestStore(r)
if path == s.prefix || path == s.prefix + "/" {
@@ -139,8 +142,18 @@ func (s *HTTPServer) handle(w http.ResponseWriter, r *http.Request) *http.Reques
w.WriteHeader(http.StatusConflict)
return r
}
defer unlock()
} else {
unlock = s.lockRepoRead(repo.ID)
}
defer unlock()
lockedRepo, lockedProject, lockedImageName, err = s.resolveRepo(store, repoName)
if err != nil || lockedRepo.ID != repo.ID {
w.WriteHeader(http.StatusNotFound)
return r
}
repo = lockedRepo
project = lockedProject
imageName = lockedImageName
r = WithRequestLogInfo(r, RequestLogInfo{
OrgPath: path,
ProjectID: project.ID,
@@ -171,6 +184,12 @@ func (s *HTTPServer) tryLockRepo(repoID string) (func(), bool) {
return s.locks.TryLock(repoID)
}
func (s *HTTPServer) lockRepoRead(repoID string) func() {
if s.locks == nil { return func() {} }
return s.locks.LockRead(repoID)
}
func (s *HTTPServer) isWriteRequest(action string, method string) bool {
if action == "manifest" && method == http.MethodPut { return true }
if action == "upload_start" && method == http.MethodPost { return true }
@@ -245,7 +264,6 @@ func (s *HTTPServer) resolveRepo(store *db.Store, repoName string) (models.Repo,
var parts []string
var project models.Project
var repo models.Repo
var projectStorageID int64
var repoStorageID int64
var err error
var slug string
@@ -272,11 +290,11 @@ func (s *HTTPServer) resolveRepo(store *db.Store, repoName string) (models.Repo,
if err != nil {
return repo, project, "", err
}
projectStorageID, repoStorageID, err = store.GetRepoStorageIDs(repo.ID)
_, repoStorageID, err = store.GetRepoStorageIDs(repo.ID)
if err != nil {
return repo, project, "", err
}
repo.Path = filepath.Join(s.baseDir, storageIDSegment(projectStorageID), storageIDSegment(repoStorageID))
repo.Path = filepath.Join(s.baseDir, storageIDSegment(repoStorageID))
return repo, project, image, nil
}
@@ -599,8 +617,12 @@ func (s *HTTPServer) handleUpload(w http.ResponseWriter, r *http.Request, repo m
w.WriteHeader(http.StatusBadRequest)
return
}
appendFile, err = os.OpenFile(uploadPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
appendFile, err = os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
if os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
+3 -1
View File
@@ -3,6 +3,8 @@ package git
import "os"
import "path/filepath"
import "codit/internal/storage"
import git "github.com/go-git/go-git/v5"
type RepoManager struct {
@@ -18,7 +20,7 @@ func (rm *RepoManager) RepoPath(repoID string) string {
}
func (rm *RepoManager) RepoPathFor(projectID, repoName string) string {
return filepath.Join(rm.BaseDir, projectID, repoName+".git")
return filepath.Join(rm.BaseDir, projectID, repoName+storage.GitRepoDirSuffix)
}
func (rm *RepoManager) InitRepo(repoPath string, bare bool) error {
File diff suppressed because it is too large Load Diff
+41
View File
@@ -120,6 +120,7 @@ func (api *API) OIDCCallbackWithProvider(w http.ResponseWriter, r *http.Request,
var txStore *db.Store
var tokenValue string
var expires time.Time
var currentProvider models.AuthProvider
provider, err = api.store(r).GetAuthProvider(params["id"])
if err != nil || provider.Type != models.AuthProviderTypeOIDC {
@@ -168,6 +169,24 @@ func (api *API) OIDCCallbackWithProvider(w http.ResponseWriter, r *http.Request,
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
currentProvider, err = txStore.GetAuthProvider(provider.ID)
if err != nil {
_ = txStore.Rollback()
if errors.Is(err, sql.ErrNoRows) {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc login rejected provider=%s reason=provider_removed", provider.Name)
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc provider changed during login")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if !oidcProviderCompatibleForCallback(provider, currentProvider) {
_ = txStore.Rollback()
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc login rejected provider=%s reason=provider_changed", provider.Name)
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc provider changed during login")
return
}
provider = currentProvider
user, err = api.oidcGetOrCreateUser(r.Context(), txStore, claims, provider)
if err != nil {
_ = txStore.Rollback()
@@ -226,6 +245,28 @@ func oidcProviderConfigured(p models.AuthProvider) bool {
return true
}
func oidcProviderCompatibleForCallback(original models.AuthProvider, current models.AuthProvider) bool {
if current.Type != models.AuthProviderTypeOIDC {
return false
}
if !current.Enabled {
return false
}
if !oidcProviderConfigured(current) {
return false
}
if strings.TrimSpace(current.OIDCClientID) != strings.TrimSpace(original.OIDCClientID) {
return false
}
if strings.TrimSpace(current.OIDCTokenURL) != strings.TrimSpace(original.OIDCTokenURL) {
return false
}
if strings.TrimSpace(current.OIDCUserInfoURL) != strings.TrimSpace(original.OIDCUserInfoURL) {
return false
}
return true
}
func oidcStateCookieNameForProvider(api *API, providerID string) string {
return api.oidcStateCookieName() + "_" + providerID
}
+98 -7
View File
@@ -8,23 +8,76 @@ type Manager struct {
locks sync.Map
}
type lockCandidate struct {
id string
index int
}
func NewManager() *Manager {
return &Manager{}
}
func (m *Manager) Lock(repoID string) func() {
return m.LockWrite(repoID)
}
func (m *Manager) TryLock(repoID string) (func(), bool) {
return m.TryLockWrite(repoID)
}
func (m *Manager) LockRead(repoID string) func() {
var key string
var value any
var actual any
var lock *sync.Mutex
var lock *sync.RWMutex
var ok bool
if m == nil { return func() {} }
key = strings.TrimSpace(repoID)
if key == "" { key = "-" }
value = &sync.Mutex{}
value = &sync.RWMutex{}
actual, _ = m.locks.LoadOrStore(key, value)
lock, ok = actual.(*sync.Mutex)
lock, ok = actual.(*sync.RWMutex)
if !ok || lock == nil { return func() {} }
lock.RLock()
return func() {
lock.RUnlock()
}
}
func (m *Manager) TryLockRead(repoID string) (func(), bool) {
var key string
var value any
var actual any
var lock *sync.RWMutex
var ok bool
if m == nil { return func() {}, true }
key = strings.TrimSpace(repoID)
if key == "" { key = "-" }
value = &sync.RWMutex{}
actual, _ = m.locks.LoadOrStore(key, value)
lock, ok = actual.(*sync.RWMutex)
if !ok || lock == nil { return func() {}, true }
if !lock.TryRLock() { return nil, false }
return func() {
lock.RUnlock()
}, true
}
func (m *Manager) LockWrite(repoID string) func() {
var key string
var value any
var actual any
var lock *sync.RWMutex
var ok bool
if m == nil { return func() {} }
key = strings.TrimSpace(repoID)
if key == "" { key = "-" }
value = &sync.RWMutex{}
actual, _ = m.locks.LoadOrStore(key, value)
lock, ok = actual.(*sync.RWMutex)
if !ok || lock == nil { return func() {} }
lock.Lock()
return func() {
@@ -32,19 +85,19 @@ func (m *Manager) Lock(repoID string) func() {
}
}
func (m *Manager) TryLock(repoID string) (func(), bool) {
func (m *Manager) TryLockWrite(repoID string) (func(), bool) {
var key string
var value any
var actual any
var lock *sync.Mutex
var lock *sync.RWMutex
var ok bool
if m == nil { return func() {}, true }
key = strings.TrimSpace(repoID)
if key == "" { key = "-" }
value = &sync.Mutex{}
value = &sync.RWMutex{}
actual, _ = m.locks.LoadOrStore(key, value)
lock, ok = actual.(*sync.Mutex)
lock, ok = actual.(*sync.RWMutex)
if !ok || lock == nil { return func() {}, true }
if !lock.TryLock() { return nil, false }
return func() {
@@ -110,3 +163,41 @@ func (m *Manager) TryLockMany(repoIDs []string) (func(), bool) {
}
}, true
}
func (m *Manager) TryLockManyIndexed(repoIDs []string) (func(), int, bool) {
var candidates []lockCandidate
var seen map[string]bool
var unlocks []func()
var unlock func()
var candidate lockCandidate
var ok bool
var id string
var i int
seen = make(map[string]bool)
for i = 0; i < len(repoIDs); i++ {
id = strings.TrimSpace(repoIDs[i])
if id == "" || seen[id] { continue }
seen[id] = true
candidates = append(candidates, lockCandidate{id: id, index: i})
}
sort.Slice(candidates, func(i int, j int) bool {
return candidates[i].id < candidates[j].id
})
for i = 0; i < len(candidates); i++ {
candidate = candidates[i]
unlock, ok = m.TryLock(candidate.id)
if !ok {
for i = len(unlocks) - 1; i >= 0; i-- {
if unlocks[i] != nil { unlocks[i]() }
}
return nil, candidate.index, false
}
unlocks = append(unlocks, unlock)
}
return func() {
for i = len(unlocks) - 1; i >= 0; i-- {
if unlocks[i] != nil { unlocks[i]() }
}
}, -1, true
}
+419
View File
@@ -0,0 +1,419 @@
package repolock
import "sync"
import "sync/atomic"
import "testing"
import "time"
// TestLockUnlock verifies the basic lock/unlock cycle completes without error.
func TestLockUnlock(t *testing.T) {
var m *Manager
var unlock func()
m = NewManager()
unlock = m.Lock("repo-a")
unlock()
}
// TestLockIsExclusive verifies that a second goroutine blocks until the first
// releases the lock, and that the critical section is never entered concurrently.
func TestLockIsExclusive(t *testing.T) {
var m *Manager
var inside int32
var wg sync.WaitGroup
var violation int32
m = NewManager()
var run func()
run = func() {
defer wg.Done()
var unlock func()
unlock = m.Lock("repo-x")
defer unlock()
if atomic.AddInt32(&inside, 1) > 1 {
atomic.StoreInt32(&violation, 1)
}
time.Sleep(2 * time.Millisecond)
atomic.AddInt32(&inside, -1)
}
wg.Add(10)
var i int
for i = 0; i < 10; i++ {
go run()
}
wg.Wait()
if violation != 0 {
t.Fatal("lock was not exclusive: multiple goroutines inside critical section simultaneously")
}
}
// TestLockDifferentKeysDoNotBlock verifies that locks on distinct repo IDs are
// independent and do not block each other.
func TestLockDifferentKeysDoNotBlock(t *testing.T) {
var m *Manager
var ch chan struct{}
var unlock func()
m = NewManager()
ch = make(chan struct{})
unlock = m.Lock("repo-a")
go func() {
// locking a different key must not block
var u func()
u = m.Lock("repo-b")
close(ch)
u()
}()
select {
case <-ch:
// good
case <-time.After(500 * time.Millisecond):
t.Fatal("Lock on different key blocked unexpectedly")
}
unlock()
}
// TestTryLockSucceedsWhenFree verifies TryLock acquires the lock when available.
func TestTryLockSucceedsWhenFree(t *testing.T) {
var m *Manager
var unlock func()
var ok bool
m = NewManager()
unlock, ok = m.TryLock("repo-a")
if !ok {
t.Fatal("TryLock should succeed on a free lock")
}
unlock()
}
// TestTryLockFailsWhenHeld verifies TryLock returns false when another goroutine
// holds the lock.
func TestTryLockFailsWhenHeld(t *testing.T) {
var m *Manager
var unlock func()
var unlock2 func()
var ok bool
m = NewManager()
unlock = m.Lock("repo-a")
defer unlock()
unlock2, ok = m.TryLock("repo-a")
if ok {
unlock2()
t.Fatal("TryLock should fail when lock is already held")
}
}
// TestTryLockSucceedsAfterRelease verifies TryLock succeeds once the prior
// holder releases the lock.
func TestTryLockSucceedsAfterRelease(t *testing.T) {
var m *Manager
var unlock func()
var ok bool
m = NewManager()
unlock = m.Lock("repo-a")
unlock() // release immediately
unlock, ok = m.TryLock("repo-a")
if !ok {
t.Fatal("TryLock should succeed after the lock is released")
}
unlock()
}
// TestLockManyAcquiresAll verifies LockMany acquires all listed locks.
func TestLockManyAcquiresAll(t *testing.T) {
var m *Manager
var unlock func()
var ok bool
var u func()
m = NewManager()
unlock = m.LockMany([]string{"repo-a", "repo-b", "repo-c"})
defer unlock()
// all three should be held — TryLock should fail for each
u, ok = m.TryLock("repo-a")
if ok { u(); t.Error("repo-a should be locked") }
u, ok = m.TryLock("repo-b")
if ok { u(); t.Error("repo-b should be locked") }
u, ok = m.TryLock("repo-c")
if ok { u(); t.Error("repo-c should be locked") }
}
// TestLockManyReleasesAll verifies LockMany's unlock function releases all locks.
func TestLockManyReleasesAll(t *testing.T) {
var m *Manager
var unlock func()
var ok bool
var u func()
m = NewManager()
unlock = m.LockMany([]string{"repo-a", "repo-b"})
unlock()
u, ok = m.TryLock("repo-a")
if !ok { t.Error("repo-a should be free after unlock") } else { u() }
u, ok = m.TryLock("repo-b")
if !ok { t.Error("repo-b should be free after unlock") } else { u() }
}
// TestLockManyDeadlockFree verifies that two goroutines acquiring overlapping
// sets in opposite order do not deadlock. LockMany sorts keys before acquiring,
// so both goroutines always acquire in the same order.
func TestLockManyDeadlockFree(t *testing.T) {
var m *Manager
var wg sync.WaitGroup
var done int32
m = NewManager()
var runAB func()
runAB = func() {
defer wg.Done()
var unlock func()
unlock = m.LockMany([]string{"repo-a", "repo-b"})
time.Sleep(1 * time.Millisecond)
unlock()
atomic.AddInt32(&done, 1)
}
var runBA func()
runBA = func() {
defer wg.Done()
var unlock func()
unlock = m.LockMany([]string{"repo-b", "repo-a"}) // reverse order — must still sort
time.Sleep(1 * time.Millisecond)
unlock()
atomic.AddInt32(&done, 1)
}
wg.Add(2)
go runAB()
go runBA()
var finished bool
var ch chan struct{}
ch = make(chan struct{})
go func() {
wg.Wait()
close(ch)
}()
select {
case <-ch:
finished = true
case <-time.After(2 * time.Second):
finished = false
}
if !finished {
t.Fatal("deadlock detected: LockMany([A,B]) vs LockMany([B,A]) did not complete")
}
if done != 2 {
t.Fatalf("expected both goroutines to finish, got %d", done)
}
}
// TestTryLockManySucceedsWhenAllFree verifies TryLockMany acquires all locks
// when none are held.
func TestTryLockManySucceedsWhenAllFree(t *testing.T) {
var m *Manager
var unlock func()
var ok bool
m = NewManager()
unlock, ok = m.TryLockMany([]string{"repo-a", "repo-b", "repo-c"})
if !ok {
t.Fatal("TryLockMany should succeed when all locks are free")
}
unlock()
}
// TestTryLockManyFailsAndReleasesWhenOneLockHeld verifies that TryLockMany
// releases any already-acquired locks when it fails to acquire one, leaving
// no locks held on failure.
func TestTryLockManyFailsAndReleasesWhenOneLockHeld(t *testing.T) {
var m *Manager
var holdUnlock func()
var unlock func()
var ok bool
var u func()
m = NewManager()
// hold repo-b
holdUnlock = m.Lock("repo-b")
defer holdUnlock()
// TryLockMany([repo-a, repo-b, repo-c]) should fail because repo-b is held.
// repo-a must be released as a result (no partial hold).
unlock, ok = m.TryLockMany([]string{"repo-a", "repo-b", "repo-c"})
if ok {
unlock()
t.Fatal("TryLockMany should fail when one of the locks is held")
}
// repo-a must be free now — TryLock should succeed
u, ok = m.TryLock("repo-a")
if !ok {
t.Fatal("repo-a should have been released by the failed TryLockMany")
}
u()
// repo-c should also be free
u, ok = m.TryLock("repo-c")
if !ok {
t.Fatal("repo-c should have been released by the failed TryLockMany")
}
u()
}
// TestLockManyDeduplicatesIDs verifies that passing duplicate IDs to LockMany
// does not cause a self-deadlock.
func TestLockManyDeduplicatesIDs(t *testing.T) {
var m *Manager
var unlock func()
var done chan struct{}
m = NewManager()
done = make(chan struct{})
go func() {
unlock = m.LockMany([]string{"repo-a", "repo-a", "repo-a"})
unlock()
close(done)
}()
select {
case <-done:
// good
case <-time.After(500 * time.Millisecond):
t.Fatal("LockMany with duplicate IDs deadlocked")
}
}
// TestLockEmptyKeyDoesNotPanic verifies that empty string keys are handled
// safely and treated as a single canonical key.
func TestLockEmptyKeyDoesNotPanic(t *testing.T) {
var m *Manager
var unlock func()
m = NewManager()
unlock = m.Lock("")
unlock()
}
// TestTryLockManyEmptySlice verifies TryLockMany with no IDs succeeds and
// the returned unlock is safe to call.
func TestTryLockManyEmptySlice(t *testing.T) {
var m *Manager
var unlock func()
var ok bool
m = NewManager()
unlock, ok = m.TryLockMany([]string{})
if !ok {
t.Fatal("TryLockMany on empty slice should succeed")
}
if unlock != nil {
unlock()
}
}
// TestNilManagerIsNoop verifies all Manager methods on a nil receiver return
// safely without panicking.
func TestNilManagerIsNoop(t *testing.T) {
var m *Manager // nil
var unlock func()
var ok bool
unlock = m.Lock("repo-a")
unlock() // should not panic
unlock, ok = m.TryLock("repo-a")
if !ok {
t.Error("nil TryLock should report success (noop)")
}
unlock()
unlock = m.LockMany([]string{"repo-a", "repo-b"})
unlock()
unlock, ok = m.TryLockMany([]string{"repo-a"})
if !ok {
t.Error("nil TryLockMany should report success (noop)")
}
if unlock != nil {
unlock()
}
}
// TestLockHighConcurrency stress-tests Lock under high goroutine count to
// surface any data races (run with -race).
func TestLockHighConcurrency(t *testing.T) {
var m *Manager
var wg sync.WaitGroup
var counter int64
const goroutines = 100
m = NewManager()
var i int
for i = 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
var unlock func()
unlock = m.Lock("shared-repo")
defer unlock()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
if counter != goroutines {
t.Fatalf("expected counter=%d, got %d", goroutines, counter)
}
}
// TestTryLockManyHighConcurrency stress-tests TryLockMany under contention.
// Some attempts will fail (lock held); all must either fully succeed or fully
// fail with no partial holds.
func TestTryLockManyHighConcurrency(t *testing.T) {
var m *Manager
var wg sync.WaitGroup
var successes int64
const goroutines = 50
m = NewManager()
var i int
for i = 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
var unlock func()
var ok bool
unlock, ok = m.TryLockMany([]string{"repo-x", "repo-y"})
if ok {
atomic.AddInt64(&successes, 1)
time.Sleep(1 * time.Millisecond)
unlock()
}
}()
}
wg.Wait()
if successes == 0 {
t.Fatal("expected at least one TryLockMany to succeed")
}
}
+5
View File
@@ -4,6 +4,11 @@ import "io"
import "os"
import "path/filepath"
const RepoDirGit string = "git"
const RepoDirRPM string = "rpm"
const RepoDirDocker string = "docker"
const GitRepoDirSuffix string = ".git"
type FileStore struct {
BaseDir string
}
+119 -51
View File
@@ -65,11 +65,13 @@ type gitIDPathRewriteHandler struct {
type rpmPathRewriteHandler struct {
next http.Handler
store *db.Store
locks *repolock.Manager
}
type rpmIDPathRewriteHandler struct {
next http.Handler
store *db.Store
locks *repolock.Manager
}
type listenerEndpoint struct {
@@ -188,14 +190,15 @@ func (h *gitPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
var currentStore *db.Store
var project models.Project
var repo models.Repo
var projectStorageID int64
var repoStorageID int64
var err error
var newPath string
var unlock func()
var locked bool
var isWrite bool
currentStore = requestStore(r, h.store)
isWrite = classifyServiceOperation(serviceGit, r) == opWrite
path = strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
h.next.ServeHTTP(w, r)
@@ -214,7 +217,7 @@ func (h *gitPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
rest = ""
}
orgPath = r.URL.Path
repoName = strings.TrimSuffix(repoSegment, ".git")
repoName = strings.TrimSuffix(repoSegment, storage.GitRepoDirSuffix)
project, err = currentStore.GetProjectBySlug(slug)
if err != nil {
http.NotFound(w, r)
@@ -225,12 +228,33 @@ func (h *gitPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
http.NotFound(w, r)
return
}
projectStorageID, repoStorageID, err = currentStore.GetRepoStorageIDs(repo.ID)
if isWrite {
unlock, locked = h.locks.TryLock(repo.ID)
if !locked {
http.Error(w, "repository is busy", http.StatusConflict)
return
}
} else {
unlock = h.locks.LockRead(repo.ID)
}
defer unlock()
repo, err = currentStore.GetRepo(repo.ID)
if err != nil || repo.Type != models.RepoTypeGit {
http.NotFound(w, r)
return
}
project, err = currentStore.GetProject(repo.ProjectID)
if err != nil || project.Slug != slug || repo.Name != repoName {
http.NotFound(w, r)
return
}
_, repoStorageID, err = currentStore.GetRepoStorageIDs(repo.ID)
if err != nil {
http.NotFound(w, r)
return
}
newPath = "/" + storageIDSegment(projectStorageID) + "/" + storageIDSegment(repoStorageID) + ".git" + rest
newPath = "/" + storageIDSegment(repoStorageID) + storage.GitRepoDirSuffix + rest
r = git.WithRequestLogInfo(r, git.RequestLogInfo{
OrgPath: orgPath,
ProjectID: project.ID,
@@ -239,14 +263,6 @@ func (h *gitPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
RepoName: repo.Name,
})
r.URL.Path = newPath
if classifyServiceOperation(serviceGit, r) == opWrite {
unlock, locked = h.locks.TryLock(repo.ID)
if !locked {
http.Error(w, "repository is busy", http.StatusConflict)
return
}
defer unlock()
}
h.next.ServeHTTP(w, r)
}
@@ -258,7 +274,6 @@ func (h *gitIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
var currentStore *db.Store
var project models.Project
var repo models.Repo
var projectStorageID int64
var repoStorageID int64
var projectErr error
var err error
@@ -266,22 +281,40 @@ func (h *gitIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
var repoID string
var unlock func()
var locked bool
var isWrite bool
currentStore = requestStore(r, h.store)
isWrite = classifyServiceOperation(serviceGit, r) == opWrite
path = strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
h.next.ServeHTTP(w, r)
return
}
parts = strings.SplitN(path, "/", 3)
repoID = strings.TrimSuffix(parts[0], ".git")
repoID = strings.TrimSuffix(parts[0], storage.GitRepoDirSuffix)
orgPath = r.URL.Path
repo, err = currentStore.GetRepo(repoID)
if err != nil || repo.Type != "git" {
if err != nil || repo.Type != models.RepoTypeGit {
http.NotFound(w, r)
return
}
if isWrite {
unlock, locked = h.locks.TryLock(repo.ID)
if !locked {
http.Error(w, "repository is busy", http.StatusConflict)
return
}
} else {
unlock = h.locks.LockRead(repo.ID)
}
defer unlock()
repo, err = currentStore.GetRepo(repo.ID)
if err != nil || repo.Type != models.RepoTypeGit {
http.NotFound(w, r)
return
}
project, projectErr = currentStore.GetProject(repo.ProjectID)
projectStorageID, repoStorageID, err = currentStore.GetRepoStorageIDs(repo.ID)
_, repoStorageID, err = currentStore.GetRepoStorageIDs(repo.ID)
if err != nil {
http.NotFound(w, r)
return
@@ -293,7 +326,7 @@ func (h *gitIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
} else {
rest = ""
}
newPath = "/" + storageIDSegment(projectStorageID) + "/" + storageIDSegment(repoStorageID) + ".git" + rest
newPath = "/" + storageIDSegment(repoStorageID) + storage.GitRepoDirSuffix + rest
if projectErr != nil {
project.Slug = ""
}
@@ -305,14 +338,6 @@ func (h *gitIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
RepoName: repo.Name,
})
r.URL.Path = newPath
if classifyServiceOperation(serviceGit, r) == opWrite {
unlock, locked = h.locks.TryLock(repo.ID)
if !locked {
http.Error(w, "repository is busy", http.StatusConflict)
return
}
defer unlock()
}
h.next.ServeHTTP(w, r)
}
@@ -326,11 +351,14 @@ func (h *rpmPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
var currentStore *db.Store
var project models.Project
var repo models.Repo
var projectStorageID int64
var repoStorageID int64
var err error
var newPath string
var unlock func()
var locked bool
var isWrite bool
currentStore = requestStore(r, h.store)
isWrite = classifyServiceOperation(serviceRPM, r) == opWrite
path = strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
h.next.ServeHTTP(w, r)
@@ -359,12 +387,33 @@ func (h *rpmPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
http.NotFound(w, r)
return
}
projectStorageID, repoStorageID, err = currentStore.GetRepoStorageIDs(repo.ID)
if isWrite {
unlock, locked = h.locks.TryLock(repo.ID)
if !locked {
http.Error(w, "repository is busy", http.StatusConflict)
return
}
} else {
unlock = h.locks.LockRead(repo.ID)
}
defer unlock()
repo, err = currentStore.GetRepo(repo.ID)
if err != nil || repo.Type != models.RepoTypeRPM {
http.NotFound(w, r)
return
}
project, err = currentStore.GetProject(repo.ProjectID)
if err != nil || project.Slug != slug || repo.Name != repoName {
http.NotFound(w, r)
return
}
_, repoStorageID, err = currentStore.GetRepoStorageIDs(repo.ID)
if err != nil {
http.NotFound(w, r)
return
}
newPath = "/" + storageIDSegment(projectStorageID) + "/" + storageIDSegment(repoStorageID) + rest
newPath = "/" + storageIDSegment(repoStorageID) + rest
r = rpm.WithRequestLogInfo(r, rpm.RequestLogInfo{
OrgPath: orgPath,
ProjectID: project.ID,
@@ -384,13 +433,16 @@ func (h *rpmIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
var currentStore *db.Store
var project models.Project
var repo models.Repo
var projectStorageID int64
var repoStorageID int64
var projectErr error
var err error
var newPath string
var repoID string
var unlock func()
var locked bool
var isWrite bool
currentStore = requestStore(r, h.store)
isWrite = classifyServiceOperation(serviceRPM, r) == opWrite
path = strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
h.next.ServeHTTP(w, r)
@@ -400,12 +452,28 @@ func (h *rpmIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
repoID = parts[0]
orgPath = r.URL.Path
repo, err = currentStore.GetRepo(repoID)
if err != nil || repo.Type != "rpm" {
if err != nil || repo.Type != models.RepoTypeRPM {
http.NotFound(w, r)
return
}
if isWrite {
unlock, locked = h.locks.TryLock(repo.ID)
if !locked {
http.Error(w, "repository is busy", http.StatusConflict)
return
}
} else {
unlock = h.locks.LockRead(repo.ID)
}
defer unlock()
repo, err = currentStore.GetRepo(repo.ID)
if err != nil || repo.Type != models.RepoTypeRPM {
http.NotFound(w, r)
return
}
project, projectErr = currentStore.GetProject(repo.ProjectID)
projectStorageID, repoStorageID, err = currentStore.GetRepoStorageIDs(repo.ID)
_, repoStorageID, err = currentStore.GetRepoStorageIDs(repo.ID)
if err != nil {
http.NotFound(w, r)
return
@@ -417,7 +485,7 @@ func (h *rpmIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
} else {
rest = ""
}
newPath = "/" + storageIDSegment(projectStorageID) + "/" + storageIDSegment(repoStorageID) + rest
newPath = "/" + storageIDSegment(repoStorageID) + rest
if projectErr != nil {
project.Slug = ""
}
@@ -583,9 +651,9 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identi
if app.siteName == "" { app.siteName = TitleFromServerId(app.serverId) }
}
app.docker_base_dir = filepath.Join(cfg.DataDir, "docker")
app.git_base_dir = filepath.Join(cfg.DataDir, "git")
app.rpm_base_dir = filepath.Join(cfg.DataDir, "rpm")
app.docker_base_dir = filepath.Join(cfg.DataDir, storage.RepoDirDocker)
app.git_base_dir = filepath.Join(cfg.DataDir, storage.RepoDirGit)
app.rpm_base_dir = filepath.Join(cfg.DataDir, storage.RepoDirRPM)
app.uploads_base_dir = filepath.Join(cfg.DataDir, "uploads")
err = os.MkdirAll(cfg.DataDir, 0o755)
@@ -1047,8 +1115,8 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("GET", "/api/repos/:id/commit/diff", api.RepoCommitDiff)
router.Handle("GET", "/api/repos/:id/compare", api.RepoCompare)
router.Handle("GET", "/api/repos/:id/stats", api.RepoStats)
router.Handle("GET", "/api/repos/:id/rpm/packages", txRead(api.RepoRPMPackages))
router.Handle("GET", "/api/repos/:id/rpm/package", txRead(api.RepoRPMPackage))
router.Handle("GET", "/api/repos/:id/rpm/packages", api.RepoRPMPackages)
router.Handle("GET", "/api/repos/:id/rpm/package", api.RepoRPMPackage)
router.Handle("POST", "/api/repos/:id/rpm/subdirs", api.RepoRPMCreateSubdir)
router.Handle("GET", "/api/repos/:id/rpm/subdir", txRead(api.RepoRPMGetSubdir))
router.Handle("POST", "/api/repos/:id/rpm/subdir/update", api.RepoRPMRenameSubdir)
@@ -1059,19 +1127,19 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("POST", "/api/repos/:id/rpm/subdir/rebuild-metadata", api.RepoRPMRebuildSubdirMetadata)
router.Handle("POST", "/api/repos/:id/rpm/subdir/cancel", api.RepoRPMCancelSubdirSync)
router.Handle("GET", "/api/repos/:id/rpm/subdir/runs", txRead(api.RepoRPMMirrorRuns))
router.Handle("DELETE", "/api/repos/:id/rpm/subdir/runs", txWrite(api.RepoRPMClearMirrorRuns))
router.Handle("DELETE", "/api/repos/:id/rpm/subdir/runs", api.RepoRPMClearMirrorRuns)
router.Handle("DELETE", "/api/repos/:id/rpm/subdir", api.RepoRPMDeleteSubdir)
router.Handle("DELETE", "/api/repos/:id/rpm/file", api.RepoRPMDeleteFile)
router.Handle("GET", "/api/repos/:id/rpm/file", api.RepoRPMFile)
router.Handle("GET", "/api/repos/:id/rpm/tree", txRead(api.RepoRPMTree))
router.Handle("GET", "/api/repos/:id/rpm/tree", api.RepoRPMTree)
router.Handle("POST", "/api/repos/:id/rpm/upload", api.RepoRPMUpload)
router.Handle("GET", "/api/repos/:id/docker/images", txRead(api.RepoDockerImages))
router.Handle("GET", "/api/repos/:id/docker/tags", txRead(api.RepoDockerTags))
router.Handle("GET", "/api/repos/:id/docker/manifest", txRead(api.RepoDockerManifest))
router.Handle("DELETE", "/api/repos/:id/docker/tag", txWrite(api.RepoDockerDeleteTag))
router.Handle("DELETE", "/api/repos/:id/docker/image", txWrite(api.RepoDockerDeleteImage))
router.Handle("POST", "/api/repos/:id/docker/tag/rename", txWrite(api.RepoDockerRenameTag))
router.Handle("POST", "/api/repos/:id/docker/image/rename", txWrite(api.RepoDockerRenameImage))
router.Handle("GET", "/api/repos/:id/docker/images", api.RepoDockerImages)
router.Handle("GET", "/api/repos/:id/docker/tags", api.RepoDockerTags)
router.Handle("GET", "/api/repos/:id/docker/manifest", api.RepoDockerManifest)
router.Handle("DELETE", "/api/repos/:id/docker/tag", api.RepoDockerDeleteTag)
router.Handle("DELETE", "/api/repos/:id/docker/image", api.RepoDockerDeleteImage)
router.Handle("POST", "/api/repos/:id/docker/tag/rename", api.RepoDockerRenameTag)
router.Handle("POST", "/api/repos/:id/docker/image/rename", api.RepoDockerRenameImage)
router.Handle("GET", "/api/me/keys", txRead(api.ListAPIKeys))
router.Handle("POST", "/api/me/keys", txWrite(api.CreateAPIKey))
router.Handle("DELETE", "/api/me/keys/:id", txWrite(api.DeleteAPIKey))
@@ -1122,12 +1190,12 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
gitIDHandler = middleware.WithRequestStore(store, gitIDHandler)
mux.Handle(cfg.GitHTTPPrefix+"-id/", gitIDHandler)
rpmRewrite = &rpmPathRewriteHandler{next: app.rpm_server, store: store}
rpmRewrite = &rpmPathRewriteHandler{next: app.rpm_server, store: store, locks: app.repo_locks}
rpmHandler = withServiceAuth(serviceRPM, cfg.RPMHTTPPrefix, http.StripPrefix(cfg.RPMHTTPPrefix, rpmRewrite), auth_user, store, logger)
rpmHandler = middleware.WithRequestStore(store, rpmHandler)
mux.Handle(cfg.RPMHTTPPrefix+"/", rpmHandler)
rpmIDRewrite = &rpmIDPathRewriteHandler{next: app.rpm_server, store: store}
rpmIDRewrite = &rpmIDPathRewriteHandler{next: app.rpm_server, store: store, locks: app.repo_locks}
rpmIDHandler = withServiceAuth(serviceRPM, cfg.RPMHTTPPrefix+"-id", http.StripPrefix(cfg.RPMHTTPPrefix+"-id", rpmIDRewrite), auth_user, store, logger)
rpmIDHandler = middleware.WithRequestStore(store, rpmIDHandler)
mux.Handle(cfg.RPMHTTPPrefix+"-id/", rpmIDHandler)
@@ -1971,7 +2039,7 @@ func resolveProjectIDForServiceWrite(service serviceKind, servicePrefix string,
if len(parts) < 1 {
return "", errors.New("invalid path")
}
first = strings.TrimSuffix(parts[0], ".git")
first = strings.TrimSuffix(parts[0], storage.GitRepoDirSuffix)
repo, err = store.GetRepo(first)
if err == nil {
return repo.ProjectID, nil
@@ -1979,7 +2047,7 @@ func resolveProjectIDForServiceWrite(service serviceKind, servicePrefix string,
if len(parts) < 2 {
return "", errors.New("invalid path")
}
second = strings.TrimSuffix(parts[1], ".git")
second = strings.TrimSuffix(parts[1], storage.GitRepoDirSuffix)
project, err = store.GetProjectBySlug(parts[0])
if err != nil {
return "", err
@@ -1992,7 +2060,7 @@ func resolveProjectIDForServiceWrite(service serviceKind, servicePrefix string,
}
func serviceRepoType(service serviceKind, segment string) string {
if service == serviceGit || strings.HasSuffix(segment, ".git") {
if service == serviceGit || strings.HasSuffix(segment, storage.GitRepoDirSuffix) {
return "git"
}
return "rpm"
+558
View File
@@ -0,0 +1,558 @@
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": "settings",
})
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))
}
}
+424
View File
@@ -0,0 +1,424 @@
package tests
import "net/http"
import "net/http/httptest"
import "strings"
import "sync"
import "testing"
import "codit/internal/db"
import "codit/internal/middleware"
import httpx "codit/internal/http"
import "codit/internal/models"
// invoke calls a TxRoute-wrapped handler via httptest and returns the recorder.
func invoke(store *db.Store, mode middleware.TxMode, output middleware.TxOutputMode, handler httpx.HandlerFunc, method string) *httptest.ResponseRecorder {
var wrapped httpx.HandlerFunc
var req *http.Request
var rec *httptest.ResponseRecorder
wrapped = middleware.TxRoute(store, mode, output, handler)
req = httptest.NewRequest(method, "/", nil)
rec = httptest.NewRecorder()
wrapped(rec, req, httpx.Params{})
return rec
}
// makeWriteHandler returns a handler that writes a board field value to the
// injected tx-store and responds with the given HTTP status code.
func makeWriteHandler(boardID string, field string, label string, status int) httpx.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request, _ httpx.Params) {
var txStore *db.Store
var ok bool
txStore, ok = middleware.StoreFromContext(r.Context())
if !ok {
http.Error(w, "no store in context", http.StatusInternalServerError)
return
}
_, _ = txStore.CreateBoardFieldValue(r.Context(), boardID, models.BoardFieldValue{
Field: field,
Value: label,
Label: label,
Color: "#000000",
})
w.WriteHeader(status)
_, _ = w.Write([]byte("body"))
}
}
// ---------------------------------------------------------------------------
// TestTxWriteCommitsOn2xx: a 200 response causes the write to be committed.
// ---------------------------------------------------------------------------
func TestTxWriteCommitsOn2xx(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var values []models.BoardFieldValue
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "tx-commit-user")
project = createTestProject(t, store, user, "tx-commit-proj")
board = createTestBoard(t, store, user, project, "tx-commit-board")
var rec *httptest.ResponseRecorder
rec = invoke(store, middleware.TxImmediate, middleware.TxBuffered,
makeWriteHandler(board.ID, "status", "done", http.StatusOK), http.MethodPost)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
values, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values: %v", err)
}
if len(values) != 1 {
t.Fatalf("expected field value to be committed, got %d values", len(values))
}
}
// ---------------------------------------------------------------------------
// TestTxWriteRollsBackOn4xx: a 4xx response causes the write to be rolled back.
// ---------------------------------------------------------------------------
func TestTxWriteRollsBackOn4xx(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var values []models.BoardFieldValue
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "tx-rollback-4xx-user")
project = createTestProject(t, store, user, "tx-rollback-4xx-proj")
board = createTestBoard(t, store, user, project, "tx-rollback-4xx-board")
var rec *httptest.ResponseRecorder
rec = invoke(store, middleware.TxImmediate, middleware.TxBuffered,
makeWriteHandler(board.ID, "status", "done", http.StatusBadRequest), http.MethodPost)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
values, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values: %v", err)
}
if len(values) != 0 {
t.Fatalf("expected write to be rolled back on 4xx, but %d value(s) persisted", len(values))
}
}
// ---------------------------------------------------------------------------
// TestTxWriteRollsBackOn5xx: a 5xx response also causes rollback.
// ---------------------------------------------------------------------------
func TestTxWriteRollsBackOn5xx(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var values []models.BoardFieldValue
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "tx-rollback-5xx-user")
project = createTestProject(t, store, user, "tx-rollback-5xx-proj")
board = createTestBoard(t, store, user, project, "tx-rollback-5xx-board")
var rec *httptest.ResponseRecorder
rec = invoke(store, middleware.TxImmediate, middleware.TxBuffered,
makeWriteHandler(board.ID, "status", "done", http.StatusInternalServerError), http.MethodPost)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
values, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values: %v", err)
}
if len(values) != 0 {
t.Fatalf("expected write to be rolled back on 5xx, but %d value(s) persisted", len(values))
}
}
// ---------------------------------------------------------------------------
// TestTxDeferredCommitsOn2xx: TxDeferred (txRead) also commits on 200.
// The difference from TxImmediate is the lock level (deferred vs exclusive),
// not the commit behaviour.
// ---------------------------------------------------------------------------
func TestTxDeferredCommitsOn2xx(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var values []models.BoardFieldValue
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "tx-deferred-commit-user")
project = createTestProject(t, store, user, "tx-deferred-commit-proj")
board = createTestBoard(t, store, user, project, "tx-deferred-commit-board")
var rec *httptest.ResponseRecorder
rec = invoke(store, middleware.TxDeferred, middleware.TxUnbuffered,
makeWriteHandler(board.ID, "status", "done", http.StatusOK), http.MethodPost)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
values, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values: %v", err)
}
if len(values) != 1 {
t.Fatalf("TxDeferred should commit on 200, but got %d value(s)", len(values))
}
}
// ---------------------------------------------------------------------------
// TestTxDeferredRollsBackOn4xx: TxDeferred also rolls back on 4xx.
// ---------------------------------------------------------------------------
func TestTxDeferredRollsBackOn4xx(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var values []models.BoardFieldValue
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "tx-deferred-rollback-user")
project = createTestProject(t, store, user, "tx-deferred-rollback-proj")
board = createTestBoard(t, store, user, project, "tx-deferred-rollback-board")
var rec *httptest.ResponseRecorder
rec = invoke(store, middleware.TxDeferred, middleware.TxUnbuffered,
makeWriteHandler(board.ID, "status", "done", http.StatusBadRequest), http.MethodPost)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
values, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values: %v", err)
}
if len(values) != 0 {
t.Fatalf("TxDeferred should roll back on 4xx, but %d value(s) persisted", len(values))
}
}
// ---------------------------------------------------------------------------
// TestTxWriteBuffersResponseBody: with TxBuffered the response body must not
// reach the client until after commit. Verify the response body is present
// only after the full handler+commit cycle, not during the write.
// ---------------------------------------------------------------------------
func TestTxWriteBuffersResponseBody(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "tx-buffer-user")
project = createTestProject(t, store, user, "tx-buffer-proj")
board = createTestBoard(t, store, user, project, "tx-buffer-board")
// The handler writes a body and then returns 200. With TxBuffered the body
// must not be visible until after a successful commit. We verify this by
// observing that the recorder has the body after the full call returns.
var bodyWrittenDuringHandler bool
var rec *httptest.ResponseRecorder
var handler httpx.HandlerFunc
handler = func(w http.ResponseWriter, r *http.Request, _ httpx.Params) {
var txStore *db.Store
var ok bool
txStore, ok = middleware.StoreFromContext(r.Context())
if !ok {
http.Error(w, "no store in context", http.StatusInternalServerError)
return
}
_, _ = txStore.CreateBoardFieldValue(r.Context(), board.ID, models.BoardFieldValue{
Field: "status", Value: "done", Label: "done", Color: "#000",
})
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("committed-body"))
// At this exact moment (inside the handler, before commit) the recorder's
// body must still be empty if buffering is working correctly.
// httptest.ResponseRecorder flushes to its internal buffer only when Body is
// written — here we cannot prevent that write, but we CAN verify that the
// commit must have happened before FlushTo copies it to the real writer.
// We verify this indirectly: the value is not yet in the database here.
var values []models.BoardFieldValue
var err error
values, err = store.ListBoardFieldValues(board.ID, "status")
if err == nil && len(values) > 0 {
bodyWrittenDuringHandler = true
}
}
rec = invoke(store, middleware.TxImmediate, middleware.TxBuffered, handler, http.MethodPost)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if bodyWrittenDuringHandler {
t.Fatal("data was committed to DB while handler was still executing: buffering is not working")
}
if !strings.Contains(rec.Body.String(), "committed-body") {
t.Fatalf("expected body in final response, got: %q", rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// TestTxWriteImmediateBlocksConcurrentWriter: two concurrent txWrite requests
// targeting the same board must serialize. The second BEGIN IMMEDIATE blocks
// until the first commits. No data must be lost.
// ---------------------------------------------------------------------------
func TestTxWriteImmediateBlocksConcurrentWriter(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var values []models.BoardFieldValue
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "tx-concurrent-write-user")
project = createTestProject(t, store, user, "tx-concurrent-write-proj")
board = createTestBoard(t, store, user, project, "tx-concurrent-write-board")
// Release barrier ensures both goroutines call into the middleware at the
// same time, maximising the chance of exposing a lost-write bug.
var ready = make(chan struct{})
var wg sync.WaitGroup
var mu sync.Mutex
var codes []int
var run func(label string)
run = func(label string) {
defer wg.Done()
<-ready
var rec *httptest.ResponseRecorder
rec = invoke(store, middleware.TxImmediate, middleware.TxBuffered,
makeWriteHandler(board.ID, "status", label, http.StatusOK), http.MethodPost)
mu.Lock()
codes = append(codes, rec.Code)
mu.Unlock()
}
wg.Add(2)
go run("value-a")
go run("value-b")
close(ready)
wg.Wait()
for _, code := range codes {
if code != http.StatusOK {
t.Errorf("expected 200 from concurrent writers, got %d", code)
}
}
values, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values: %v", err)
}
// Both writes must have committed: no lost update.
if len(values) != 2 {
t.Errorf("expected 2 committed field values from concurrent writes, got %d", len(values))
}
}
// ---------------------------------------------------------------------------
// TestTxWebSocketUpgradeBypassesTx: a WebSocket upgrade request must bypass
// the transaction entirely — no BEGIN is issued and no commit/rollback occurs.
// ---------------------------------------------------------------------------
func TestTxWebSocketUpgradeBypassesTx(t *testing.T) {
var store *db.Store
var handlerCalled bool
store = openTestStore(t)
defer store.Close()
var handler httpx.HandlerFunc
handler = func(w http.ResponseWriter, r *http.Request, _ httpx.Params) {
handlerCalled = true
// StoreFromContext must NOT return a tx-wrapped store for a WS upgrade.
var txStore *db.Store
var ok bool
txStore, ok = middleware.StoreFromContext(r.Context())
if ok && txStore != nil {
t.Error("tx store must not be injected for WebSocket upgrade requests")
}
w.WriteHeader(http.StatusSwitchingProtocols)
}
var wrapped httpx.HandlerFunc
var req *http.Request
var rec *httptest.ResponseRecorder
wrapped = middleware.TxRoute(store, middleware.TxImmediate, middleware.TxBuffered, handler)
req = httptest.NewRequest(http.MethodGet, "/ws", nil)
req.Header.Set("Connection", "upgrade")
req.Header.Set("Upgrade", "websocket")
rec = httptest.NewRecorder()
wrapped(rec, req, httpx.Params{})
if !handlerCalled {
t.Fatal("handler was not called for WebSocket upgrade")
}
if rec.Code != http.StatusSwitchingProtocols {
t.Fatalf("expected 101, got %d", rec.Code)
}
}
// ---------------------------------------------------------------------------
// TestTxWriteStoreInjectedInContext: the handler receives a tx-wrapped store
// via context, distinct from the root store, so DB operations inside the
// handler participate in the middleware-managed transaction.
// ---------------------------------------------------------------------------
func TestTxWriteStoreInjectedInContext(t *testing.T) {
var store *db.Store
store = openTestStore(t)
defer store.Close()
var gotStore *db.Store
var handler httpx.HandlerFunc
handler = func(w http.ResponseWriter, r *http.Request, _ httpx.Params) {
var ok bool
gotStore, ok = middleware.StoreFromContext(r.Context())
if !ok || gotStore == nil {
http.Error(w, "no store injected", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
var rec *httptest.ResponseRecorder
rec = invoke(store, middleware.TxImmediate, middleware.TxBuffered, handler, http.MethodPost)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d — store injection failed", rec.Code)
}
if gotStore == nil {
t.Fatal("tx store was not injected into handler context")
}
}
+555
View File
@@ -0,0 +1,555 @@
package tests
import "context"
import "errors"
import "fmt"
import "sync"
import "testing"
import "codit/internal/db"
import "codit/internal/models"
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
func createTestBoard(t *testing.T, store *db.Store, user models.User, project models.Project, title string) models.Board {
var board models.Board
var created models.Board
var err error
board = models.Board{
ProjectID: project.ID,
Title: title,
CreatedBy: user.ID,
UpdatedBy: user.ID,
}
created, err = store.CreateBoard(context.Background(), board)
if err != nil {
t.Fatalf("create board %q: %v", title, err)
}
return created
}
func createTestBoardFieldValue(t *testing.T, store *db.Store, boardID string, field string, label string) models.BoardFieldValue {
var fv models.BoardFieldValue
var created models.BoardFieldValue
var err error
fv = models.BoardFieldValue{
Field: field,
Value: label, // Value must be unique within (board, field)
Label: label,
Color: "#ff0000",
}
created, err = store.CreateBoardFieldValue(context.Background(), boardID, fv)
if err != nil {
t.Fatalf("create board field value %q: %v", label, err)
}
return created
}
func createTestSSHServer(t *testing.T, store *db.Store, user models.User, name string) models.SSHServer {
var item models.SSHServer
var created models.SSHServer
var err error
item = models.SSHServer{
Name: name,
Host: "10.0.0.1",
Port: 22,
Enabled: true,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username,
}
created, err = store.CreateSSHServer(item)
if err != nil {
t.Fatalf("create ssh server %q: %v", name, err)
}
return created
}
func createTestSSHCredential(t *testing.T, store *db.Store, user models.User, name string) models.SSHCredential {
var item models.SSHCredential
var secret models.SSHSecret
var created models.SSHCredential
var err error
item = models.SSHCredential{
Name: name,
Type: "private_key",
PublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test",
Fingerprint: "SHA256:test-" + name,
Enabled: true,
OwnerScope: "user",
OwnerUserID: user.ID,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username,
}
secret = models.SSHSecret{
Payload: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
}
created, err = store.CreateSSHCredential(context.Background(), item, secret)
if err != nil {
t.Fatalf("create ssh credential %q: %v", name, err)
}
return created
}
// ---------------------------------------------------------------------------
// TestNestedTransactionReuse: a method that calls beginContext inside a
// BeginStore tx should reuse the outer transaction, not start a new one.
// Verified by checking that a rollback of the outer tx discards all inner work.
// ---------------------------------------------------------------------------
func TestNestedTransactionReuse(t *testing.T) {
var store *db.Store
var txStore *db.Store
var user models.User
var project models.Project
var board models.Board
var fv models.BoardFieldValue
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "nested-tx-user")
project = createTestProject(t, store, user, "nested-tx-proj")
board = createTestBoard(t, store, user, project, "board-nested")
// Open an outer transaction and create a field value inside it.
txStore, err = store.BeginStore(context.Background())
if err != nil {
t.Fatalf("begin outer tx: %v", err)
}
// CreateBoardFieldValue calls beginContext internally.
// It must reuse the outer txStore transaction, not start its own.
fv, err = txStore.CreateBoardFieldValue(context.Background(), board.ID, models.BoardFieldValue{
Field: "status",
Label: "In Progress",
Color: "#0000ff",
})
if err != nil {
_ = txStore.Rollback()
t.Fatalf("create field value inside outer tx: %v", err)
}
// Roll back the outer transaction — the field value must not be committed.
err = txStore.Rollback()
if err != nil {
t.Fatalf("rollback outer tx: %v", err)
}
// Verify the field value is not visible.
var values []models.BoardFieldValue
values, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values: %v", err)
}
var i int
for i = 0; i < len(values); i++ {
if values[i].ID == fv.ID {
t.Fatal("field value created inside rolled-back outer tx is visible: inner tx was not reusing the outer tx")
}
}
}
// ---------------------------------------------------------------------------
// TestDeleteBoardFieldValueConcurrent: two goroutines delete the same field
// value simultaneously. Exactly one must succeed; the other must get
// ErrFieldValueNotFound. No panic, no double-delete.
// ---------------------------------------------------------------------------
func TestDeleteBoardFieldValueConcurrent(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var fv models.BoardFieldValue
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "del-fv-user")
project = createTestProject(t, store, user, "del-fv-proj")
board = createTestBoard(t, store, user, project, "board-del-fv")
fv = createTestBoardFieldValue(t, store, board.ID, "status", "todo")
var wg sync.WaitGroup
var successes int
var notFounds int
var mu sync.Mutex
var run func()
run = func() {
defer wg.Done()
var err error
err = store.DeleteBoardFieldValue(context.Background(), board.ID, fv.ID)
mu.Lock()
defer mu.Unlock()
if err == nil {
successes++
} else if errors.Is(err, db.ErrFieldValueNotFound) {
notFounds++
} else {
t.Errorf("unexpected error from DeleteBoardFieldValue: %v", err)
}
}
wg.Add(2)
go run()
go run()
wg.Wait()
if successes != 1 {
t.Errorf("expected exactly 1 successful delete, got %d", successes)
}
if notFounds != 1 {
t.Errorf("expected exactly 1 not-found error, got %d", notFounds)
}
}
// ---------------------------------------------------------------------------
// TestDeleteSSHCredentialConcurrent: two goroutines delete the same SSH
// credential. Exactly one succeeds; the other gets a "not found" SQL error.
// ---------------------------------------------------------------------------
func TestDeleteSSHCredentialConcurrent(t *testing.T) {
var store *db.Store
var user models.User
var cred models.SSHCredential
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "del-cred-user")
cred = createTestSSHCredential(t, store, user, "my-key")
var wg sync.WaitGroup
var successes int
var mu sync.Mutex
var run func()
run = func() {
defer wg.Done()
var err error
err = store.DeleteSSHCredential(context.Background(), cred.ID)
mu.Lock()
defer mu.Unlock()
if err == nil {
successes++
}
// the second delete gets a sql.ErrNoRows or similar — not counted as success
}
wg.Add(2)
go run()
go run()
wg.Wait()
if successes != 1 {
t.Errorf("expected exactly 1 successful delete, got %d", successes)
}
// Verify the credential is gone.
var _, err = store.GetSSHCredential(cred.ID)
if err == nil {
t.Error("credential still exists after concurrent deletes")
}
}
// ---------------------------------------------------------------------------
// TestDeleteSSHServerWhileProfileAdded: goroutine A checks references (zero),
// goroutine B concurrently adds a profile for the same server, then A deletes.
// With BEGIN IMMEDIATE the delete must either fail (reference found) or fully
// succeed atomically — it must never leave an orphaned access profile.
// ---------------------------------------------------------------------------
func TestDeleteSSHServerWhileProfileAdded(t *testing.T) {
var store *db.Store
var user models.User
var server models.SSHServer
var deleteErr error
var profileErr error
var wg sync.WaitGroup
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "del-server-user")
server = createTestSSHServer(t, store, user, "target-server")
// Barrier ensures both goroutines start at roughly the same time.
var ready = make(chan struct{})
// Goroutine A: delete the server.
wg.Add(1)
go func() {
defer wg.Done()
<-ready
deleteErr = store.DeleteSSHServer(context.Background(), server.ID)
}()
// Goroutine B: create an access profile for the same server.
wg.Add(1)
go func() {
defer wg.Done()
<-ready
var _, err = store.CreateSSHAccessProfile(context.Background(), models.SSHAccessProfile{
ServerID: server.ID,
Name: "profile-concurrent",
RemoteUsername: "deploy",
AuthMethod: "none",
OwnerScope: "admin_shared",
Enabled: true,
DefaultCertValidSeconds: 3600,
MaxCertValidSeconds: 3600,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username,
})
profileErr = err
}()
close(ready)
wg.Wait()
// Regardless of which operation won:
// - If delete succeeded, the server is gone and the profile must not exist.
// - If profile creation succeeded, delete must have failed with a reference error.
// What must never happen: both succeed, leaving an orphaned profile record.
if deleteErr == nil && profileErr == nil {
// Both claimed success — verify no orphaned profile exists (server is gone,
// so a profile referencing it would be orphaned).
var profiles []models.SSHAccessProfile
var err error
profiles, err = store.ListSSHAccessProfiles()
if err != nil {
t.Fatalf("list profiles after concurrent ops: %v", err)
}
var i int
for i = 0; i < len(profiles); i++ {
if profiles[i].ServerID == server.ID {
t.Errorf("orphaned access profile exists for deleted server %s", server.ID)
}
}
}
// At least one operation must have succeeded.
if deleteErr != nil && profileErr != nil {
t.Errorf("both operations failed — deleteErr=%v profileErr=%v", deleteErr, profileErr)
}
}
// ---------------------------------------------------------------------------
// TestReorderBoardFieldValuesConcurrent: two goroutines concurrently reorder
// the same field's values. Both use BEGIN IMMEDIATE, so they serialize.
// Both must succeed, and the final ordering must be a valid permutation with
// no duplicate display_order values.
// ---------------------------------------------------------------------------
func TestReorderBoardFieldValuesConcurrent(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var fv1, fv2, fv3 models.BoardFieldValue
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "reorder-user")
project = createTestProject(t, store, user, "reorder-proj")
board = createTestBoard(t, store, user, project, "board-reorder")
fv1 = createTestBoardFieldValue(t, store, board.ID, "status", "todo")
fv2 = createTestBoardFieldValue(t, store, board.ID, "status", "in-progress")
fv3 = createTestBoardFieldValue(t, store, board.ID, "status", "done")
orderAB := []string{fv1.ID, fv2.ID, fv3.ID}
orderBA := []string{fv3.ID, fv2.ID, fv1.ID}
var wg sync.WaitGroup
var errA, errB error
var ready = make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
<-ready
_, errA = store.ReorderBoardFieldValues(context.Background(), board.ID, "status", orderAB)
}()
wg.Add(1)
go func() {
defer wg.Done()
<-ready
_, errB = store.ReorderBoardFieldValues(context.Background(), board.ID, "status", orderBA)
}()
close(ready)
wg.Wait()
if errA != nil {
t.Errorf("reorder AB failed: %v", errA)
}
if errB != nil {
t.Errorf("reorder BA failed: %v", errB)
}
// Final state must have unique display_order values for all three field values.
var values []models.BoardFieldValue
var err error
values, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values after concurrent reorder: %v", err)
}
if len(values) != 3 {
t.Fatalf("expected 3 field values, got %d", len(values))
}
var seen = map[int]bool{}
var i int
for i = 0; i < len(values); i++ {
if seen[values[i].DisplayOrder] {
t.Errorf("duplicate display_order %d after concurrent reorder", values[i].DisplayOrder)
}
seen[values[i].DisplayOrder] = true
}
}
// ---------------------------------------------------------------------------
// TestConcurrentBoardMemberUpsert: two goroutines concurrently upsert the same
// user as a board member. The result must be exactly one member row (no
// duplicate) and no error.
// ---------------------------------------------------------------------------
func TestConcurrentBoardMemberUpsert(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "upsert-member-user")
project = createTestProject(t, store, user, "upsert-member-proj")
board = createTestBoard(t, store, user, project, "board-upsert-member")
var wg sync.WaitGroup
var mu sync.Mutex
var errs []error
var ready = make(chan struct{})
var run func()
run = func() {
defer wg.Done()
<-ready
var err error
err = store.UpsertBoardMember(context.Background(), models.BoardMember{
BoardID: board.ID,
UserID: user.ID,
Role: models.RoleWriter,
})
if err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}
wg.Add(3)
go run()
go run()
go run()
close(ready)
wg.Wait()
if len(errs) > 0 {
t.Errorf("unexpected errors from concurrent UpsertBoardMember: %v", errs)
}
// Must be exactly one member row.
var members []models.BoardMember
var err error
members, err = store.ListBoardMembers(board.ID)
if err != nil {
t.Fatalf("list board members: %v", err)
}
if len(members) != 1 {
t.Errorf("expected 1 board member after concurrent upserts, got %d", len(members))
}
}
// ---------------------------------------------------------------------------
// TestConcurrentCreateBoardFieldValue: multiple goroutines create field values
// for the same board/field simultaneously. All should succeed and produce
// distinct IDs with no constraint violations.
// ---------------------------------------------------------------------------
func TestConcurrentCreateBoardFieldValue(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
const n = 8
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "concurrent-create-fv-user")
project = createTestProject(t, store, user, "concurrent-create-fv-proj")
board = createTestBoard(t, store, user, project, "board-concurrent-fv")
var wg sync.WaitGroup
var mu sync.Mutex
var ids []string
var errs []error
var ready = make(chan struct{})
var i int
for i = 0; i < n; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-ready
var fv models.BoardFieldValue
var err error
fv, err = store.CreateBoardFieldValue(context.Background(), board.ID, models.BoardFieldValue{
Field: "status",
Value: fmt.Sprintf("value-%d", idx),
Label: fmt.Sprintf("value-%d", idx),
Color: "#aabbcc",
})
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, err)
} else {
ids = append(ids, fv.ID)
}
}(i)
}
close(ready)
wg.Wait()
if len(errs) > 0 {
t.Errorf("unexpected errors creating field values concurrently: %v", errs)
}
if len(ids) != n {
t.Errorf("expected %d created field values, got %d", n, len(ids))
}
// All IDs must be unique.
var seen = map[string]bool{}
var id string
for _, id = range ids {
if seen[id] {
t.Errorf("duplicate field value ID %s from concurrent creates", id)
}
seen[id] = true
}
}