diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b4ce7da..945d4ca 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -843,7 +843,7 @@ components: QuotaView: type: object - description: A user's quotas row (internal/api/repo.go QuotaView). Null fields mean unlimited. + description: A user's quotas row (internal/api/repo.go QuotaView). An absent or null field is unlimited; 0 grants none of that resource. required: [user_id] properties: user_id: { type: string } @@ -4186,6 +4186,11 @@ paths: tags: [users] operationId: setQuotas summary: Set a user's quotas (admin only). + description: >- + Replaces all four caps at once. An absent or null field is unlimited; 0 + grants none of that resource, so every claim that needs it is refused. An + empty body lifts every cap. A cap below what the user already owns refuses + new claims and leaves the servers they have alone. x-felis-face: [external] x-felis-tier: owner security: [{ sessionCookie: [] }] @@ -4198,10 +4203,10 @@ paths: schema: type: object properties: - max_servers: { type: integer, nullable: true } - max_cpu_milli: { type: integer, nullable: true } - max_memory_mb: { type: integer, nullable: true } - max_storage_gb: { type: integer, nullable: true } + max_servers: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 } + max_cpu_milli: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 } + max_memory_mb: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 } + max_storage_gb: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 } responses: '200': description: Quotas updated. @@ -4209,7 +4214,12 @@ paths: application/json: schema: { $ref: '#/components/schemas/QuotaView' } '400': - $ref: '#/components/responses/BadRequest' + description: >- + invalid_quota, a cap outside 0..2147483647; or a body that is not JSON + or carries a fraction. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' '403': diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 677a788..2522bb5 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1511,21 +1511,9 @@ func (f *fakeRepo) SetQuotas(_ context.Context, userID string, qi QuotaInput, _ if f.fakeQuotas == nil { f.fakeQuotas = map[string]*QuotaView{} } - if _, ok := f.fakeQuotas[userID]; !ok { - f.fakeQuotas[userID] = &QuotaView{UserID: userID} - } - if qi.MaxServers != nil { - f.fakeQuotas[userID].MaxServers = qi.MaxServers - } - if qi.MaxCPUMilli != nil { - f.fakeQuotas[userID].MaxCPUMilli = qi.MaxCPUMilli - } - if qi.MaxMemoryMB != nil { - f.fakeQuotas[userID].MaxMemoryMB = qi.MaxMemoryMB - } - if qi.MaxStorageGB != nil { - f.fakeQuotas[userID].MaxStorageGB = qi.MaxStorageGB - } + // A full replacement, like the SQL upsert: nil is unlimited. + f.fakeQuotas[userID] = &QuotaView{UserID: userID, MaxServers: qi.MaxServers, + MaxCPUMilli: qi.MaxCPUMilli, MaxMemoryMB: qi.MaxMemoryMB, MaxStorageGB: qi.MaxStorageGB} return f.fakeQuotas[userID], nil } diff --git a/internal/api/handlers_patch_test.go b/internal/api/handlers_patch_test.go index 96a6d61..0716ffa 100644 --- a/internal/api/handlers_patch_test.go +++ b/internal/api/handlers_patch_test.go @@ -434,3 +434,43 @@ func TestPatchServerClearsDisplayName(t *testing.T) { t.Fatalf("patched displayName = %v, want an empty one", p.DisplayName) } } + +// An owner over a cap an admin lowered (quota set below what they already use) +// must still be brought back under it: only growth is held to the caps. Before, +// every resource patch ran the quota check, so shrinking a server of an over-cap +// owner got the same 403 as growing it. +func TestPatchServerOverQuotaMayShrink(t *testing.T) { + api, repo, cl, _ := newPatchAPI() + seedResources(cl) + repo.byName["survival"].OwnerID = "u1" + repo.quota["u1"] = false // over every cap: any check refuses + repo.serverResources["survival"] = ResourceSpec{CPUMilli: 1000, MemoryMB: 4096, StorageMB: 10240} + + for _, body := range []string{`{"resources":{"cpu":"500m"}}`, `{"resources":{"cpu":"1"}}`} { + delete(cl.patched, "survival") + w := patchSurvival(api, body) + if w.Code != http.StatusOK { + t.Fatalf("%s: code = %d, want 200 (%s)", body, w.Code, w.Body.String()) + } + if _, ok := cl.patched["survival"]; !ok { + t.Fatalf("%s: the patch did not reach the cluster", body) + } + } + if len(repo.quotaChecked) != 0 { + t.Errorf("a patch that grows nothing was quota-checked: %+v", repo.quotaChecked) + } + if got := repo.resourceUpdates["survival"]; got != (ResourceSpec{CPUMilli: 1000, MemoryMB: 4096, StorageMB: 10240}) { + t.Errorf("resource cache = %+v, want cpu 1000 / mem 4096 / storage kept", got) + } + + for _, body := range []string{`{"resources":{"cpu":"2"}}`, `{"resources":{"cpu":"500m","memory":"8Gi"}}`} { + delete(cl.patched, "survival") + w := patchSurvival(api, body) + if w.Code != http.StatusForbidden || decodeErr(t, w) != "quota_exceeded" { + t.Fatalf("growing an over-cap owner's server %s: code = %d body %s, want 403 quota_exceeded", body, w.Code, w.Body.String()) + } + if _, ok := cl.patched["survival"]; ok { + t.Fatalf("a refused growth %s reached the cluster", body) + } + } +} diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index b3ff503..2efaf2b 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -1041,7 +1041,17 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } - if rec != nil && rec.OwnerID != "" { + var cur ResourceSpec + if rec != nil { + if cur, err = a.Repo.ServerResources(r.Context(), name); err != nil { + writeError(w, r, err) + return + } + } + // Only growth is held to the caps. A change that grows neither CPU nor memory + // cannot push the owner past one, and it is how an admin brings a server back + // under a cap lowered below what the owner already uses. + if rec != nil && rec.OwnerID != "" && (newCPU > cur.CPUMilli || newMemMB > cur.MemoryMB) { ok, err := a.Repo.QuotaCheck(r.Context(), rec.OwnerID, name, ResourceSpec{CPUMilli: newCPU, MemoryMB: newMemMB}) if err != nil { @@ -1062,16 +1072,7 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) { // A resource patch cannot change storage, so its cached contribution must // be preserved: passing 0 would silently zero the storage dimension of the // owner's four-cap aggregate (the cached columns are its only input). - storMB := 0 - if rec != nil { - cur, err := a.Repo.ServerResources(r.Context(), name) - if err != nil { - writeError(w, r, err) - return - } - storMB = cur.StorageMB - } - _ = a.Repo.UpdateServerResources(r.Context(), name, newCPU, newMemMB, storMB) + _ = a.Repo.UpdateServerResources(r.Context(), name, newCPU, newMemMB, cur.StorageMB) } else { if err := a.Cluster.PatchServerSpec(r.Context(), name, patch); err != nil { a.writeLookupError(w, r, err) diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index 522e43d..2be6d04 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -2,6 +2,7 @@ package api import ( "errors" + "math" "net/http" "strconv" "strings" @@ -317,11 +318,23 @@ func (a *API) handleSetQuotas(w http.ResponseWriter, r *http.Request) { return } - // Reject a body where every field is nil — a silent no-op is a client mistake. - if body.MaxServers == nil && body.MaxCPUMilli == nil && body.MaxMemoryMB == nil && body.MaxStorageGB == nil { - writeError(w, r, newError(http.StatusBadRequest, "bad_request", - "at least one quota field must be set")) - return + // The body replaces all four caps; an empty one lifts every cap. A negative cap + // would refuse every claim the way 0 does while reading like a mistake, and the + // columns are 32-bit. + for _, f := range []struct { + name string + v *int + }{ + {"max_servers", body.MaxServers}, + {"max_cpu_milli", body.MaxCPUMilli}, + {"max_memory_mb", body.MaxMemoryMB}, + {"max_storage_gb", body.MaxStorageGB}, + } { + if f.v != nil && (*f.v < 0 || *f.v > math.MaxInt32) { + writeError(w, r, newError(http.StatusBadRequest, "invalid_quota", + "%s must be a whole number from 0 to 2147483647, or null for unlimited", f.name)) + return + } } v, err := a.Repo.SetQuotas(r.Context(), id, body, p.Email) diff --git a/internal/api/handlers_users_test.go b/internal/api/handlers_users_test.go index 92e9650..7a35942 100644 --- a/internal/api/handlers_users_test.go +++ b/internal/api/handlers_users_test.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "strings" "testing" ) @@ -127,3 +128,65 @@ func TestAdminSubresourcesRequireLiveUser(t *testing.T) { } }) } + +// The quotas form replaces all four caps at once, so an owner can lift a cap they +// set: a missing or null field is unlimited, 0 grants none of it. Before, a null +// field meant "leave it", the panel sent null for every emptied box, and a cap once +// set could only be moved, never removed; a negative one was written as is. +func TestSetQuotasReplacesAllCaps(t *testing.T) { + owner := &Principal{UserID: "usr-root", Role: "owner", ViaAdminAccess: true} + repo := newFakeRepo() + repo.seedUser(UserView{ID: "usr-root", Username: "root", Role: "owner"}) + repo.seedUser(UserView{ID: "u2", Username: "alice", Role: "user"}) + api := newTestAPI(repo, newFakeCluster()) + api.External = staticExternal{p: owner} + eh := api.ExternalHandler() + + caps := func() string { + t.Helper() + w := do(eh, "GET", "/api/v1/users/u2/quotas", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("get quotas: code = %d body %s", w.Code, w.Body.String()) + } + return strings.TrimSpace(w.Body.String()) + } + put := func(body string, want int) { + t.Helper() + w := do(eh, "PUT", "/api/v1/users/u2/quotas", body, jsonHeader) + if w.Code != want { + t.Fatalf("PUT %s: code = %d body %s, want %d", body, w.Code, w.Body.String(), want) + } + } + + put(`{"max_servers":0,"max_cpu_milli":2000,"max_memory_mb":4096,"max_storage_gb":20}`, http.StatusOK) + if got, want := caps(), `{"user_id":"u2","max_servers":0,"max_cpu_milli":2000,"max_memory_mb":4096,"max_storage_gb":20}`; got != want { + t.Fatalf("after setting every cap: %s, want %s", got, want) + } + // What the panel sends after the owner empties two boxes. + put(`{"max_servers":null,"max_cpu_milli":1000,"max_memory_mb":null,"max_storage_gb":20}`, http.StatusOK) + if got, want := caps(), `{"user_id":"u2","max_cpu_milli":1000,"max_storage_gb":20}`; got != want { + t.Fatalf("after emptying two boxes: %s, want %s", got, want) + } + put(`{}`, http.StatusOK) + if got, want := caps(), `{"user_id":"u2"}`; got != want { + t.Fatalf("after lifting every cap: %s, want %s", got, want) + } + + put(`{"max_servers":3}`, http.StatusOK) + for _, body := range []string{ + `{"max_servers":-1}`, + `{"max_servers":1,"max_storage_gb":-5}`, + `{"max_memory_mb":2147483648}`, + `{"max_cpu_milli":1.5}`, + } { + put(body, http.StatusBadRequest) + } + if got, want := caps(), `{"user_id":"u2","max_servers":3}`; got != want { + t.Fatalf("a refused write changed the caps: %s, want %s", got, want) + } + w := do(eh, "PUT", "/api/v1/users/u2/quotas", `{"max_storage_gb":-5}`, jsonHeader) + if !strings.Contains(w.Body.String(), `"invalid_quota"`) || !strings.Contains(w.Body.String(), "max_storage_gb") { + t.Fatalf("a negative cap should name the field: %s", w.Body.String()) + } + put(`{"max_memory_mb":2147483647}`, http.StatusOK) +} diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 10a99cd..e4dd6e2 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -2027,55 +2027,23 @@ func (p *PGRepo) requireLiveUser(ctx context.Context, userID string) error { return nil } -// SetQuotas upserts a quotas row. Nil fields are left unchanged; a non-nil -// zero-value field clears the cap. +// SetQuotas replaces the user's quotas row. A nil field stores NULL, which every +// quota gate reads as unlimited; any other value is the cap, 0 included. The form +// always carries all four caps, so clearing one is a matter of leaving it out. func (p *PGRepo) SetQuotas(ctx context.Context, userID string, qi QuotaInput, setBy string) (*QuotaView, error) { if err := p.requireLiveUser(ctx, userID); err != nil { return nil, err } - type col struct { - name string - value *int - } - cols := []col{ - {"max_servers", qi.MaxServers}, - {"max_cpu_milli", qi.MaxCPUMilli}, - {"max_memory_mb", qi.MaxMemoryMB}, - {"max_storage_gb", qi.MaxStorageGB}, - } - - // Build the ON CONFLICT upsert dynamically. - var insCols, insVals []string - var upd []string - var args []any - argn := 0 - args = append(args, userID) // $1 = user_id - argn++ - args = append(args, setBy) // $2 = updated_by - argn++ - insCols = append(insCols, "user_id", "updated_by") - insVals = append(insVals, "$1", "$2") - - for _, c := range cols { - if c.value == nil { - continue - } - argn++ - insCols = append(insCols, c.name) - insVals = append(insVals, fmt.Sprintf("$%d", argn)) - args = append(args, *c.value) - upd = append(upd, fmt.Sprintf("%s = EXCLUDED.%s", c.name, c.name)) - } - - query := fmt.Sprintf(`INSERT INTO quotas (%s) VALUES (%s) - ON CONFLICT (user_id) DO UPDATE SET %s, updated_by = $2 - RETURNING user_id, max_servers, max_cpu_milli, max_memory_mb, max_storage_gb`, - joinStr(insCols), joinStr(insVals), joinStr(upd)) - v := QuotaView{} - switch err := p.db.QueryRowContext(ctx, query, args...).Scan( - &v.UserID, &v.MaxServers, &v.MaxCPUMilli, &v.MaxMemoryMB, &v.MaxStorageGB); { - case err != nil: + if err := p.db.QueryRowContext(ctx, + `INSERT INTO quotas (user_id, updated_by, max_servers, max_cpu_milli, max_memory_mb, max_storage_gb) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_id) DO UPDATE SET updated_by = EXCLUDED.updated_by, + max_servers = EXCLUDED.max_servers, max_cpu_milli = EXCLUDED.max_cpu_milli, + max_memory_mb = EXCLUDED.max_memory_mb, max_storage_gb = EXCLUDED.max_storage_gb + RETURNING user_id, max_servers, max_cpu_milli, max_memory_mb, max_storage_gb`, + userID, setBy, qi.MaxServers, qi.MaxCPUMilli, qi.MaxMemoryMB, qi.MaxStorageGB).Scan( + &v.UserID, &v.MaxServers, &v.MaxCPUMilli, &v.MaxMemoryMB, &v.MaxStorageGB); err != nil { return nil, err } return &v, nil @@ -2735,18 +2703,6 @@ func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string, return err } -// joinStr joins a slice of strings with ", ". -func joinStr(vals []string) string { - if len(vals) == 0 { - return "" - } - s := vals[0] - for _, v := range vals[1:] { - s += ", " + v - } - return s -} - // isUniqueViolation reports whether err is a Postgres unique-constraint // violation (code 23505). func isUniqueViolation(err error) bool { diff --git a/internal/api/repo.go b/internal/api/repo.go index d1d1e3b..5b2f247 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -723,8 +723,8 @@ type Repo interface { // GetQuotas returns the quotas row for a user, or a zero-value view when no // row exists (which means unlimited per spec §9.3). GetQuotas(ctx context.Context, userID string) (*QuotaView, error) - // SetQuotas upserts a quotas row for userID. Nil fields leave the column - // untouched; a zero-value (non-nil) field clears the cap (unlimited). + // SetQuotas replaces the quotas row for userID with q: a nil field is stored as + // NULL (unlimited), any other value is the cap, 0 included. SetQuotas(ctx context.Context, userID string, q QuotaInput, setBy string) (*QuotaView, error) // ---- session admin (admin-only) ---- @@ -854,8 +854,9 @@ type QuotaView struct { MaxStorageGB *int `json:"max_storage_gb,omitempty"` } -// QuotaInput is the admin set-quotas form. Nil fields are left unchanged; -// a non-nil zero-value field clears the cap (unlimited). +// QuotaInput is the admin set-quotas form. It replaces all four caps at once: an +// absent or null field is unlimited, and 0 grants none of that resource, so every +// claim that needs it is refused. The handler rejects a value outside 0..MaxInt32. type QuotaInput struct { MaxServers *int `json:"max_servers,omitempty"` MaxCPUMilli *int `json:"max_cpu_milli,omitempty"` diff --git a/internal/pgint/pgint_test.go b/internal/pgint/pgint_test.go index 072c116..e9620e0 100644 --- a/internal/pgint/pgint_test.go +++ b/internal/pgint/pgint_test.go @@ -553,6 +553,79 @@ func TestClaimServerQuotaAtomicGate(t *testing.T) { } } +// The quotas form replaces every cap at once: a nil field lifts that cap and 0 grants +// none of it. SetQuotas used to skip nil fields, so a cap once set could be moved but +// never removed, and the panel's emptied box changed nothing. +func TestSetQuotasReplacesEveryCap(t *testing.T) { + ctx := context.Background() + u := newUser(t, "user", "qrepl") + name := "qr-" + suffix(t) + if _, err := db.ExecContext(ctx, + `INSERT INTO servers (name, cached_cpu_milli, cached_memory_mb, cached_storage_mb) VALUES ($1, 100, 128, 1)`, + name); err != nil { + t.Fatalf("seed server: %v", err) + } + spec := api.ResourceSpec{CPUMilli: 100, MemoryMB: 128, StorageMB: 1} + n := func(v int) *int { return &v } + caps := func(q *api.QuotaView) string { + s := make([]string, 0, 4) + for _, v := range []*int{q.MaxServers, q.MaxCPUMilli, q.MaxMemoryMB, q.MaxStorageGB} { + if v == nil { + s = append(s, "-") + } else { + s = append(s, fmt.Sprint(*v)) + } + } + return strings.Join(s, "/") + } + set := func(in api.QuotaInput, want string) { + t.Helper() + got, err := repo.SetQuotas(ctx, u.ID, in, "pgint-"+want) + if err != nil { + t.Fatalf("SetQuotas(%s): %v", want, err) + } + if caps(got) != want { + t.Fatalf("SetQuotas returned %s, want %s", caps(got), want) + } + read, err := repo.GetQuotas(ctx, u.ID) + if err != nil || caps(read) != want { + t.Fatalf("GetQuotas = %s, %v; want %s", caps(read), err, want) + } + var by string + if err := db.QueryRowContext(ctx, `SELECT updated_by FROM quotas WHERE user_id = $1`, u.ID).Scan(&by); err != nil || by != "pgint-"+want { + t.Fatalf("updated_by = %q, %v; want the latest writer", by, err) + } + } + gate := func(want bool) { + t.Helper() + if ok, err := repo.QuotaCheck(ctx, u.ID, "", spec); err != nil || ok != want { + t.Fatalf("QuotaCheck = %v, %v; want %v", ok, err, want) + } + } + + set(api.QuotaInput{MaxServers: n(0), MaxCPUMilli: n(2000), MaxMemoryMB: n(4096), MaxStorageGB: n(20)}, "0/2000/4096/20") + gate(false) + if ok, err := repo.QuotaAvailable(ctx, u.ID); err != nil || ok { + t.Fatalf("QuotaAvailable at max_servers 0 = %v, %v; want false", ok, err) + } + if _, err := repo.ClaimServer(ctx, name, u.ID); !errors.Is(err, api.ErrQuotaExceeded) { + t.Fatalf("claim at max_servers 0 = %v, want ErrQuotaExceeded", err) + } + + set(api.QuotaInput{MaxCPUMilli: n(2000), MaxStorageGB: n(20)}, "-/2000/-/20") + gate(true) + set(api.QuotaInput{MaxCPUMilli: n(0)}, "-/0/-/-") + gate(false) + set(api.QuotaInput{}, "-/-/-/-") + gate(true) + if ok, err := repo.QuotaAvailable(ctx, u.ID); err != nil || !ok { + t.Fatalf("QuotaAvailable with every cap lifted = %v, %v; want true", ok, err) + } + if claimed, err := repo.ClaimServer(ctx, name, u.ID); err != nil || !claimed { + t.Fatalf("claim with every cap lifted = %v, %v; want claimed", claimed, err) + } +} + // The fleet read needs every live server's claim state: the owner's id to tell // the caller's own servers apart, the display name (email, else username), and // the unclaimed rows too, since only those may be claimed. A soft-deleted row is diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 0364653..7da1135 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -218,6 +218,7 @@ "users_quota_memory": "Max Memory (MiB)", "users_quota_storage": "Max Storage (GiB)", "users_quota_unlimited": "Unlimited", + "users_quota_hint": "Leave a box empty for unlimited. 0 grants none of it, so every claim that needs it is refused. A cap below current use only stops new claims; servers the user already has stay.", "users_sessions": "Sessions", "users_no_sessions": "No active sessions.", "users_session_expires": "Expires", diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 1bfdf90..4c203c0 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -74,6 +74,7 @@ "account_retired": "This account has been retired — sign in with the account it was migrated to.", "no_migration": "There is no migration in progress.", "not_confirmed": "Confirm it's you in this browser first. A confirmation lasts 10 minutes.", + "invalid_quota": "A quota must be a whole number from 0 to 2147483647. Leave it empty for unlimited.", "already_confirmed": "This migration has already been confirmed.", "invalid_target": "The migration target must be a different account.", "target_not_found": "No account matches that migration target.", diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 966e522..d4027b2 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -217,6 +217,7 @@ "users_quota_memory": "最大内存(MiB)", "users_quota_storage": "最大存储(GiB)", "users_quota_unlimited": "无限制", + "users_quota_hint": "留空为不限。填 0 表示这一项不给额度,该用户需要它的认领都会被拒绝。调低到已用量以下只挡新的认领,已有的服务器不受影响。", "users_sessions": "活跃会话", "users_no_sessions": "无活跃会话。", "users_session_expires": "过期时间", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 7b7d814..0bf994c 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -74,6 +74,7 @@ "account_retired": "该账户已退役——请使用迁移后的账户登录。", "no_migration": "当前没有进行中的迁移。", "not_confirmed": "请先在当前浏览器完成身份确认,确认 10 分钟内有效。", + "invalid_quota": "配额要填 0 到 2147483647 之间的整数,不限就留空。", "already_confirmed": "该迁移已经确认过了。", "invalid_target": "迁移目标账户不能与来源账户相同。", "target_not_found": "找不到迁移目标账户。", diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 72864a2..97a167b 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -1146,6 +1146,8 @@ export function humanizeError(e: unknown): string { return t("no_migration"); case "not_confirmed": return t("not_confirmed"); + case "invalid_quota": + return t("invalid_quota"); case "already_confirmed": return t("already_confirmed"); case "invalid_target": diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index c398fac..a5eb206 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -1263,7 +1263,10 @@ export interface paths { }; /** Get a user's quotas (admin only). */ get: operations["getQuotas"]; - /** Set a user's quotas (admin only). */ + /** + * Set a user's quotas (admin only). + * @description Replaces all four caps at once. An absent or null field is unlimited; 0 grants none of that resource, so every claim that needs it is refused. An empty body lifts every cap. A cap below what the user already owns refuses new claims and leaves the servers they have alone. + */ put: operations["setQuotas"]; post?: never; delete?: never; @@ -2620,7 +2623,7 @@ export interface components { verified_at: string; }[]; }; - /** @description A user's quotas row (internal/api/repo.go QuotaView). Null fields mean unlimited. */ + /** @description A user's quotas row (internal/api/repo.go QuotaView). An absent or null field is unlimited; 0 grants none of that resource. */ QuotaView: { user_id: string; max_servers?: number | null; @@ -6119,7 +6122,15 @@ export interface operations { "application/json": components["schemas"]["QuotaView"]; }; }; - 400: components["responses"]["BadRequest"]; + /** @description invalid_quota, a cap outside 0..2147483647; or a body that is not JSON or carries a fraction. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; /** @description Unknown user. */ diff --git a/panel/src/pages/admin/UserDetailPage.test.tsx b/panel/src/pages/admin/UserDetailPage.test.tsx index ae344eb..c1c2380 100644 --- a/panel/src/pages/admin/UserDetailPage.test.tsx +++ b/panel/src/pages/admin/UserDetailPage.test.tsx @@ -10,6 +10,7 @@ import type { UserDetail } from "@/lib/types"; const calls = vi.hoisted(() => ({ getUser: vi.fn(), getUserQuotas: vi.fn(), + setUserQuotas: vi.fn(), listUserSessions: vi.fn(), revokeUserSession: vi.fn(), revokeUserSessions: vi.fn(), @@ -240,3 +241,57 @@ describe("UserDetailPage", () => { }); }); }); + +describe("UserDetailPage quotas", () => { + function quotaBox(label: string) { + return screen.getByLabelText(i18next.t(`admin:${label}`)) as HTMLInputElement; + } + function quotaCard() { + return quotaBox("users_quota_servers").closest(".space-y-4") as HTMLElement; + } + + it("sends every box, an emptied one as unlimited and 0 as none", async () => { + calls.getUser.mockResolvedValue(USER); + calls.getUserQuotas.mockResolvedValue({ user_id: "u-1", max_servers: 3, max_cpu_milli: 2000 }); + calls.setUserQuotas.mockResolvedValue({ user_id: "u-1", max_cpu_milli: 2000, max_storage_gb: 0 }); + renderPage(); + const user = userEvent.setup(); + + await waitFor(() => expect(quotaBox("users_quota_servers").value).toBe("3")); + expect(within(quotaCard()).getByText(i18next.t("admin:users_quota_hint"))).toBeTruthy(); + await user.clear(quotaBox("users_quota_servers")); + await user.type(quotaBox("users_quota_storage"), "0"); + await user.click(within(quotaCard()).getByRole("button", { name: i18next.t("admin:users_save_btn") })); + + await waitFor(() => expect(calls.setUserQuotas).toHaveBeenCalledTimes(1)); + expect(calls.setUserQuotas).toHaveBeenCalledWith("u-1", { + max_servers: null, + max_cpu_milli: 2000, + max_memory_mb: null, + max_storage_gb: 0, + }); + }); + + it("refuses a box that is not a whole number the server can store, and sends nothing", async () => { + calls.getUser.mockResolvedValue(USER); + renderPage(); + const user = userEvent.setup(); + await waitFor(() => expect(quotaBox("users_quota_servers")).toBeTruthy()); + + for (const bad of ["-1", "1.5", "2abc", "2147483648"]) { + await user.clear(quotaBox("users_quota_memory")); + await user.type(quotaBox("users_quota_memory"), bad); + await user.click(within(quotaCard()).getByRole("button", { name: i18next.t("admin:users_save_btn") })); + expect(await within(quotaCard()).findByText(i18next.t("errors:invalid_quota"))).toBeTruthy(); + expect(quotaBox("users_quota_memory").value).toBe(bad); + } + expect(calls.setUserQuotas).not.toHaveBeenCalled(); + + await user.clear(quotaBox("users_quota_memory")); + await user.type(quotaBox("users_quota_memory"), " 2147483647 "); + calls.setUserQuotas.mockResolvedValue({ user_id: "u-1", max_memory_mb: 2147483647 }); + await user.click(within(quotaCard()).getByRole("button", { name: i18next.t("admin:users_save_btn") })); + await waitFor(() => expect(calls.setUserQuotas).toHaveBeenCalledTimes(1)); + expect(calls.setUserQuotas.mock.calls[0][1].max_memory_mb).toBe(2147483647); + }); +}); diff --git a/panel/src/pages/admin/UserDetailPage.tsx b/panel/src/pages/admin/UserDetailPage.tsx index dd8df48..8d488a8 100644 --- a/panel/src/pages/admin/UserDetailPage.tsx +++ b/panel/src/pages/admin/UserDetailPage.tsx @@ -428,13 +428,27 @@ function QuotasCard({ userId }: { userId: string }) { setSaving(true); setErr(null); setOk(null); + // The form replaces all four caps: an empty box is unlimited, 0 grants none. + // Anything else must be a whole number the server's 32-bit columns hold; the + // boxes are text so a stray letter stays visible and is refused here instead + // of turning into an empty (unlimited) number field. + const parse = (s: string): number | null | undefined => { + const v = s.trim(); + if (v === "") return null; + return /^\d+$/.test(v) && Number(v) <= 2147483647 ? Number(v) : undefined; + }; + const [servers, cpu, mem, storage] = [maxServers, maxCpu, maxMem, maxStorage].map(parse); + if (servers === undefined || cpu === undefined || mem === undefined || storage === undefined) { + setErr(t("errors:invalid_quota")); + setSaving(false); + return; + } try { - const toNum = (s: string) => (s === "" ? null : parseInt(s, 10)); await api.setUserQuotas(userId, { - max_servers: toNum(maxServers), - max_cpu_milli: toNum(maxCpu), - max_memory_mb: toNum(maxMem), - max_storage_gb: toNum(maxStorage), + max_servers: servers, + max_cpu_milli: cpu, + max_memory_mb: mem, + max_storage_gb: storage, }); setOk(t("users_save_ok")); reload(); @@ -464,7 +478,8 @@ function QuotasCard({ userId }: { userId: string }) { setMaxServers(e.target.value)} placeholder={t("users_quota_unlimited")} @@ -475,7 +490,8 @@ function QuotasCard({ userId }: { userId: string }) { setMaxCpu(e.target.value)} placeholder={t("users_quota_unlimited")} @@ -486,7 +502,8 @@ function QuotasCard({ userId }: { userId: string }) { setMaxMem(e.target.value)} placeholder={t("users_quota_unlimited")} @@ -497,7 +514,8 @@ function QuotasCard({ userId }: { userId: string }) { setMaxStorage(e.target.value)} placeholder={t("users_quota_unlimited")} @@ -506,6 +524,8 @@ function QuotasCard({ userId }: { userId: string }) { +

{t("users_quota_hint")}

+ {err && ( )}