package api import ( "bytes" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "testing" "time" "git.misaka.ren/m1saka/dsh-web/internal/store" "golang.org/x/crypto/bcrypt" ) func TestReauthSlowUpgradeDoesNotBlockOtherLogin(t *testing.T) { inspecting, release := make(chan struct{}), make(chan struct{}) a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/containers/old/json") { t.Errorf("unexpected Docker request: %s %s", r.Method, r.URL.Path) w.WriteHeader(500) return } close(inspecting) <-release w.WriteHeader(503) }) hash, err := bcrypt.GenerateFromPassword([]byte("original-password"), bcrypt.MinCost) if err != nil { t.Fatal(err) } if _, err := a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id='owner'", string(hash)); err != nil { t.Fatal(err) } if err := a.s.CreateUser(store.User{ID: "other", Username: "other"}, string(hash)); err != nil { t.Fatal(err) } i := store.Instance{ID: "one", UID: "owner", ContainerID: "old", Status: "exited", Created: time.Now()} if err := a.s.AddInstance(i); err != nil { t.Fatal(err) } upgradeDone := make(chan struct{}) go func() { defer close(upgradeDone) a.upgrade(httptest.NewRecorder(), runtimeRequest("POST", "/api/instances/one/upgrade", `{"image_id":"sha256:`+strings.Repeat("a", 64)+`","password":"original-password"}`, sid), i) }() defer func() { close(release) select { case <-upgradeDone: case <-time.After(5 * time.Second): t.Error("upgrade did not finish after releasing Docker") } }() select { case <-inspecting: case <-upgradeDone: t.Fatal("upgrade did not reach Docker after reauthentication") case <-time.After(5 * time.Second): t.Fatal("upgrade did not reach inspection") } loginDone := make(chan *httptest.ResponseRecorder, 1) go func() { w := httptest.NewRecorder() r := runtimeRequest("POST", "/api/login", resetLogin("other", "original-password"), "") r.Header.Set("Origin", a.origin) a.login(w, r) loginDone <- w }() select { case w := <-loginDone: if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } case <-time.After(5 * time.Second): t.Fatal("slow upgrade held authentication lock and blocked another user's login") } select { case <-upgradeDone: t.Fatal("upgrade finished before Docker was released") default: } } func TestReauthConcurrentFailures(t *testing.T) { a, i, _ := configTestAPI(t, false) start := make(chan struct{}) done := make(chan int, 10) for n := 0; n < 10; n++ { go func() { <-start w := httptest.NewRecorder() a.reauthenticate(w, i.UID, "wrong") done <- w.Code }() } close(start) counts := map[int]int{} for n := 0; n < 10; n++ { select { case code := <-done: counts[code]++ case <-time.After(5 * time.Second): t.Fatal("reauthentication deadlocked") } } if counts[403] != 5 || counts[429] != 5 { t.Fatal(counts) } } func TestReauthOldBackupPasswordIndependent(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires chown") } a, i, home := configTestAPI(t, false) configWrite(t, filepath.Join(home, ".credentials.yaml"), "original") w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`) var result struct { ID string `json:"id"` } if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result.ID == "" { t.Fatal(w.Code, w.Body.String()) } if _, err := a.s.DB.Exec("UPDATE users SET pass_hash='changed' WHERE id=?", i.UID); err != nil { t.Fatal(err) } if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES('owner',5,?)", time.Now().Add(time.Hour).Unix()); err != nil { t.Fatal(err) } configWrite(t, filepath.Join(home, ".credentials.yaml"), "changed") w = configCall(a, i, "POST", "restore", `{"backup_id":"`+result.ID+`","password":"original-password"}`) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } content, err := os.ReadFile(filepath.Join(home, ".credentials.yaml")) if err != nil || string(content) != "original" { t.Fatal(string(content), err) } var failures int if err := a.s.DB.QueryRow("SELECT failures FROM auth_failures WHERE username='owner'").Scan(&failures); err != nil || failures != 5 { t.Fatal(failures, err) } } func TestReauthSharedFailureLock(t *testing.T) { a, i, _ := configTestAPI(t, false) sid, _, err := a.s.Session(i.UID) if err != nil { t.Fatal(err) } registerTestAccess(t, a, sid) backup := func(password string) *httptest.ResponseRecorder { return configCall(a, i, "POST", "backups", `{"password":"`+password+`"}`) } upgrade := func(password string) *httptest.ResponseRecorder { w := httptest.NewRecorder() a.upgrade(w, runtimeRequest("POST", "/api/instances/one/upgrade", `{"image_id":"sha256:`+strings.Repeat("a", 64)+`","password":"`+password+`"}`, sid), i) return w } for _, call := range []func(string) *httptest.ResponseRecorder{backup, upgrade, backup, upgrade} { if w := call("wrong"); w.Code != 403 { t.Fatal(w.Code, w.Body.String()) } } w := httptest.NewRecorder() a.changePassword(w, runtimeRequest("POST", changePath, changeBody("wrong", resetSecret), sid)) if w.Code != 400 { t.Fatal(w.Code, w.Body.String()) } for _, call := range []func(string) *httptest.ResponseRecorder{backup, upgrade} { if w := call("original-password"); w.Code != 429 { t.Fatal(w.Code, w.Body.String()) } } w = httptest.NewRecorder() r := runtimeRequest("POST", "/api/login", resetLogin("owner", "original-password"), "") r.Header.Set("Origin", a.origin) a.login(w, r) if w.Code != 429 { t.Fatal(w.Code, w.Body.String()) } var failures int if err := a.s.DB.QueryRow("SELECT failures FROM auth_failures WHERE username='owner'").Scan(&failures); err != nil || failures != 5 { t.Fatal(failures, err) } if _, err := a.s.DB.Exec("UPDATE auth_failures SET locked_until=?", time.Now().Add(-time.Minute).Unix()); err != nil { t.Fatal(err) } if w := upgrade("wrong"); w.Code != 403 { t.Fatal(w.Code, w.Body.String()) } if err := a.s.DB.QueryRow("SELECT failures FROM auth_failures WHERE username='owner'").Scan(&failures); err != nil || failures != 1 { t.Fatal(failures, err) } } func TestReauthFailureStorageUnavailable(t *testing.T) { a, i, _ := configTestAPI(t, false) if _, err := a.s.DB.Exec("DROP TABLE auth_failures"); err != nil { t.Fatal(err) } w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`) if w.Code != 503 { t.Fatal(w.Code, w.Body.String()) } } type quotaPausedBody struct { io.Reader started chan struct{} resume chan struct{} } func (b *quotaPausedBody) Read(p []byte) (int, error) { if b.started != nil { close(b.started) b.started = nil <-b.resume } return b.Reader.Read(p) } func TestReauthQuotaInFlightUpload(t *testing.T) { a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) {}) if _, err := a.s.DB.Exec("UPDATE users SET admin=1,quota=1048576 WHERE id='owner'"); err != nil { t.Fatal(err) } u, err := a.s.UserByID("owner") if err != nil { t.Fatal(err) } dir := filepath.Join(a.root, "workspaces", u.ID) if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } root, err := os.OpenRoot(dir) if err != nil { t.Fatal(err) } defer root.Close() var body bytes.Buffer form := multipart.NewWriter(&body) part, err := form.CreateFormFile("file", "large.txt") if err != nil { t.Fatal(err) } part.Write(bytes.Repeat([]byte("x"), 4096)) form.Close() started, resume := make(chan struct{}), make(chan struct{}) r := httptest.NewRequest("POST", "/api/ws/upload", "aPausedBody{Reader: &body, started: started, resume: resume}) r.Header.Set("Content-Type", form.FormDataContentType()) done := make(chan *httptest.ResponseRecorder, 1) go func() { w := httptest.NewRecorder() a.wsUpload(w, r, root, u) done <- w }() select { case <-started: case <-time.After(5 * time.Second): close(resume) t.Fatal("upload did not start reading") } w := httptest.NewRecorder() a.users(w, runtimeRequest("PATCH", "/api/users", `{"id":"owner","quota_bytes":1024}`, sid)) close(resume) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } select { case w := <-done: if w.Code != 413 { t.Fatal(w.Code, w.Body.String()) } case <-time.After(5 * time.Second): t.Fatal("upload deadlocked") } entries, err := os.ReadDir(dir) if err != nil || len(entries) != 0 { t.Fatal("rejected upload left files", entries, err) } } func TestReauthQuotaUpdateUsesUserLock(t *testing.T) { a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) {}) if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='owner'"); err != nil { t.Fatal(err) } lock := a.userLocks.Acquire("owner") defer func() { if lock != nil { lock.Release() } }() done := make(chan *httptest.ResponseRecorder, 1) go func() { w := httptest.NewRecorder() a.users(w, runtimeRequest("PATCH", "/api/users", `{"id":"owner","quota_bytes":1024}`, sid)) done <- w }() deadline := time.Now().Add(5 * time.Second) for { a.userLocks.mu.Lock() refs := lock.refs a.userLocks.mu.Unlock() if refs == 2 { break } select { case w := <-done: t.Fatal("quota update bypassed user lock", w.Code) default: } if time.Now().After(deadline) { t.Fatal("quota update did not wait on user lock") } runtime.Gosched() } lock.Release() lock = nil select { case w := <-done: if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } case <-time.After(5 * time.Second): t.Fatal("quota update deadlocked") } }