Compare commits
6 Commits
2f12f5c00a
...
3d333dd506
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d333dd506 | |||
| b180ef1893 | |||
| 05a4af70b5 | |||
| d5b8349377 | |||
| 12861d1b63 | |||
| dc2d501a13 |
@@ -10,6 +10,7 @@ import "strconv"
|
|||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
import storedb "codit/internal/db"
|
import storedb "codit/internal/db"
|
||||||
|
import "codit/internal/storage"
|
||||||
import _ "modernc.org/sqlite"
|
import _ "modernc.org/sqlite"
|
||||||
import "github.com/gdamore/tcell/v2"
|
import "github.com/gdamore/tcell/v2"
|
||||||
import "github.com/rivo/tview"
|
import "github.com/rivo/tview"
|
||||||
@@ -415,9 +416,7 @@ func (b *browser) resolvePathHint(name string) string {
|
|||||||
var rel string
|
var rel string
|
||||||
var parts []string
|
var parts []string
|
||||||
var service string
|
var service string
|
||||||
var projectID int64
|
|
||||||
var repoID int64
|
var repoID int64
|
||||||
var project projectInfo
|
|
||||||
var repo repoInfo
|
var repo repoInfo
|
||||||
var err error
|
var err error
|
||||||
rel, err = filepath.Rel(b.DataDir, filepath.Join(b.Cwd, name))
|
rel, err = filepath.Rel(b.DataDir, filepath.Join(b.Cwd, name))
|
||||||
@@ -429,20 +428,10 @@ func (b *browser) resolvePathHint(name string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
service = parts[0]
|
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" {
|
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 {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -468,7 +457,7 @@ func (b *browser) countMismatches() int {
|
|||||||
count = 0
|
count = 0
|
||||||
for _, repoID = range ids {
|
for _, repoID = range ids {
|
||||||
repo = b.ReposByID[repoID]
|
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)
|
_, err = os.Stat(expected)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
count = count + 1
|
count = count + 1
|
||||||
@@ -512,7 +501,7 @@ func (b *browser) collectMismatchLines() []string {
|
|||||||
lines = []string{}
|
lines = []string{}
|
||||||
for _, repoID = range ids {
|
for _, repoID = range ids {
|
||||||
repo = b.ReposByID[repoID]
|
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)
|
_, err = os.Stat(expected)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
continue
|
continue
|
||||||
@@ -529,19 +518,17 @@ func (b *browser) collectMismatchLines() []string {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
func expectedRepoPath(dataDir string, repoType string, projectID int64, repoID int64) string {
|
func expectedRepoPath(dataDir string, repoType string, repoID int64) string {
|
||||||
var p string
|
|
||||||
var r string
|
var r string
|
||||||
p = formatStorageIDSegment(projectID)
|
|
||||||
r = formatStorageIDSegment(repoID)
|
r = formatStorageIDSegment(repoID)
|
||||||
if repoType == "git" {
|
if repoType == "git" {
|
||||||
return filepath.Join(dataDir, "git", p, r+".git")
|
return filepath.Join(dataDir, storage.RepoDirGit, r+storage.GitRepoDirSuffix)
|
||||||
}
|
}
|
||||||
if repoType == "rpm" {
|
if repoType == "rpm" {
|
||||||
return filepath.Join(dataDir, "rpm", p, r)
|
return filepath.Join(dataDir, storage.RepoDirRPM, r)
|
||||||
}
|
}
|
||||||
if repoType == "docker" {
|
if repoType == "docker" {
|
||||||
return filepath.Join(dataDir, "docker", p, r)
|
return filepath.Join(dataDir, storage.RepoDirDocker, r)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -481,14 +481,13 @@ func (s *Store) DeleteRPMMirrorRuns(repoID string, path string) (int64, error) {
|
|||||||
var res sql.Result
|
var res sql.Result
|
||||||
var count int64
|
var count int64
|
||||||
var err error
|
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)
|
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 {
|
if err != nil { return 0, err }
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
count, err = res.RowsAffected()
|
count, err = res.RowsAffected()
|
||||||
if err != nil {
|
if err != nil { return 0, err }
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ func (s *HTTPServer) handle(w http.ResponseWriter, r *http.Request) *http.Reques
|
|||||||
var unlock func()
|
var unlock func()
|
||||||
var locked bool
|
var locked bool
|
||||||
var err error
|
var err error
|
||||||
|
var lockedRepo models.Repo
|
||||||
|
var lockedProject models.Project
|
||||||
|
var lockedImageName string
|
||||||
path = r.URL.Path
|
path = r.URL.Path
|
||||||
store = s.requestStore(r)
|
store = s.requestStore(r)
|
||||||
if path == s.prefix || path == s.prefix + "/" {
|
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)
|
w.WriteHeader(http.StatusConflict)
|
||||||
return r
|
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{
|
r = WithRequestLogInfo(r, RequestLogInfo{
|
||||||
OrgPath: path,
|
OrgPath: path,
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
@@ -171,6 +184,12 @@ func (s *HTTPServer) tryLockRepo(repoID string) (func(), bool) {
|
|||||||
return s.locks.TryLock(repoID)
|
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 {
|
func (s *HTTPServer) isWriteRequest(action string, method string) bool {
|
||||||
if action == "manifest" && method == http.MethodPut { return true }
|
if action == "manifest" && method == http.MethodPut { return true }
|
||||||
if action == "upload_start" && method == http.MethodPost { 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 parts []string
|
||||||
var project models.Project
|
var project models.Project
|
||||||
var repo models.Repo
|
var repo models.Repo
|
||||||
var projectStorageID int64
|
|
||||||
var repoStorageID int64
|
var repoStorageID int64
|
||||||
var err error
|
var err error
|
||||||
var slug string
|
var slug string
|
||||||
@@ -272,11 +290,11 @@ func (s *HTTPServer) resolveRepo(store *db.Store, repoName string) (models.Repo,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return repo, project, "", err
|
return repo, project, "", err
|
||||||
}
|
}
|
||||||
projectStorageID, repoStorageID, err = store.GetRepoStorageIDs(repo.ID)
|
_, repoStorageID, err = store.GetRepoStorageIDs(repo.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return repo, project, "", err
|
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
|
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)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
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 err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package git
|
|||||||
import "os"
|
import "os"
|
||||||
import "path/filepath"
|
import "path/filepath"
|
||||||
|
|
||||||
|
import "codit/internal/storage"
|
||||||
|
|
||||||
import git "github.com/go-git/go-git/v5"
|
import git "github.com/go-git/go-git/v5"
|
||||||
|
|
||||||
type RepoManager struct {
|
type RepoManager struct {
|
||||||
@@ -18,7 +20,7 @@ func (rm *RepoManager) RepoPath(repoID string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rm *RepoManager) RepoPathFor(projectID, repoName 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 {
|
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 txStore *db.Store
|
||||||
var tokenValue string
|
var tokenValue string
|
||||||
var expires time.Time
|
var expires time.Time
|
||||||
|
var currentProvider models.AuthProvider
|
||||||
|
|
||||||
provider, err = api.store(r).GetAuthProvider(params["id"])
|
provider, err = api.store(r).GetAuthProvider(params["id"])
|
||||||
if err != nil || provider.Type != models.AuthProviderTypeOIDC {
|
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())
|
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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)
|
user, err = api.oidcGetOrCreateUser(r.Context(), txStore, claims, provider)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = txStore.Rollback()
|
_ = txStore.Rollback()
|
||||||
@@ -226,6 +245,28 @@ func oidcProviderConfigured(p models.AuthProvider) bool {
|
|||||||
return true
|
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 {
|
func oidcStateCookieNameForProvider(api *API, providerID string) string {
|
||||||
return api.oidcStateCookieName() + "_" + providerID
|
return api.oidcStateCookieName() + "_" + providerID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,23 +8,76 @@ type Manager struct {
|
|||||||
locks sync.Map
|
locks sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type lockCandidate struct {
|
||||||
|
id string
|
||||||
|
index int
|
||||||
|
}
|
||||||
|
|
||||||
func NewManager() *Manager {
|
func NewManager() *Manager {
|
||||||
return &Manager{}
|
return &Manager{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Lock(repoID string) func() {
|
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 key string
|
||||||
var value any
|
var value any
|
||||||
var actual any
|
var actual any
|
||||||
var lock *sync.Mutex
|
var lock *sync.RWMutex
|
||||||
var ok bool
|
var ok bool
|
||||||
|
|
||||||
if m == nil { return func() {} }
|
if m == nil { return func() {} }
|
||||||
key = strings.TrimSpace(repoID)
|
key = strings.TrimSpace(repoID)
|
||||||
if key == "" { key = "-" }
|
if key == "" { key = "-" }
|
||||||
value = &sync.Mutex{}
|
value = &sync.RWMutex{}
|
||||||
actual, _ = m.locks.LoadOrStore(key, value)
|
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() {} }
|
if !ok || lock == nil { return func() {} }
|
||||||
lock.Lock()
|
lock.Lock()
|
||||||
return func() {
|
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 key string
|
||||||
var value any
|
var value any
|
||||||
var actual any
|
var actual any
|
||||||
var lock *sync.Mutex
|
var lock *sync.RWMutex
|
||||||
var ok bool
|
var ok bool
|
||||||
|
|
||||||
if m == nil { return func() {}, true }
|
if m == nil { return func() {}, true }
|
||||||
key = strings.TrimSpace(repoID)
|
key = strings.TrimSpace(repoID)
|
||||||
if key == "" { key = "-" }
|
if key == "" { key = "-" }
|
||||||
value = &sync.Mutex{}
|
value = &sync.RWMutex{}
|
||||||
actual, _ = m.locks.LoadOrStore(key, value)
|
actual, _ = m.locks.LoadOrStore(key, value)
|
||||||
lock, ok = actual.(*sync.Mutex)
|
lock, ok = actual.(*sync.RWMutex)
|
||||||
if !ok || lock == nil { return func() {}, true }
|
if !ok || lock == nil { return func() {}, true }
|
||||||
if !lock.TryLock() { return nil, false }
|
if !lock.TryLock() { return nil, false }
|
||||||
return func() {
|
return func() {
|
||||||
@@ -110,3 +163,41 @@ func (m *Manager) TryLockMany(repoIDs []string) (func(), bool) {
|
|||||||
}
|
}
|
||||||
}, true
|
}, 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 "os"
|
||||||
import "path/filepath"
|
import "path/filepath"
|
||||||
|
|
||||||
|
const RepoDirGit string = "git"
|
||||||
|
const RepoDirRPM string = "rpm"
|
||||||
|
const RepoDirDocker string = "docker"
|
||||||
|
const GitRepoDirSuffix string = ".git"
|
||||||
|
|
||||||
type FileStore struct {
|
type FileStore struct {
|
||||||
BaseDir string
|
BaseDir string
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-51
@@ -65,11 +65,13 @@ type gitIDPathRewriteHandler struct {
|
|||||||
type rpmPathRewriteHandler struct {
|
type rpmPathRewriteHandler struct {
|
||||||
next http.Handler
|
next http.Handler
|
||||||
store *db.Store
|
store *db.Store
|
||||||
|
locks *repolock.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
type rpmIDPathRewriteHandler struct {
|
type rpmIDPathRewriteHandler struct {
|
||||||
next http.Handler
|
next http.Handler
|
||||||
store *db.Store
|
store *db.Store
|
||||||
|
locks *repolock.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
type listenerEndpoint struct {
|
type listenerEndpoint struct {
|
||||||
@@ -188,14 +190,15 @@ func (h *gitPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
|||||||
var currentStore *db.Store
|
var currentStore *db.Store
|
||||||
var project models.Project
|
var project models.Project
|
||||||
var repo models.Repo
|
var repo models.Repo
|
||||||
var projectStorageID int64
|
|
||||||
var repoStorageID int64
|
var repoStorageID int64
|
||||||
var err error
|
var err error
|
||||||
var newPath string
|
var newPath string
|
||||||
var unlock func()
|
var unlock func()
|
||||||
var locked bool
|
var locked bool
|
||||||
|
var isWrite bool
|
||||||
|
|
||||||
currentStore = requestStore(r, h.store)
|
currentStore = requestStore(r, h.store)
|
||||||
|
isWrite = classifyServiceOperation(serviceGit, r) == opWrite
|
||||||
path = strings.TrimPrefix(r.URL.Path, "/")
|
path = strings.TrimPrefix(r.URL.Path, "/")
|
||||||
if path == "" {
|
if path == "" {
|
||||||
h.next.ServeHTTP(w, r)
|
h.next.ServeHTTP(w, r)
|
||||||
@@ -214,7 +217,7 @@ func (h *gitPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
|||||||
rest = ""
|
rest = ""
|
||||||
}
|
}
|
||||||
orgPath = r.URL.Path
|
orgPath = r.URL.Path
|
||||||
repoName = strings.TrimSuffix(repoSegment, ".git")
|
repoName = strings.TrimSuffix(repoSegment, storage.GitRepoDirSuffix)
|
||||||
project, err = currentStore.GetProjectBySlug(slug)
|
project, err = currentStore.GetProjectBySlug(slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -225,12 +228,33 @@ func (h *gitPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newPath = "/" + storageIDSegment(projectStorageID) + "/" + storageIDSegment(repoStorageID) + ".git" + rest
|
newPath = "/" + storageIDSegment(repoStorageID) + storage.GitRepoDirSuffix + rest
|
||||||
r = git.WithRequestLogInfo(r, git.RequestLogInfo{
|
r = git.WithRequestLogInfo(r, git.RequestLogInfo{
|
||||||
OrgPath: orgPath,
|
OrgPath: orgPath,
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
@@ -239,14 +263,6 @@ func (h *gitPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
|||||||
RepoName: repo.Name,
|
RepoName: repo.Name,
|
||||||
})
|
})
|
||||||
r.URL.Path = newPath
|
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)
|
h.next.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +274,6 @@ func (h *gitIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||||||
var currentStore *db.Store
|
var currentStore *db.Store
|
||||||
var project models.Project
|
var project models.Project
|
||||||
var repo models.Repo
|
var repo models.Repo
|
||||||
var projectStorageID int64
|
|
||||||
var repoStorageID int64
|
var repoStorageID int64
|
||||||
var projectErr error
|
var projectErr error
|
||||||
var err error
|
var err error
|
||||||
@@ -266,22 +281,40 @@ func (h *gitIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||||||
var repoID string
|
var repoID string
|
||||||
var unlock func()
|
var unlock func()
|
||||||
var locked bool
|
var locked bool
|
||||||
|
var isWrite bool
|
||||||
currentStore = requestStore(r, h.store)
|
currentStore = requestStore(r, h.store)
|
||||||
|
isWrite = classifyServiceOperation(serviceGit, r) == opWrite
|
||||||
path = strings.TrimPrefix(r.URL.Path, "/")
|
path = strings.TrimPrefix(r.URL.Path, "/")
|
||||||
if path == "" {
|
if path == "" {
|
||||||
h.next.ServeHTTP(w, r)
|
h.next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
parts = strings.SplitN(path, "/", 3)
|
parts = strings.SplitN(path, "/", 3)
|
||||||
repoID = strings.TrimSuffix(parts[0], ".git")
|
repoID = strings.TrimSuffix(parts[0], storage.GitRepoDirSuffix)
|
||||||
orgPath = r.URL.Path
|
orgPath = r.URL.Path
|
||||||
repo, err = currentStore.GetRepo(repoID)
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
project, projectErr = currentStore.GetProject(repo.ProjectID)
|
project, projectErr = currentStore.GetProject(repo.ProjectID)
|
||||||
projectStorageID, repoStorageID, err = currentStore.GetRepoStorageIDs(repo.ID)
|
_, repoStorageID, err = currentStore.GetRepoStorageIDs(repo.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -293,7 +326,7 @@ func (h *gitIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||||||
} else {
|
} else {
|
||||||
rest = ""
|
rest = ""
|
||||||
}
|
}
|
||||||
newPath = "/" + storageIDSegment(projectStorageID) + "/" + storageIDSegment(repoStorageID) + ".git" + rest
|
newPath = "/" + storageIDSegment(repoStorageID) + storage.GitRepoDirSuffix + rest
|
||||||
if projectErr != nil {
|
if projectErr != nil {
|
||||||
project.Slug = ""
|
project.Slug = ""
|
||||||
}
|
}
|
||||||
@@ -305,14 +338,6 @@ func (h *gitIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||||||
RepoName: repo.Name,
|
RepoName: repo.Name,
|
||||||
})
|
})
|
||||||
r.URL.Path = newPath
|
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)
|
h.next.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,11 +351,14 @@ func (h *rpmPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
|||||||
var currentStore *db.Store
|
var currentStore *db.Store
|
||||||
var project models.Project
|
var project models.Project
|
||||||
var repo models.Repo
|
var repo models.Repo
|
||||||
var projectStorageID int64
|
|
||||||
var repoStorageID int64
|
var repoStorageID int64
|
||||||
var err error
|
var err error
|
||||||
var newPath string
|
var newPath string
|
||||||
|
var unlock func()
|
||||||
|
var locked bool
|
||||||
|
var isWrite bool
|
||||||
currentStore = requestStore(r, h.store)
|
currentStore = requestStore(r, h.store)
|
||||||
|
isWrite = classifyServiceOperation(serviceRPM, r) == opWrite
|
||||||
path = strings.TrimPrefix(r.URL.Path, "/")
|
path = strings.TrimPrefix(r.URL.Path, "/")
|
||||||
if path == "" {
|
if path == "" {
|
||||||
h.next.ServeHTTP(w, r)
|
h.next.ServeHTTP(w, r)
|
||||||
@@ -359,12 +387,33 @@ func (h *rpmPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newPath = "/" + storageIDSegment(projectStorageID) + "/" + storageIDSegment(repoStorageID) + rest
|
newPath = "/" + storageIDSegment(repoStorageID) + rest
|
||||||
r = rpm.WithRequestLogInfo(r, rpm.RequestLogInfo{
|
r = rpm.WithRequestLogInfo(r, rpm.RequestLogInfo{
|
||||||
OrgPath: orgPath,
|
OrgPath: orgPath,
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
@@ -384,13 +433,16 @@ func (h *rpmIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||||||
var currentStore *db.Store
|
var currentStore *db.Store
|
||||||
var project models.Project
|
var project models.Project
|
||||||
var repo models.Repo
|
var repo models.Repo
|
||||||
var projectStorageID int64
|
|
||||||
var repoStorageID int64
|
var repoStorageID int64
|
||||||
var projectErr error
|
var projectErr error
|
||||||
var err error
|
var err error
|
||||||
var newPath string
|
var newPath string
|
||||||
var repoID string
|
var repoID string
|
||||||
|
var unlock func()
|
||||||
|
var locked bool
|
||||||
|
var isWrite bool
|
||||||
currentStore = requestStore(r, h.store)
|
currentStore = requestStore(r, h.store)
|
||||||
|
isWrite = classifyServiceOperation(serviceRPM, r) == opWrite
|
||||||
path = strings.TrimPrefix(r.URL.Path, "/")
|
path = strings.TrimPrefix(r.URL.Path, "/")
|
||||||
if path == "" {
|
if path == "" {
|
||||||
h.next.ServeHTTP(w, r)
|
h.next.ServeHTTP(w, r)
|
||||||
@@ -400,12 +452,28 @@ func (h *rpmIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||||||
repoID = parts[0]
|
repoID = parts[0]
|
||||||
orgPath = r.URL.Path
|
orgPath = r.URL.Path
|
||||||
repo, err = currentStore.GetRepo(repoID)
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
project, projectErr = currentStore.GetProject(repo.ProjectID)
|
project, projectErr = currentStore.GetProject(repo.ProjectID)
|
||||||
projectStorageID, repoStorageID, err = currentStore.GetRepoStorageIDs(repo.ID)
|
_, repoStorageID, err = currentStore.GetRepoStorageIDs(repo.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -417,7 +485,7 @@ func (h *rpmIDPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||||||
} else {
|
} else {
|
||||||
rest = ""
|
rest = ""
|
||||||
}
|
}
|
||||||
newPath = "/" + storageIDSegment(projectStorageID) + "/" + storageIDSegment(repoStorageID) + rest
|
newPath = "/" + storageIDSegment(repoStorageID) + rest
|
||||||
if projectErr != nil {
|
if projectErr != nil {
|
||||||
project.Slug = ""
|
project.Slug = ""
|
||||||
}
|
}
|
||||||
@@ -583,9 +651,9 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identi
|
|||||||
if app.siteName == "" { app.siteName = TitleFromServerId(app.serverId) }
|
if app.siteName == "" { app.siteName = TitleFromServerId(app.serverId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
app.docker_base_dir = filepath.Join(cfg.DataDir, "docker")
|
app.docker_base_dir = filepath.Join(cfg.DataDir, storage.RepoDirDocker)
|
||||||
app.git_base_dir = filepath.Join(cfg.DataDir, "git")
|
app.git_base_dir = filepath.Join(cfg.DataDir, storage.RepoDirGit)
|
||||||
app.rpm_base_dir = filepath.Join(cfg.DataDir, "rpm")
|
app.rpm_base_dir = filepath.Join(cfg.DataDir, storage.RepoDirRPM)
|
||||||
app.uploads_base_dir = filepath.Join(cfg.DataDir, "uploads")
|
app.uploads_base_dir = filepath.Join(cfg.DataDir, "uploads")
|
||||||
|
|
||||||
err = os.MkdirAll(cfg.DataDir, 0o755)
|
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/commit/diff", api.RepoCommitDiff)
|
||||||
router.Handle("GET", "/api/repos/:id/compare", api.RepoCompare)
|
router.Handle("GET", "/api/repos/:id/compare", api.RepoCompare)
|
||||||
router.Handle("GET", "/api/repos/:id/stats", api.RepoStats)
|
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/packages", api.RepoRPMPackages)
|
||||||
router.Handle("GET", "/api/repos/:id/rpm/package", txRead(api.RepoRPMPackage))
|
router.Handle("GET", "/api/repos/:id/rpm/package", api.RepoRPMPackage)
|
||||||
router.Handle("POST", "/api/repos/:id/rpm/subdirs", api.RepoRPMCreateSubdir)
|
router.Handle("POST", "/api/repos/:id/rpm/subdirs", api.RepoRPMCreateSubdir)
|
||||||
router.Handle("GET", "/api/repos/:id/rpm/subdir", txRead(api.RepoRPMGetSubdir))
|
router.Handle("GET", "/api/repos/:id/rpm/subdir", txRead(api.RepoRPMGetSubdir))
|
||||||
router.Handle("POST", "/api/repos/:id/rpm/subdir/update", api.RepoRPMRenameSubdir)
|
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/rebuild-metadata", api.RepoRPMRebuildSubdirMetadata)
|
||||||
router.Handle("POST", "/api/repos/:id/rpm/subdir/cancel", api.RepoRPMCancelSubdirSync)
|
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("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/subdir", api.RepoRPMDeleteSubdir)
|
||||||
router.Handle("DELETE", "/api/repos/:id/rpm/file", api.RepoRPMDeleteFile)
|
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/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("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/images", api.RepoDockerImages)
|
||||||
router.Handle("GET", "/api/repos/:id/docker/tags", txRead(api.RepoDockerTags))
|
router.Handle("GET", "/api/repos/:id/docker/tags", api.RepoDockerTags)
|
||||||
router.Handle("GET", "/api/repos/:id/docker/manifest", txRead(api.RepoDockerManifest))
|
router.Handle("GET", "/api/repos/:id/docker/manifest", api.RepoDockerManifest)
|
||||||
router.Handle("DELETE", "/api/repos/:id/docker/tag", txWrite(api.RepoDockerDeleteTag))
|
router.Handle("DELETE", "/api/repos/:id/docker/tag", api.RepoDockerDeleteTag)
|
||||||
router.Handle("DELETE", "/api/repos/:id/docker/image", txWrite(api.RepoDockerDeleteImage))
|
router.Handle("DELETE", "/api/repos/:id/docker/image", api.RepoDockerDeleteImage)
|
||||||
router.Handle("POST", "/api/repos/:id/docker/tag/rename", txWrite(api.RepoDockerRenameTag))
|
router.Handle("POST", "/api/repos/:id/docker/tag/rename", api.RepoDockerRenameTag)
|
||||||
router.Handle("POST", "/api/repos/:id/docker/image/rename", txWrite(api.RepoDockerRenameImage))
|
router.Handle("POST", "/api/repos/:id/docker/image/rename", api.RepoDockerRenameImage)
|
||||||
router.Handle("GET", "/api/me/keys", txRead(api.ListAPIKeys))
|
router.Handle("GET", "/api/me/keys", txRead(api.ListAPIKeys))
|
||||||
router.Handle("POST", "/api/me/keys", txWrite(api.CreateAPIKey))
|
router.Handle("POST", "/api/me/keys", txWrite(api.CreateAPIKey))
|
||||||
router.Handle("DELETE", "/api/me/keys/:id", txWrite(api.DeleteAPIKey))
|
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)
|
gitIDHandler = middleware.WithRequestStore(store, gitIDHandler)
|
||||||
mux.Handle(cfg.GitHTTPPrefix+"-id/", 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 = withServiceAuth(serviceRPM, cfg.RPMHTTPPrefix, http.StripPrefix(cfg.RPMHTTPPrefix, rpmRewrite), auth_user, store, logger)
|
||||||
rpmHandler = middleware.WithRequestStore(store, rpmHandler)
|
rpmHandler = middleware.WithRequestStore(store, rpmHandler)
|
||||||
mux.Handle(cfg.RPMHTTPPrefix+"/", 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 = withServiceAuth(serviceRPM, cfg.RPMHTTPPrefix+"-id", http.StripPrefix(cfg.RPMHTTPPrefix+"-id", rpmIDRewrite), auth_user, store, logger)
|
||||||
rpmIDHandler = middleware.WithRequestStore(store, rpmIDHandler)
|
rpmIDHandler = middleware.WithRequestStore(store, rpmIDHandler)
|
||||||
mux.Handle(cfg.RPMHTTPPrefix+"-id/", rpmIDHandler)
|
mux.Handle(cfg.RPMHTTPPrefix+"-id/", rpmIDHandler)
|
||||||
@@ -1971,7 +2039,7 @@ func resolveProjectIDForServiceWrite(service serviceKind, servicePrefix string,
|
|||||||
if len(parts) < 1 {
|
if len(parts) < 1 {
|
||||||
return "", errors.New("invalid path")
|
return "", errors.New("invalid path")
|
||||||
}
|
}
|
||||||
first = strings.TrimSuffix(parts[0], ".git")
|
first = strings.TrimSuffix(parts[0], storage.GitRepoDirSuffix)
|
||||||
repo, err = store.GetRepo(first)
|
repo, err = store.GetRepo(first)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return repo.ProjectID, nil
|
return repo.ProjectID, nil
|
||||||
@@ -1979,7 +2047,7 @@ func resolveProjectIDForServiceWrite(service serviceKind, servicePrefix string,
|
|||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return "", errors.New("invalid path")
|
return "", errors.New("invalid path")
|
||||||
}
|
}
|
||||||
second = strings.TrimSuffix(parts[1], ".git")
|
second = strings.TrimSuffix(parts[1], storage.GitRepoDirSuffix)
|
||||||
project, err = store.GetProjectBySlug(parts[0])
|
project, err = store.GetProjectBySlug(parts[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -1992,7 +2060,7 @@ func resolveProjectIDForServiceWrite(service serviceKind, servicePrefix string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func serviceRepoType(service serviceKind, segment string) 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 "git"
|
||||||
}
|
}
|
||||||
return "rpm"
|
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