fix(quota): 配额表单整体替换,留空即不限、0 即不给、负数和越界拒收,超配额的服务器仍可调小

This commit is contained in:
Lemon-miaow committed 2026-09-27 02:26:03 +08:00
1 parent ee4065b986
commit 7aa5034c1a
17 files changed
+346 -109

No files matched your search

+16 -6
View File
@@ -843,7 +843,7 @@ components:
QuotaView: QuotaView:
type: object 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] required: [user_id]
properties: properties:
user_id: { type: string } user_id: { type: string }
@@ -4186,6 +4186,11 @@ paths:
tags: [users] tags: [users]
operationId: setQuotas operationId: setQuotas
summary: Set a user's quotas (admin only). 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-face: [external]
x-felis-tier: owner x-felis-tier: owner
security: [{ sessionCookie: [] }] security: [{ sessionCookie: [] }]
@@ -4198,10 +4203,10 @@ paths:
schema: schema:
type: object type: object
properties: properties:
max_servers: { type: integer, nullable: true } max_servers: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 }
max_cpu_milli: { type: integer, nullable: true } max_cpu_milli: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 }
max_memory_mb: { type: integer, nullable: true } max_memory_mb: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 }
max_storage_gb: { type: integer, nullable: true } max_storage_gb: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 }
responses: responses:
'200': '200':
description: Quotas updated. description: Quotas updated.
@@ -4209,7 +4214,12 @@ paths:
application/json: application/json:
schema: { $ref: '#/components/schemas/QuotaView' } schema: { $ref: '#/components/schemas/QuotaView' }
'400': '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': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '403':
+3 -15
View File
@@ -1511,21 +1511,9 @@ func (f *fakeRepo) SetQuotas(_ context.Context, userID string, qi QuotaInput, _
if f.fakeQuotas == nil { if f.fakeQuotas == nil {
f.fakeQuotas = map[string]*QuotaView{} f.fakeQuotas = map[string]*QuotaView{}
} }
if _, ok := f.fakeQuotas[userID]; !ok { // A full replacement, like the SQL upsert: nil is unlimited.
f.fakeQuotas[userID] = &QuotaView{UserID: userID} f.fakeQuotas[userID] = &QuotaView{UserID: userID, MaxServers: qi.MaxServers,
} MaxCPUMilli: qi.MaxCPUMilli, MaxMemoryMB: qi.MaxMemoryMB, MaxStorageGB: qi.MaxStorageGB}
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
}
return f.fakeQuotas[userID], nil return f.fakeQuotas[userID], nil
} }
+40
View File
@@ -434,3 +434,43 @@ func TestPatchServerClearsDisplayName(t *testing.T) {
t.Fatalf("patched displayName = %v, want an empty one", p.DisplayName) 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)
}
}
}
+12 -11
View File
@@ -1041,7 +1041,17 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err) writeError(w, r, err)
return 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, ok, err := a.Repo.QuotaCheck(r.Context(), rec.OwnerID, name,
ResourceSpec{CPUMilli: newCPU, MemoryMB: newMemMB}) ResourceSpec{CPUMilli: newCPU, MemoryMB: newMemMB})
if err != nil { 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 // A resource patch cannot change storage, so its cached contribution must
// be preserved: passing 0 would silently zero the storage dimension of the // be preserved: passing 0 would silently zero the storage dimension of the
// owner's four-cap aggregate (the cached columns are its only input). // owner's four-cap aggregate (the cached columns are its only input).
storMB := 0 _ = a.Repo.UpdateServerResources(r.Context(), name, newCPU, newMemMB, cur.StorageMB)
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)
} else { } else {
if err := a.Cluster.PatchServerSpec(r.Context(), name, patch); err != nil { if err := a.Cluster.PatchServerSpec(r.Context(), name, patch); err != nil {
a.writeLookupError(w, r, err) a.writeLookupError(w, r, err)
+18 -5
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"errors" "errors"
"math"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -317,11 +318,23 @@ func (a *API) handleSetQuotas(w http.ResponseWriter, r *http.Request) {
return return
} }
// Reject a body where every field is nil — a silent no-op is a client mistake. // The body replaces all four caps; an empty one lifts every cap. A negative cap
if body.MaxServers == nil && body.MaxCPUMilli == nil && body.MaxMemoryMB == nil && body.MaxStorageGB == nil { // would refuse every claim the way 0 does while reading like a mistake, and the
writeError(w, r, newError(http.StatusBadRequest, "bad_request", // columns are 32-bit.
"at least one quota field must be set")) for _, f := range []struct {
return 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) v, err := a.Repo.SetQuotas(r.Context(), id, body, p.Email)
+63
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"net/http" "net/http"
"strings"
"testing" "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)
}
+12 -56
View File
@@ -2027,55 +2027,23 @@ func (p *PGRepo) requireLiveUser(ctx context.Context, userID string) error {
return nil return nil
} }
// SetQuotas upserts a quotas row. Nil fields are left unchanged; a non-nil // SetQuotas replaces the user's quotas row. A nil field stores NULL, which every
// zero-value field clears the cap. // 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) { func (p *PGRepo) SetQuotas(ctx context.Context, userID string, qi QuotaInput, setBy string) (*QuotaView, error) {
if err := p.requireLiveUser(ctx, userID); err != nil { if err := p.requireLiveUser(ctx, userID); err != nil {
return nil, err 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{} v := QuotaView{}
switch err := p.db.QueryRowContext(ctx, query, args...).Scan( if err := p.db.QueryRowContext(ctx,
&v.UserID, &v.MaxServers, &v.MaxCPUMilli, &v.MaxMemoryMB, &v.MaxStorageGB); { `INSERT INTO quotas (user_id, updated_by, max_servers, max_cpu_milli, max_memory_mb, max_storage_gb)
case err != nil: 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 nil, err
} }
return &v, nil return &v, nil
@@ -2735,18 +2703,6 @@ func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string,
return err 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 // isUniqueViolation reports whether err is a Postgres unique-constraint
// violation (code 23505). // violation (code 23505).
func isUniqueViolation(err error) bool { func isUniqueViolation(err error) bool {
+5 -4
View File
@@ -723,8 +723,8 @@ type Repo interface {
// GetQuotas returns the quotas row for a user, or a zero-value view when no // GetQuotas returns the quotas row for a user, or a zero-value view when no
// row exists (which means unlimited per spec §9.3). // row exists (which means unlimited per spec §9.3).
GetQuotas(ctx context.Context, userID string) (*QuotaView, error) GetQuotas(ctx context.Context, userID string) (*QuotaView, error)
// SetQuotas upserts a quotas row for userID. Nil fields leave the column // SetQuotas replaces the quotas row for userID with q: a nil field is stored as
// untouched; a zero-value (non-nil) field clears the cap (unlimited). // NULL (unlimited), any other value is the cap, 0 included.
SetQuotas(ctx context.Context, userID string, q QuotaInput, setBy string) (*QuotaView, error) SetQuotas(ctx context.Context, userID string, q QuotaInput, setBy string) (*QuotaView, error)
// ---- session admin (admin-only) ---- // ---- session admin (admin-only) ----
@@ -854,8 +854,9 @@ type QuotaView struct {
MaxStorageGB *int `json:"max_storage_gb,omitempty"` MaxStorageGB *int `json:"max_storage_gb,omitempty"`
} }
// QuotaInput is the admin set-quotas form. Nil fields are left unchanged; // QuotaInput is the admin set-quotas form. It replaces all four caps at once: an
// a non-nil zero-value field clears the cap (unlimited). // 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 { type QuotaInput struct {
MaxServers *int `json:"max_servers,omitempty"` MaxServers *int `json:"max_servers,omitempty"`
MaxCPUMilli *int `json:"max_cpu_milli,omitempty"` MaxCPUMilli *int `json:"max_cpu_milli,omitempty"`
+73
View File
@@ -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 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 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 // the unclaimed rows too, since only those may be claimed. A soft-deleted row is
@@ -218,6 +218,7 @@
"users_quota_memory": "Max Memory (MiB)", "users_quota_memory": "Max Memory (MiB)",
"users_quota_storage": "Max Storage (GiB)", "users_quota_storage": "Max Storage (GiB)",
"users_quota_unlimited": "Unlimited", "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_sessions": "Sessions",
"users_no_sessions": "No active sessions.", "users_no_sessions": "No active sessions.",
"users_session_expires": "Expires", "users_session_expires": "Expires",
@@ -74,6 +74,7 @@
"account_retired": "This account has been retired — sign in with the account it was migrated to.", "account_retired": "This account has been retired — sign in with the account it was migrated to.",
"no_migration": "There is no migration in progress.", "no_migration": "There is no migration in progress.",
"not_confirmed": "Confirm it's you in this browser first. A confirmation lasts 10 minutes.", "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.", "already_confirmed": "This migration has already been confirmed.",
"invalid_target": "The migration target must be a different account.", "invalid_target": "The migration target must be a different account.",
"target_not_found": "No account matches that migration target.", "target_not_found": "No account matches that migration target.",
@@ -217,6 +217,7 @@
"users_quota_memory": "最大内存(MiB)", "users_quota_memory": "最大内存(MiB)",
"users_quota_storage": "最大存储(GiB)", "users_quota_storage": "最大存储(GiB)",
"users_quota_unlimited": "无限制", "users_quota_unlimited": "无限制",
"users_quota_hint": "留空为不限。填 0 表示这一项不给额度,该用户需要它的认领都会被拒绝。调低到已用量以下只挡新的认领,已有的服务器不受影响。",
"users_sessions": "活跃会话", "users_sessions": "活跃会话",
"users_no_sessions": "无活跃会话。", "users_no_sessions": "无活跃会话。",
"users_session_expires": "过期时间", "users_session_expires": "过期时间",
@@ -74,6 +74,7 @@
"account_retired": "该账户已退役——请使用迁移后的账户登录。", "account_retired": "该账户已退役——请使用迁移后的账户登录。",
"no_migration": "当前没有进行中的迁移。", "no_migration": "当前没有进行中的迁移。",
"not_confirmed": "请先在当前浏览器完成身份确认,确认 10 分钟内有效。", "not_confirmed": "请先在当前浏览器完成身份确认,确认 10 分钟内有效。",
"invalid_quota": "配额要填 0 到 2147483647 之间的整数,不限就留空。",
"already_confirmed": "该迁移已经确认过了。", "already_confirmed": "该迁移已经确认过了。",
"invalid_target": "迁移目标账户不能与来源账户相同。", "invalid_target": "迁移目标账户不能与来源账户相同。",
"target_not_found": "找不到迁移目标账户。", "target_not_found": "找不到迁移目标账户。",
+2
View File
@@ -1146,6 +1146,8 @@ export function humanizeError(e: unknown): string {
return t("no_migration"); return t("no_migration");
case "not_confirmed": case "not_confirmed":
return t("not_confirmed"); return t("not_confirmed");
case "invalid_quota":
return t("invalid_quota");
case "already_confirmed": case "already_confirmed":
return t("already_confirmed"); return t("already_confirmed");
case "invalid_target": case "invalid_target":
+14 -3
View File
@@ -1263,7 +1263,10 @@ export interface paths {
}; };
/** Get a user's quotas (admin only). */ /** Get a user's quotas (admin only). */
get: operations["getQuotas"]; 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"]; put: operations["setQuotas"];
post?: never; post?: never;
delete?: never; delete?: never;
@@ -2620,7 +2623,7 @@ export interface components {
verified_at: string; 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: { QuotaView: {
user_id: string; user_id: string;
max_servers?: number | null; max_servers?: number | null;
@@ -6119,7 +6122,15 @@ export interface operations {
"application/json": components["schemas"]["QuotaView"]; "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"]; 401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"]; 403: components["responses"]["Forbidden"];
/** @description Unknown user. */ /** @description Unknown user. */
@@ -10,6 +10,7 @@ import type { UserDetail } from "@/lib/types";
const calls = vi.hoisted(() => ({ const calls = vi.hoisted(() => ({
getUser: vi.fn(), getUser: vi.fn(),
getUserQuotas: vi.fn(), getUserQuotas: vi.fn(),
setUserQuotas: vi.fn(),
listUserSessions: vi.fn(), listUserSessions: vi.fn(),
revokeUserSession: vi.fn(), revokeUserSession: vi.fn(),
revokeUserSessions: 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);
});
});
+29 -9
View File
@@ -428,13 +428,27 @@ function QuotasCard({ userId }: { userId: string }) {
setSaving(true); setSaving(true);
setErr(null); setErr(null);
setOk(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 { try {
const toNum = (s: string) => (s === "" ? null : parseInt(s, 10));
await api.setUserQuotas(userId, { await api.setUserQuotas(userId, {
max_servers: toNum(maxServers), max_servers: servers,
max_cpu_milli: toNum(maxCpu), max_cpu_milli: cpu,
max_memory_mb: toNum(maxMem), max_memory_mb: mem,
max_storage_gb: toNum(maxStorage), max_storage_gb: storage,
}); });
setOk(t("users_save_ok")); setOk(t("users_save_ok"));
reload(); reload();
@@ -464,7 +478,8 @@ function QuotasCard({ userId }: { userId: string }) {
<Label htmlFor="user-detail-quota-servers" className="text-xs font-semibold text-muted-foreground">{t("users_quota_servers")}</Label> <Label htmlFor="user-detail-quota-servers" className="text-xs font-semibold text-muted-foreground">{t("users_quota_servers")}</Label>
<Input <Input
id="user-detail-quota-servers" id="user-detail-quota-servers"
type="number" type="text"
inputMode="numeric"
value={maxServers} value={maxServers}
onChange={(e) => setMaxServers(e.target.value)} onChange={(e) => setMaxServers(e.target.value)}
placeholder={t("users_quota_unlimited")} placeholder={t("users_quota_unlimited")}
@@ -475,7 +490,8 @@ function QuotasCard({ userId }: { userId: string }) {
<Label htmlFor="user-detail-quota-cpu" className="text-xs font-semibold text-muted-foreground">{t("users_quota_cpu")}</Label> <Label htmlFor="user-detail-quota-cpu" className="text-xs font-semibold text-muted-foreground">{t("users_quota_cpu")}</Label>
<Input <Input
id="user-detail-quota-cpu" id="user-detail-quota-cpu"
type="number" type="text"
inputMode="numeric"
value={maxCpu} value={maxCpu}
onChange={(e) => setMaxCpu(e.target.value)} onChange={(e) => setMaxCpu(e.target.value)}
placeholder={t("users_quota_unlimited")} placeholder={t("users_quota_unlimited")}
@@ -486,7 +502,8 @@ function QuotasCard({ userId }: { userId: string }) {
<Label htmlFor="user-detail-quota-memory" className="text-xs font-semibold text-muted-foreground">{t("users_quota_memory")}</Label> <Label htmlFor="user-detail-quota-memory" className="text-xs font-semibold text-muted-foreground">{t("users_quota_memory")}</Label>
<Input <Input
id="user-detail-quota-memory" id="user-detail-quota-memory"
type="number" type="text"
inputMode="numeric"
value={maxMem} value={maxMem}
onChange={(e) => setMaxMem(e.target.value)} onChange={(e) => setMaxMem(e.target.value)}
placeholder={t("users_quota_unlimited")} placeholder={t("users_quota_unlimited")}
@@ -497,7 +514,8 @@ function QuotasCard({ userId }: { userId: string }) {
<Label htmlFor="user-detail-quota-storage" className="text-xs font-semibold text-muted-foreground">{t("users_quota_storage")}</Label> <Label htmlFor="user-detail-quota-storage" className="text-xs font-semibold text-muted-foreground">{t("users_quota_storage")}</Label>
<Input <Input
id="user-detail-quota-storage" id="user-detail-quota-storage"
type="number" type="text"
inputMode="numeric"
value={maxStorage} value={maxStorage}
onChange={(e) => setMaxStorage(e.target.value)} onChange={(e) => setMaxStorage(e.target.value)}
placeholder={t("users_quota_unlimited")} placeholder={t("users_quota_unlimited")}
@@ -506,6 +524,8 @@ function QuotasCard({ userId }: { userId: string }) {
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground">{t("users_quota_hint")}</p>
{err && ( {err && (
<MessageLine kind="error" message={err} /> <MessageLine kind="error" message={err} />
)} )}