fix(panel): 服务器列表的归属与可认领改由后端按账号判定,无邮箱管理员也能认出自己的服务器,所有者查询失败时显示未知且不给认领
This commit is contained in:
14 files changed
+323
-70
No files matched your search
@@ -339,6 +339,7 @@ components:
|
|||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/components/schemas/ServerInfo'
|
- $ref: '#/components/schemas/ServerInfo'
|
||||||
- type: object
|
- type: object
|
||||||
|
required: [owned, claimable]
|
||||||
properties:
|
properties:
|
||||||
owner:
|
owner:
|
||||||
type: string
|
type: string
|
||||||
@@ -346,6 +347,21 @@ components:
|
|||||||
The owner's display identity (email, or username when the address is
|
The owner's display identity (email, or username when the address is
|
||||||
absent). Absent for an unclaimed server or when the best-effort owner
|
absent). Absent for an unclaimed server or when the best-effort owner
|
||||||
lookup failed.
|
lookup failed.
|
||||||
|
owned:
|
||||||
|
type: boolean
|
||||||
|
description: >-
|
||||||
|
True when the caller claimed this server, decided by account id so an
|
||||||
|
owner without an email is still recognized.
|
||||||
|
claimable:
|
||||||
|
type: boolean
|
||||||
|
description: >-
|
||||||
|
True for a live, unclaimed, non-system server, the same rule the claim
|
||||||
|
route enforces. False whenever ownership is unknown.
|
||||||
|
ownerUnknown:
|
||||||
|
type: boolean
|
||||||
|
description: >-
|
||||||
|
Present and true when the owner lookup failed, so an absent owner says
|
||||||
|
nothing about whether the server is claimed.
|
||||||
system:
|
system:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: >-
|
description: >-
|
||||||
|
|||||||
+58
-23
@@ -33,10 +33,10 @@ type fakeRepo struct {
|
|||||||
// the account_links-bridged web view of the same data.
|
// the account_links-bridged web view of the same data.
|
||||||
allowUUID map[string]map[string]bool
|
allowUUID map[string]map[string]bool
|
||||||
mine map[string][]MyServerView
|
mine map[string][]MyServerView
|
||||||
// owners mirrors the ServerOwners join (name -> owner display identity); only
|
// owners mirrors the ServerOwners join (name -> claim state); a live unclaimed
|
||||||
// claimed servers appear. ownersErr forces the lookup to fail so a test can
|
// server appears with an empty OwnerID. ownersErr forces the lookup to fail so
|
||||||
// prove the fleet read degrades to owner-less rows rather than 500ing.
|
// a test can prove the fleet read degrades rather than 500ing.
|
||||||
owners map[string]string
|
owners map[string]ServerOwnership
|
||||||
ownersErr error
|
ownersErr error
|
||||||
claimOK map[string]bool // name -> claim succeeds; absent name -> ErrNotFound
|
claimOK map[string]bool // name -> claim succeeds; absent name -> ErrNotFound
|
||||||
// claimQuotaRefuse simulates ClaimServer's atomic quota gate (audit #4)
|
// claimQuotaRefuse simulates ClaimServer's atomic quota gate (audit #4)
|
||||||
@@ -255,7 +255,7 @@ func newFakeRepo() *fakeRepo {
|
|||||||
linked: map[string]bool{}, quota: map[string]bool{},
|
linked: map[string]bool{}, quota: map[string]bool{},
|
||||||
allowlist: map[string]map[string]bool{}, allowUUID: map[string]map[string]bool{},
|
allowlist: map[string]map[string]bool{}, allowUUID: map[string]map[string]bool{},
|
||||||
mine: map[string][]MyServerView{},
|
mine: map[string][]MyServerView{},
|
||||||
owners: map[string]string{},
|
owners: map[string]ServerOwnership{},
|
||||||
claimOK: map[string]bool{}, claimQuotaRefuse: map[string]bool{},
|
claimOK: map[string]bool{}, claimQuotaRefuse: map[string]bool{},
|
||||||
serverResources: map[string]ResourceSpec{}, resourceUpdates: map[string]ResourceSpec{},
|
serverResources: map[string]ResourceSpec{}, resourceUpdates: map[string]ResourceSpec{},
|
||||||
seeded: map[string]bool{}, aliases: map[string]string{},
|
seeded: map[string]bool{}, aliases: map[string]string{},
|
||||||
@@ -750,7 +750,7 @@ func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error
|
|||||||
// into the slice it gets.
|
// into the slice it gets.
|
||||||
return append([]MyServerView(nil), f.mine[u]...), nil
|
return append([]MyServerView(nil), f.mine[u]...), nil
|
||||||
}
|
}
|
||||||
func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) {
|
func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]ServerOwnership, error) {
|
||||||
if f.ownersErr != nil {
|
if f.ownersErr != nil {
|
||||||
return nil, f.ownersErr
|
return nil, f.ownersErr
|
||||||
}
|
}
|
||||||
@@ -1971,11 +1971,13 @@ func TestFleetAdminRead(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// fleetRow mirrors the on-the-wire fleetServerView: the lifecycle fields plus
|
// fleetRow mirrors the on-the-wire fleetServerView: the lifecycle fields plus
|
||||||
// the presentational owner join. A server absent from ServerOwners (unclaimed)
|
// the ownership join.
|
||||||
// or a failed lookup must serialize owner as "" (omitempty drops it).
|
|
||||||
type fleetRow struct {
|
type fleetRow struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
|
Owned bool `json:"owned"`
|
||||||
|
Claimable bool `json:"claimable"`
|
||||||
|
OwnerUnknown bool `json:"ownerUnknown"`
|
||||||
}
|
}
|
||||||
adminAPI := func(repo *fakeRepo) *API {
|
adminAPI := func(repo *fakeRepo) *API {
|
||||||
api := newTestAPI(repo, cl)
|
api := newTestAPI(repo, cl)
|
||||||
@@ -2042,33 +2044,66 @@ func TestFleetAdminRead(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("owner merges for claimed, absent for unclaimed", func(t *testing.T) {
|
t.Run("ownership comes from the account id", func(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
// Only "survival" is claimed; "creative"/"skyblock" stay unowned.
|
// The caller (a1) owns "survival" but has no email, so its display is the
|
||||||
repo.owners["survival"] = "[email protected]"
|
// username; "creative" belongs to someone else; "skyblock" is unclaimed.
|
||||||
byName := map[string]string{}
|
repo.owners["survival"] = ServerOwnership{OwnerID: "a1", Owner: "a1-username"}
|
||||||
|
repo.owners["creative"] = ServerOwnership{OwnerID: "u2", Owner: "[email protected]"}
|
||||||
|
repo.owners["skyblock"] = ServerOwnership{}
|
||||||
|
byName := map[string]fleetRow{}
|
||||||
for _, r := range readFleet(t, adminAPI(repo)) {
|
for _, r := range readFleet(t, adminAPI(repo)) {
|
||||||
byName[r.Name] = r.Owner
|
byName[r.Name] = r
|
||||||
}
|
}
|
||||||
if byName["survival"] != "[email protected]" {
|
want := map[string]fleetRow{
|
||||||
t.Fatalf("survival owner = %q, want [email protected]", byName["survival"])
|
"survival": {Name: "survival", Owner: "a1-username", Owned: true},
|
||||||
|
"creative": {Name: "creative", Owner: "[email protected]"},
|
||||||
|
"skyblock": {Name: "skyblock", Claimable: true},
|
||||||
}
|
}
|
||||||
if byName["creative"] != "" {
|
for name, w := range want {
|
||||||
t.Fatalf("creative owner = %q, want empty (unclaimed)", byName["creative"])
|
if byName[name] != w {
|
||||||
|
t.Errorf("%s = %+v, want %+v", name, byName[name], w)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("owner lookup failure degrades to owner-less rows", func(t *testing.T) {
|
t.Run("a server without a business row cannot be claimed", func(t *testing.T) {
|
||||||
|
// A CRD the servers table does not know (or a soft-deleted row) answers a
|
||||||
|
// claim with 404, so the row must not offer one.
|
||||||
|
rows := readFleet(t, adminAPI(newFakeRepo()))
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Claimable || r.Owned || r.OwnerUnknown {
|
||||||
|
t.Errorf("%s = %+v, want no claim state (no business row)", r.Name, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("system services are never claimable", func(t *testing.T) {
|
||||||
|
sysCl := newFakeCluster()
|
||||||
|
sysCl.list = []ServerInfo{{Name: "lobby", Phase: "Running", Ready: true}}
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
repo.owners["survival"] = "[email protected]" // would merge, but the lookup errors
|
repo.owners["lobby"] = ServerOwnership{}
|
||||||
|
api := newTestAPI(repo, sysCl)
|
||||||
|
api.External = staticExternal{p: &Principal{UserID: "a1", Email: "[email protected]",
|
||||||
|
Role: "admin", ViaAdminAccess: true}}
|
||||||
|
rows := readFleet(t, api)
|
||||||
|
if len(rows) != 1 || rows[0].Claimable {
|
||||||
|
t.Fatalf("rows = %+v, want lobby present and not claimable", rows)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("owner lookup failure marks ownership unknown", func(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.owners["survival"] = ServerOwnership{OwnerID: "u2", Owner: "[email protected]"} // would merge, but the lookup errors
|
||||||
|
repo.owners["skyblock"] = ServerOwnership{}
|
||||||
repo.ownersErr = fmt.Errorf("postgres unreachable")
|
repo.ownersErr = fmt.Errorf("postgres unreachable")
|
||||||
rows := readFleet(t, adminAPI(repo)) // must still be 200, not 500
|
rows := readFleet(t, adminAPI(repo)) // must still be 200, not 500
|
||||||
if len(rows) != 3 {
|
if len(rows) != 3 {
|
||||||
t.Fatalf("servers = %d, want 3 (a Postgres blip must not drop the fleet)", len(rows))
|
t.Fatalf("servers = %d, want 3 (a Postgres blip must not drop the fleet)", len(rows))
|
||||||
}
|
}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
if r.Owner != "" {
|
if r.Owner != "" || r.Claimable || r.Owned || !r.OwnerUnknown {
|
||||||
t.Fatalf("%s owner = %q, want empty (owner lookup failed → degrade)", r.Name, r.Owner)
|
t.Fatalf("%s = %+v, want owner unknown and nothing to claim", r.Name, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -296,15 +296,20 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Owner is presentational and best-effort. The cockpit exists for the lifecycle
|
// Ownership is best-effort. The cockpit exists for the lifecycle view, so a
|
||||||
// view, so a Postgres hiccup must degrade to owner-less rows, never 500 the whole
|
// Postgres hiccup must never 500 the whole fleet; the rows say the owner is
|
||||||
// fleet: a lookup error is swallowed and owners stays nil, leaving every row's
|
// unknown instead, and none offers a claim that may already be taken.
|
||||||
// Owner "" (a nil map reads as zero values).
|
p := principalFromContext(r.Context())
|
||||||
owners, _ := a.Repo.ServerOwners(r.Context())
|
owners, err := a.Repo.ServerOwners(r.Context())
|
||||||
|
unknown := err != nil
|
||||||
views := make([]fleetServerView, len(servers))
|
views := make([]fleetServerView, len(servers))
|
||||||
for i, s := range servers {
|
for i, s := range servers {
|
||||||
views[i] = fleetServerView{ServerInfo: s, Owner: owners[s.Name],
|
o, known := owners[s.Name]
|
||||||
System: naming.IsSystemServer(s.Name)}
|
system := naming.IsSystemServer(s.Name)
|
||||||
|
views[i] = fleetServerView{ServerInfo: s, Owner: o.Owner, System: system,
|
||||||
|
Owned: o.OwnerID != "" && o.OwnerID == p.UserID,
|
||||||
|
Claimable: known && o.OwnerID == "" && !system,
|
||||||
|
OwnerUnknown: unknown}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"servers": views})
|
writeJSON(w, http.StatusOK, map[string]any{"servers": views})
|
||||||
}
|
}
|
||||||
@@ -316,9 +321,18 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
|
|||||||
type fleetServerView struct {
|
type fleetServerView struct {
|
||||||
ServerInfo
|
ServerInfo
|
||||||
// Owner is the claiming user's display identity (email, or username when the
|
// Owner is the claiming user's display identity (email, or username when the
|
||||||
// address is absent), or "" when the server is unclaimed or the best-effort
|
// address is absent), or "" when the server is unclaimed or the owner lookup
|
||||||
// owner lookup failed — the cockpit renders "" as "unclaimed".
|
// failed (OwnerUnknown tells the two apart).
|
||||||
Owner string `json:"owner,omitempty"`
|
Owner string `json:"owner,omitempty"`
|
||||||
|
// Owned is true when the caller claimed this server, decided by account id so
|
||||||
|
// an owner without an email is still recognized.
|
||||||
|
Owned bool `json:"owned"`
|
||||||
|
// Claimable is true for a live, unclaimed, non-system server: the same rule
|
||||||
|
// ClaimServer enforces. It is false whenever ownership is unknown.
|
||||||
|
Claimable bool `json:"claimable"`
|
||||||
|
// OwnerUnknown is true when the best-effort owner lookup failed, so an empty
|
||||||
|
// Owner says nothing about whether the server is claimed.
|
||||||
|
OwnerUnknown bool `json:"ownerUnknown,omitempty"`
|
||||||
// System marks a platform-provisioned system service (the login gate and the
|
// System marks a platform-provisioned system service (the login gate and the
|
||||||
// lobby, naming.IsSystemServer). Their names are reserved, so every per-server
|
// lobby, naming.IsSystemServer). Their names are reserved, so every per-server
|
||||||
// API route rejects them — the cockpit must render them read-only rather than
|
// API route rejects them — the cockpit must render them read-only rather than
|
||||||
|
|||||||
+14
-14
@@ -609,29 +609,29 @@ func (p *PGRepo) MyServers(ctx context.Context, userID string) ([]MyServerView,
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerOwners returns name -> owner display identity for every currently-owned,
|
// ServerOwners returns name -> claim state for every non-deleted server (the
|
||||||
// non-deleted server (the SysAdmin cockpit's fleet read). The INNER JOIN drops
|
// SysAdmin cockpit's fleet read). The LEFT JOIN keeps unclaimed servers (owner_id
|
||||||
// unclaimed servers (owner_id NULL) and the deleted_at filter drops soft-deleted
|
// NULL) with an empty OwnerID, and the deleted_at filter drops soft-deleted ones.
|
||||||
// ones, so the map holds only servers that have a live owner — the cockpit reads a
|
// The display value prefers the recognizable email (the same identity the audit
|
||||||
// missing key as "no owner". The display value prefers the recognizable email
|
// log records as the human actor, §6) and falls back to the never-NULL username
|
||||||
// (the same identity the audit log records as the human actor, §6) and falls back
|
// when the address is absent.
|
||||||
// to the never-NULL username when the address is absent.
|
func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]ServerOwnership, error) {
|
||||||
func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]string, error) {
|
const q = `SELECT s.name, COALESCE(s.owner_id, ''), COALESCE(NULLIF(u.email, ''), u.username, '')
|
||||||
const q = `SELECT s.name, COALESCE(NULLIF(u.email, ''), u.username)
|
FROM servers s LEFT JOIN users u ON u.id = s.owner_id
|
||||||
FROM servers s JOIN users u ON u.id = s.owner_id
|
|
||||||
WHERE s.deleted_at IS NULL`
|
WHERE s.deleted_at IS NULL`
|
||||||
rows, err := p.db.QueryContext(ctx, q)
|
rows, err := p.db.QueryContext(ctx, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := make(map[string]string)
|
out := make(map[string]ServerOwnership)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name, owner string
|
var name string
|
||||||
if err := rows.Scan(&name, &owner); err != nil {
|
var o ServerOwnership
|
||||||
|
if err := rows.Scan(&name, &o.OwnerID, &o.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out[name] = owner
|
out[name] = o
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-9
@@ -38,6 +38,17 @@ type MyServerView struct {
|
|||||||
PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"`
|
PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServerOwnership is one live server's claim state as the fleet read joins it.
|
||||||
|
type ServerOwnership struct {
|
||||||
|
// OwnerID is the claiming account, "" while the server is unclaimed. The fleet
|
||||||
|
// compares it with the caller's id: an account without an email shows its
|
||||||
|
// username as Owner, so the display text cannot say whose server it is.
|
||||||
|
OwnerID string
|
||||||
|
// Owner is the claiming account's display identity (email, or username when
|
||||||
|
// the address is absent), "" while unclaimed.
|
||||||
|
Owner string
|
||||||
|
}
|
||||||
|
|
||||||
// AuditEntry is one row written to audit_logs (spec §6). Actor is display text:
|
// AuditEntry is one row written to audit_logs (spec §6). Actor is display text:
|
||||||
// a verified email or the username for people (auditActor), the component name
|
// a verified email or the username for people (auditActor), the component name
|
||||||
// for internal callers. ActorUserID is the account that acted, the column to
|
// for internal callers. ActorUserID is the account that acted, the column to
|
||||||
@@ -286,15 +297,14 @@ type Repo interface {
|
|||||||
RecordJoin(ctx context.Context, name, mcUUID string) error
|
RecordJoin(ctx context.Context, name, mcUUID string) error
|
||||||
// MyServers lists the servers a user owns or may claim.
|
// MyServers lists the servers a user owns or may claim.
|
||||||
MyServers(ctx context.Context, userID string) ([]MyServerView, error)
|
MyServers(ctx context.Context, userID string) ([]MyServerView, error)
|
||||||
// ServerOwners maps each currently-owned server to its owner's display identity
|
// ServerOwners maps every live server to its claim state, for the SysAdmin
|
||||||
// (email, or username when the address is absent), for the SysAdmin cockpit's
|
// cockpit's fleet read. It is a READ-ONLY join: owner stays authored in Postgres
|
||||||
// fleet read. It is a READ-ONLY presentational join: owner stays authored in
|
// (§6 business authority) and is never written back to the CRD, so this does not
|
||||||
// Postgres (§6 business authority) and is never written back to the CRD, so this
|
// breach §1's store-of-record split. An unclaimed server is present with an empty
|
||||||
// does not breach §1's store-of-record split. Unclaimed and soft-deleted servers
|
// OwnerID; a soft-deleted one, or a CRD with no business row, is absent, and
|
||||||
// are simply absent from the map, so a missing key reads as "no owner". The
|
// cannot be claimed. The cockpit treats it as best-effort: a lookup error leaves
|
||||||
// cockpit treats it as best-effort — a lookup error degrades to owner-less rows
|
// ownership unknown rather than failing the fleet read.
|
||||||
// rather than failing the fleet read — so callers may ignore the error.
|
ServerOwners(ctx context.Context) (map[string]ServerOwnership, error)
|
||||||
ServerOwners(ctx context.Context) (map[string]string, error)
|
|
||||||
// AllBackups lists every present world backup, newest first (spec §7 GET
|
// AllBackups lists every present world backup, newest first (spec §7 GET
|
||||||
// /backups, admin scope). Expired/deleted rows are never returned.
|
// /backups, admin scope). Expired/deleted rows are never returned.
|
||||||
AllBackups(ctx context.Context) ([]BackupView, error)
|
AllBackups(ctx context.Context) ([]BackupView, error)
|
||||||
|
|||||||
@@ -551,6 +551,54 @@ func TestClaimServerQuotaAtomicGate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// gone.
|
||||||
|
func TestServerOwnersJoin(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
sfx := suffix(t)
|
||||||
|
withEmail, err := repo.CreateUser(ctx,
|
||||||
|
api.CreateUserInput{Username: "own-mail-" + sfx, Email: "own-" + sfx + "@example.test", Role: "user"}, "pgint")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser with email: %v", err)
|
||||||
|
}
|
||||||
|
noEmail := newUser(t, "admin", "own-bare")
|
||||||
|
seed := func(name string, owner any, deleted bool) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.ExecContext(ctx,
|
||||||
|
`INSERT INTO servers (name, owner_id, deleted_at, cached_cpu_milli, cached_memory_mb, cached_storage_mb)
|
||||||
|
VALUES ($1, $2, CASE WHEN $3 THEN now() END, 100, 128, 1)`,
|
||||||
|
name, owner, deleted); err != nil {
|
||||||
|
t.Fatalf("seed server %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mailed, bare, free, gone := "om-"+sfx, "ob-"+sfx, "of-"+sfx, "od-"+sfx
|
||||||
|
seed(mailed, withEmail.ID, false)
|
||||||
|
seed(bare, noEmail.ID, false)
|
||||||
|
seed(free, nil, false)
|
||||||
|
seed(gone, nil, true)
|
||||||
|
|
||||||
|
owners, err := repo.ServerOwners(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ServerOwners: %v", err)
|
||||||
|
}
|
||||||
|
want := map[string]api.ServerOwnership{
|
||||||
|
mailed: {OwnerID: withEmail.ID, Owner: "own-" + sfx + "@example.test"},
|
||||||
|
bare: {OwnerID: noEmail.ID, Owner: noEmail.Username},
|
||||||
|
free: {},
|
||||||
|
}
|
||||||
|
for name, w := range want {
|
||||||
|
got, ok := owners[name]
|
||||||
|
if !ok || got != w {
|
||||||
|
t.Errorf("owners[%s] = %+v (present %v), want %+v", name, got, ok, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o, ok := owners[gone]; ok {
|
||||||
|
t.Errorf("owners[%s] = %+v, want absent (soft-deleted)", gone, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// An admin email edit must not carry a verification over to an address nobody
|
// An admin email edit must not carry a verification over to an address nobody
|
||||||
// proved: the verified flag is exactly what the pre-session login resolves on
|
// proved: the verified flag is exactly what the pre-session login resolves on
|
||||||
// (UserByEmail), and only VerifyEmailOTP may assert it — the same rationale as
|
// (UserByEmail), and only VerifyEmailOTP may assert it — the same rationale as
|
||||||
|
|||||||
@@ -590,8 +590,9 @@ function visibleServers(state: MockState, accountInfo: MockAccount): MyServerVie
|
|||||||
// the CRD field names (playersOnline/playersMax, ready, endpoint*) plus the
|
// the CRD field names (playersOnline/playersMax, ready, endpoint*) plus the
|
||||||
// runtime `ready`/`endpoint*` fields, and the owner joined as the email
|
// runtime `ready`/`endpoint*` fields, and the owner joined as the email
|
||||||
// (COALESCE(email, username) server-side). Endpoint and live player counts are
|
// (COALESCE(email, username) server-side). Endpoint and live player counts are
|
||||||
// gated on Running, exactly as the real cluster reports them.
|
// gated on Running, exactly as the real cluster reports them. Ownership is
|
||||||
function fleetView(state: MockState): FleetServer[] {
|
// decided by account id for the caller, as the Go handler does.
|
||||||
|
function fleetView(state: MockState, accountInfo: MockAccount): FleetServer[] {
|
||||||
return state.servers.map((s, i) => {
|
return state.servers.map((s, i) => {
|
||||||
const { owner, ...wire } = s;
|
const { owner, ...wire } = s;
|
||||||
return {
|
return {
|
||||||
@@ -600,6 +601,8 @@ function fleetView(state: MockState): FleetServer[] {
|
|||||||
endpointAddress: s.ready ? `10.43.0.${10 + i}:25565` : undefined,
|
endpointAddress: s.ready ? `10.43.0.${10 + i}:25565` : undefined,
|
||||||
playersOnline: s.ready ? s.playersOnline : 0,
|
playersOnline: s.ready ? s.playersOnline : 0,
|
||||||
owner: owner ? state.accounts[owner].email : "",
|
owner: owner ? state.accounts[owner].email : "",
|
||||||
|
owned: owner === accountInfo.id,
|
||||||
|
claimable: owner === null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -903,7 +906,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
sendJSON(ctx.res, 200, { servers: fleetView(ctx.state) });
|
sendJSON(ctx.res, 200, { servers: fleetView(ctx.state, ctx.account) });
|
||||||
return true;
|
return true;
|
||||||
case "GET backups":
|
case "GET backups":
|
||||||
// Admin sees every archive; a user only worlds they formerly owned — mirrors
|
// Admin sees every archive; a user only worlds they formerly owned — mirrors
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function StatCard({ icon: Icon, label, value, accentClass, accentColor, v
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xl sm:text-2xl font-bold font-mono leading-none truncate text-foreground",
|
"text-lg sm:text-2xl font-bold font-mono leading-none truncate text-foreground",
|
||||||
valueClass,
|
valueClass,
|
||||||
)}
|
)}
|
||||||
title={typeof value === "string" ? value : undefined}
|
title={typeof value === "string" ? value : undefined}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
"fleet_col_endpoint": "Endpoint",
|
"fleet_col_endpoint": "Endpoint",
|
||||||
"fleet_col_actions": "Actions",
|
"fleet_col_actions": "Actions",
|
||||||
"fleet_unclaimed": "Unclaimed",
|
"fleet_unclaimed": "Unclaimed",
|
||||||
|
"fleet_owner_you": "You",
|
||||||
|
"fleet_owner_unknown": "Unknown",
|
||||||
|
"fleet_owner_unknown_hint": "Couldn't look up the owner just now; it shows again on the next refresh.",
|
||||||
"fleet_endpoint_idle": "Not running",
|
"fleet_endpoint_idle": "Not running",
|
||||||
"policy_owneronly": "Owner only",
|
"policy_owneronly": "Owner only",
|
||||||
"policy_public": "Public",
|
"policy_public": "Public",
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
"fleet_col_endpoint": "连接地址",
|
"fleet_col_endpoint": "连接地址",
|
||||||
"fleet_col_actions": "操作",
|
"fleet_col_actions": "操作",
|
||||||
"fleet_unclaimed": "未认领",
|
"fleet_unclaimed": "未认领",
|
||||||
|
"fleet_owner_you": "你",
|
||||||
|
"fleet_owner_unknown": "未知",
|
||||||
|
"fleet_owner_unknown_hint": "暂时查不到所有者,下次刷新时会重新显示。",
|
||||||
"fleet_endpoint_idle": "未运行",
|
"fleet_endpoint_idle": "未运行",
|
||||||
"policy_owneronly": "仅所有者",
|
"policy_owneronly": "仅所有者",
|
||||||
"policy_public": "公开",
|
"policy_public": "公开",
|
||||||
|
|||||||
@@ -2005,6 +2005,12 @@ export interface components {
|
|||||||
FleetServer: components["schemas"]["ServerInfo"] & {
|
FleetServer: components["schemas"]["ServerInfo"] & {
|
||||||
/** @description The owner's display identity (email, or username when the address is absent). Absent for an unclaimed server or when the best-effort owner lookup failed. */
|
/** @description The owner's display identity (email, or username when the address is absent). Absent for an unclaimed server or when the best-effort owner lookup failed. */
|
||||||
owner?: string;
|
owner?: string;
|
||||||
|
/** @description True when the caller claimed this server, decided by account id so an owner without an email is still recognized. */
|
||||||
|
owned: boolean;
|
||||||
|
/** @description True for a live, unclaimed, non-system server, the same rule the claim route enforces. False whenever ownership is unknown. */
|
||||||
|
claimable: boolean;
|
||||||
|
/** @description Present and true when the owner lookup failed, so an absent owner says nothing about whether the server is claimed. */
|
||||||
|
ownerUnknown?: boolean;
|
||||||
/** @description True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. */
|
/** @description True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. */
|
||||||
system?: boolean;
|
system?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -132,9 +132,15 @@ export interface KickResult {
|
|||||||
* Sharing one interface would blur which fields each face actually guarantees. */
|
* Sharing one interface would blur which fields each face actually guarantees. */
|
||||||
export interface FleetServer extends ServerStatus {
|
export interface FleetServer extends ServerStatus {
|
||||||
/** Owner's display identity (email, or username when the address is absent).
|
/** Owner's display identity (email, or username when the address is absent).
|
||||||
* Empty/absent for an unclaimed server or when the best-effort owner lookup
|
* Empty/absent for an unclaimed server or when the owner lookup failed;
|
||||||
* failed — the cockpit renders that as "unclaimed". */
|
* ownerUnknown tells the two apart. */
|
||||||
owner?: string;
|
owner?: string;
|
||||||
|
/** The caller claimed this server, decided by account id on the server. */
|
||||||
|
owned: boolean;
|
||||||
|
/** Live, unclaimed and not a system service; false while ownership is unknown. */
|
||||||
|
claimable: boolean;
|
||||||
|
/** The owner lookup failed: an absent owner says nothing about the claim. */
|
||||||
|
ownerUnknown?: boolean;
|
||||||
/** True for a platform-provisioned system service (the login gate, the lobby).
|
/** True for a platform-provisioned system service (the login gate, the lobby).
|
||||||
* Their reserved names are rejected by every per-server route, so the cockpit
|
* Their reserved names are rejected by every per-server route, so the cockpit
|
||||||
* renders them read-only instead of offering actions that would 400. */
|
* renders them read-only instead of offering actions that would 400. */
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import type { FleetServer } from "@/lib/types";
|
||||||
|
import { ServersPage } from "./ServersPage";
|
||||||
|
|
||||||
|
const tier = vi.hoisted(() => ({ isAdmin: true, identity: { email: "[email protected]" } }));
|
||||||
|
const calls = vi.hoisted(() => ({ fleet: vi.fn(), myServers: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
|
||||||
|
vi.mock("@/lib/config", async (importActual) => {
|
||||||
|
const actual = await importActual<typeof import("@/lib/config")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test" }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("@/lib/api", async (importActual) => {
|
||||||
|
const actual = await importActual<typeof import("@/lib/api")>();
|
||||||
|
return { ...actual, api: { ...actual.api, ...calls } };
|
||||||
|
});
|
||||||
|
|
||||||
|
function row(name: string, over: Partial<FleetServer>): FleetServer {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
subdomain: name,
|
||||||
|
phase: "Stopped",
|
||||||
|
ready: false,
|
||||||
|
playersOnline: 0,
|
||||||
|
playersMax: 20,
|
||||||
|
owned: false,
|
||||||
|
claimable: false,
|
||||||
|
...over,
|
||||||
|
} as FleetServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The desktop table row of one server (the phone cards render the same data).
|
||||||
|
async function tableRow(name: string) {
|
||||||
|
const table = await screen.findByRole("table");
|
||||||
|
const cell = within(table).getByText(name);
|
||||||
|
const tr = cell.closest("tr");
|
||||||
|
if (!tr) throw new Error(`no row for ${name}`);
|
||||||
|
return within(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls.fleet.mockReset();
|
||||||
|
calls.myServers.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ServersPage fleet ownership", () => {
|
||||||
|
it("trusts the server's ownership and claim flags over the owner text", async () => {
|
||||||
|
calls.fleet.mockResolvedValue([
|
||||||
|
// The caller has no email: its own server shows the username, which never
|
||||||
|
// equals identity.email, yet the row is still marked as the caller's.
|
||||||
|
row("survival", { owner: "steve-mc", owned: true }),
|
||||||
|
row("creative", { owner: "[email protected]" }),
|
||||||
|
row("skyblock", { claimable: true }),
|
||||||
|
]);
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ServersPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const survival = await tableRow("survival");
|
||||||
|
expect(survival.getByText("You")).toBeTruthy();
|
||||||
|
expect(survival.getByText("steve-mc")).toBeTruthy();
|
||||||
|
expect(survival.queryByRole("button", { name: /Claim/ })).toBeNull();
|
||||||
|
|
||||||
|
const creative = await tableRow("creative");
|
||||||
|
expect(creative.queryByText("You")).toBeNull();
|
||||||
|
expect(creative.queryByRole("button", { name: /Claim/ })).toBeNull();
|
||||||
|
|
||||||
|
const skyblock = await tableRow("skyblock");
|
||||||
|
expect(skyblock.getByText("Unclaimed")).toBeTruthy();
|
||||||
|
expect(skyblock.getByRole("button", { name: /Claim/ })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the owner is unknown and offers no claim when the lookup failed", async () => {
|
||||||
|
calls.fleet.mockResolvedValue([row("survival", { ownerUnknown: true })]);
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ServersPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const survival = await tableRow("survival");
|
||||||
|
expect(survival.getByText("Unknown")).toBeTruthy();
|
||||||
|
expect(survival.queryByText("Unclaimed")).toBeNull();
|
||||||
|
expect(survival.queryByRole("button", { name: /Claim/ })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,12 +85,14 @@ interface UnifiedServer {
|
|||||||
endpointAddress?: string | null;
|
endpointAddress?: string | null;
|
||||||
claimable?: boolean;
|
claimable?: boolean;
|
||||||
owned?: boolean;
|
owned?: boolean;
|
||||||
|
/** The fleet's owner lookup failed, so an absent owner proves nothing. */
|
||||||
|
ownerUnknown?: boolean;
|
||||||
system?: boolean;
|
system?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServersPage() {
|
export function ServersPage() {
|
||||||
const { t } = useTranslation(["ops", "servers"]);
|
const { t } = useTranslation(["ops", "servers"]);
|
||||||
const { isAdmin, identity } = useTier();
|
const { isAdmin } = useTier();
|
||||||
const cfg = useConfig();
|
const cfg = useConfig();
|
||||||
|
|
||||||
const fetchFn = useMemo<() => Promise<FleetServer[] | MyServerView[]>>(
|
const fetchFn = useMemo<() => Promise<FleetServer[] | MyServerView[]>>(
|
||||||
@@ -133,8 +135,9 @@ export function ServersPage() {
|
|||||||
playerCountUnknown: s.playerCountUnknown,
|
playerCountUnknown: s.playerCountUnknown,
|
||||||
owner: s.owner,
|
owner: s.owner,
|
||||||
endpointAddress: s.endpointAddress,
|
endpointAddress: s.endpointAddress,
|
||||||
claimable: !s.owner,
|
claimable: s.claimable,
|
||||||
owned: s.owner === identity?.email,
|
owned: s.owned,
|
||||||
|
ownerUnknown: s.ownerUnknown,
|
||||||
system: s.system,
|
system: s.system,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
@@ -154,7 +157,7 @@ export function ServersPage() {
|
|||||||
owned: s.owned,
|
owned: s.owned,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [data, isAdmin, t, identity]);
|
}, [data, isAdmin, t]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const counts: Record<Phase, number> = {
|
const counts: Record<Phase, number> = {
|
||||||
@@ -352,10 +355,10 @@ export function ServersPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Cards below xl (two per row from md); the table needs about
|
{/* Cards below 2xl (two per row from md); the admin table needs
|
||||||
1000px of content width for all its columns, which the page
|
about 1100px of content width for all its columns, which the
|
||||||
only has from xl beside the sidebar. */}
|
page only has from 2xl beside the sidebar. */}
|
||||||
<ul className="grid gap-3 md:grid-cols-2 xl:hidden">
|
<ul className="grid gap-3 md:grid-cols-2 2xl:hidden">
|
||||||
{paged.map((s) => (
|
{paged.map((s) => (
|
||||||
<ServerMobileCard
|
<ServerMobileCard
|
||||||
key={s.name}
|
key={s.name}
|
||||||
@@ -366,7 +369,7 @@ export function ServersPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<Card className="hidden overflow-hidden border border-border/80 xl:block">
|
<Card className="hidden overflow-hidden border border-border/80 2xl:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse text-sm">
|
<table className="w-full border-collapse text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -551,12 +554,24 @@ function OwnerLabel({ server }: { server: UnifiedServer }) {
|
|||||||
return (
|
return (
|
||||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 md:max-w-[13rem]">
|
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 md:max-w-[13rem]">
|
||||||
<UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
{server.owned && (
|
||||||
|
<span className="shrink-0 rounded-full border px-1.5 text-[10px] font-medium leading-4 text-foreground">
|
||||||
|
{t("fleet_owner_you")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="truncate" title={server.owner}>
|
<span className="truncate" title={server.owner}>
|
||||||
{server.owner}
|
{server.owner}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (server.ownerUnknown) {
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground/70" title={t("fleet_owner_unknown_hint")}>
|
||||||
|
{t("fleet_owner_unknown")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <span className="text-xs text-muted-foreground/70">{t("fleet_unclaimed")}</span>;
|
return <span className="text-xs text-muted-foreground/70">{t("fleet_unclaimed")}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user