fix(panel): 服务器列表的归属与可认领改由后端按账号判定,无邮箱管理员也能认出自己的服务器,所有者查询失败时显示未知且不给认领

This commit is contained in:
Lemon-miaow committed 2026-09-25 12:36:38 +08:00
1 parent 06d5e652c6
commit bb9c2168df
14 files changed
+323 -70

No files matched your search

+16
View File
@@ -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
View File
@@ -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)
} }
} }
}) })
+23 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+48
View File
@@ -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
+6 -3
View File
@@ -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
+1 -1
View File
@@ -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}
+3
View File
@@ -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",
+3
View File
@@ -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": "公开",
+6
View File
@@ -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;
}; };
+8 -2
View File
@@ -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();
});
});
+24 -9
View File
@@ -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>;
} }