Harden image admission, resource recovery and session renewal
Add exact-image compatibility policy, experimental admission, paginated build history and shared reauthentication checks. Preserve uncertain Docker creation records, bound frontend resource waits and renew runtime sessions proactively. Lazy-load and split Monaco, add regression and opt-in live Docker fault tests, and record full race and real-browser acceptance results.
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user