Compare commits
6 Commits
2f12f5c00a
...
3d333dd506
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d333dd506 | |||
| b180ef1893 | |||
| 05a4af70b5 | |||
| d5b8349377 | |||
| 12861d1b63 | |||
| dc2d501a13 |
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
+328
-343
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user