test(panel): 加组件测试与浏览器冒烟、hooks lint、OpenAPI 双向类型对齐,修复 CI 类型检查空转、列表不显示服务器名称与玩家停服不确认
This commit is contained in:
44 files changed
+10578
-216
No files matched your search
@@ -20,7 +20,7 @@ import type {
|
||||
QuotaView,
|
||||
ServerFileEntry,
|
||||
ServerJob,
|
||||
ServerInfo,
|
||||
MyServerView,
|
||||
ServerStatus,
|
||||
SessionView,
|
||||
UserDetail,
|
||||
@@ -300,7 +300,7 @@ export const api = rejectingSync({
|
||||
me: () => request<Identity>("GET", "/me"),
|
||||
|
||||
myServers: () =>
|
||||
request<{ servers: ServerInfo[] }>("GET", "/me/servers").then((r) => r.servers ?? []),
|
||||
request<{ servers: MyServerView[] }>("GET", "/me/servers").then((r) => r.servers ?? []),
|
||||
|
||||
// fleet is the SysAdmin cockpit's fleet-wide read (admin-tier GET /fleet): every
|
||||
// server's CRD lifecycle view plus its owner. It 403s for a non-admin principal —
|
||||
|
||||
@@ -58,8 +58,10 @@ export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []): AsyncSt
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
// Bumping the ticket on cleanup drops any response still in flight.
|
||||
const tickets = seq;
|
||||
return () => {
|
||||
seq.current++;
|
||||
tickets.current++;
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,12 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { canManage, ownershipPending } from "./ownership";
|
||||
import type { ServerInfo } from "./types";
|
||||
import type { MyServerView } from "./types";
|
||||
|
||||
// The owner-tier gate once read `owned` off /servers/{name}/status, which never
|
||||
// sends it, so owners lost the LuckPerms entry. These cases pin that ownership
|
||||
// comes only from the /me/servers row for this exact server.
|
||||
|
||||
const row = (name: string, owned?: boolean): ServerInfo => ({ name, subdomain: name, phase: "Running", owned });
|
||||
const row = (name: string, owned = false): MyServerView => ({
|
||||
name,
|
||||
subdomain: name,
|
||||
phase: "Running",
|
||||
owned,
|
||||
claimable: false,
|
||||
playersOnline: 0,
|
||||
playersMax: 0,
|
||||
});
|
||||
|
||||
describe("canManage", () => {
|
||||
it("lets an admin in without any /me/servers rows", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ServerInfo } from "./types";
|
||||
import type { MyServerView } from "./types";
|
||||
|
||||
// Owner-tier pages (console extras, players, files, backups, LuckPerms) decide
|
||||
// who may act from GET /me/servers: the status projection never carries
|
||||
@@ -7,7 +7,7 @@ import type { ServerInfo } from "./types";
|
||||
/** canManage reports whether the caller may use a server's owner-tier tools. */
|
||||
export function canManage(
|
||||
isAdmin: boolean,
|
||||
mine: readonly ServerInfo[] | null | undefined,
|
||||
mine: readonly MyServerView[] | null | undefined,
|
||||
name: string,
|
||||
): boolean {
|
||||
return isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true);
|
||||
@@ -18,7 +18,7 @@ export function canManage(
|
||||
export function ownershipPending(
|
||||
tierLoading: boolean,
|
||||
isAdmin: boolean,
|
||||
mine: readonly ServerInfo[] | null | undefined,
|
||||
mine: readonly MyServerView[] | null | undefined,
|
||||
mineError: unknown,
|
||||
): boolean {
|
||||
return tierLoading || (!isAdmin && mine == null && !mineError);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Compile-time parity between the hand-written wire types (types.ts) and the
|
||||
// schemas in docs/openapi.yaml, via the generated openapi.gen.ts (`npm run
|
||||
// gen:api`; CI fails when the generated file is stale). The Go side is held to
|
||||
// the same schemas by internal/api/openapi_parity_test.go, so a field that
|
||||
// changes in one place and not the others breaks a build instead of rendering
|
||||
// `undefined`.
|
||||
//
|
||||
// For each pair three things must hold:
|
||||
// - the two key sets are equal;
|
||||
// - a key the docs mark optional is optional here too (the panel must not
|
||||
// count on a field the server may omit);
|
||||
// - every documented value fits the panel type (enums included).
|
||||
// A failure names the offending keys in the error's type.
|
||||
import type { components } from "./openapi.gen";
|
||||
import type * as T from "./types";
|
||||
|
||||
type S = components["schemas"];
|
||||
|
||||
type OptionalKeys<X> = { [K in keyof X]-?: object extends Pick<X, K> ? K : never }[keyof X];
|
||||
|
||||
type Parity<Panel, Docs> = [Exclude<keyof Panel, keyof Docs>, Exclude<keyof Docs, keyof Panel>] extends [
|
||||
never,
|
||||
never,
|
||||
]
|
||||
? [Exclude<OptionalKeys<Docs>, OptionalKeys<Panel>>] extends [never]
|
||||
? Docs extends Panel
|
||||
? true
|
||||
: { docsValueDoesNotFitPanel: { [K in keyof Docs & keyof Panel]: Docs[K] extends Panel[K] ? never : K }[keyof Docs & keyof Panel] }
|
||||
: { optionalInDocsButRequiredInPanel: Exclude<OptionalKeys<Docs>, OptionalKeys<Panel>> }
|
||||
: { onlyInPanel: Exclude<keyof Panel, keyof Docs>; onlyInDocs: Exclude<keyof Docs, keyof Panel> };
|
||||
|
||||
type Holds<X extends true> = X;
|
||||
|
||||
export type WireParity = [
|
||||
Holds<Parity<T.ServerStatus, S["ServerInfo"]>>,
|
||||
Holds<Parity<T.MyServerView, S["MyServerView"]>>,
|
||||
Holds<Parity<T.FleetServer, S["FleetServer"]>>,
|
||||
Holds<Parity<T.BackupView, S["BackupView"]>>,
|
||||
Holds<Parity<T.Build, S["Build"]>>,
|
||||
Holds<Parity<T.WhitelistImage, S["Image"]>>,
|
||||
Holds<Parity<T.Submission, S["Submission"]>>,
|
||||
Holds<Parity<T.UserView, S["UserView"]>>,
|
||||
Holds<Parity<T.UserDetail, S["UserDetail"]>>,
|
||||
Holds<Parity<T.QuotaView, S["QuotaView"]>>,
|
||||
Holds<Parity<T.SessionView, S["SessionView"]>>,
|
||||
Holds<Parity<T.PasskeyCredential, S["PasskeyCredential"]>>,
|
||||
Holds<Parity<T.UpdateWindow, S["UpdateWindow"]>>,
|
||||
Holds<Parity<T.DBBackupStatus, S["DBBackupStatus"]>>,
|
||||
];
|
||||
+53
-37
@@ -17,35 +17,53 @@ export type Phase =
|
||||
|
||||
export type AutostartPolicy = "ownerOnly" | "public" | "allowlist";
|
||||
|
||||
/** ServerInfo is the GET /me/servers row (wrapped under { servers: [...] }).
|
||||
* Only this projection says whether the caller owns or may claim a server. */
|
||||
export interface ServerInfo {
|
||||
/** MyServerView is the GET /me/servers row (wrapped under { servers: [...] }).
|
||||
* Only this projection says whether the caller owns or may claim a server.
|
||||
* The live fields come from the CRD best-effort; desiredState, autostartPolicy
|
||||
* and playerCountUnknown are present on the caller's own rows only. */
|
||||
export interface MyServerView {
|
||||
name: string;
|
||||
subdomain: string;
|
||||
displayName?: string;
|
||||
phase: Phase;
|
||||
desiredState?: "Running" | "Stopped";
|
||||
playersOnline?: number;
|
||||
playersMax?: number;
|
||||
autostartPolicy?: AutostartPolicy;
|
||||
/** Whether the caller may claim this server (unowned + linked + quota). */
|
||||
claimable?: boolean;
|
||||
/** Whether the caller owns it. */
|
||||
owned?: boolean;
|
||||
owned: boolean;
|
||||
/** Whether the caller may claim this server (unowned + linked + quota). */
|
||||
claimable: boolean;
|
||||
phase?: Phase;
|
||||
playersOnline: number;
|
||||
playersMax: number;
|
||||
displayName?: string;
|
||||
desiredState?: "Running" | "Stopped";
|
||||
autostartPolicy?: AutostartPolicy;
|
||||
/** True while the operator cannot read the player count; a stop may drop players. */
|
||||
playerCountUnknown?: boolean;
|
||||
}
|
||||
|
||||
/** ServerStatus is GET /servers/{name}/status (Go ServerInfo). It never carries
|
||||
* `owned` or `claimable`; owner-tier gates read /me/servers via lib/ownership.
|
||||
* A caller who does not own the server gets the public subset, so everything
|
||||
* past the counts may be absent. */
|
||||
export interface ServerStatus {
|
||||
name: string;
|
||||
subdomain: string;
|
||||
phase: Phase;
|
||||
ready: boolean;
|
||||
autostartPolicy?: AutostartPolicy;
|
||||
desiredState?: "Running" | "Stopped";
|
||||
endpointMode?: string;
|
||||
endpointAddress?: string;
|
||||
playersOnline: number;
|
||||
playersMax: number;
|
||||
displayName?: string;
|
||||
image?: string;
|
||||
javaMemory?: string;
|
||||
storageSize?: string;
|
||||
cpu?: string;
|
||||
/** Seconds empty before idle auto-stop; 0 when the server never idles out. */
|
||||
idleStopSeconds?: number;
|
||||
idleStopSeconds: number;
|
||||
/** True while the operator cannot read the player count; idle stop waits. */
|
||||
playerCountUnknown?: boolean;
|
||||
}
|
||||
|
||||
/** ServerStatus is GET /servers/{name}/status. It never carries `owned` or
|
||||
* `claimable`; owner-tier gates read /me/servers via lib/ownership. */
|
||||
export type ServerStatus = Omit<ServerInfo, "owned" | "claimable">;
|
||||
|
||||
/** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access).
|
||||
* `players` is a BEST-EFFORT parse of the vanilla "whitelist list" reply done
|
||||
* server-side (parseWhitelistOutput); `output` is the raw RCON text and is the
|
||||
@@ -82,7 +100,7 @@ export interface AccessResult {
|
||||
}
|
||||
|
||||
/** PlayersResult projects GET /servers/{name}/access/players (spec §7 access), the
|
||||
* ONLY source of WHO is online — ServerInfo.playersOnline carries the count alone.
|
||||
* ONLY source of WHO is online — MyServerView.playersOnline carries the count alone.
|
||||
* `online`/`max` are the tally; `players` is a BEST-EFFORT parse of the vanilla
|
||||
* "list" reply (parseListOutput) and, like the whitelist, can come back empty on a
|
||||
* non-vanilla format while `output` (the raw RCON text, ground truth) still names
|
||||
@@ -108,21 +126,11 @@ export interface KickResult {
|
||||
* read (admin-tier). It mirrors the Go fleetServerView: the CRD lifecycle
|
||||
* projection plus the owner joined read-only from Postgres for display.
|
||||
*
|
||||
* It is a DISTINCT type from ServerInfo, not a reuse: /fleet emits the raw CRD
|
||||
* It is a DISTINCT type from MyServerView, not a reuse: /fleet emits the raw CRD
|
||||
* shape — `ready` and the `endpoint*` runtime fields, with playersOnline/playersMax
|
||||
* required — whereas ServerInfo is the /me/servers projection with them optional.
|
||||
* required — whereas MyServerView is the /me/servers projection.
|
||||
* Sharing one interface would blur which fields each face actually guarantees. */
|
||||
export interface FleetServer {
|
||||
name: string;
|
||||
subdomain: string;
|
||||
phase: Phase;
|
||||
ready: boolean;
|
||||
desiredState?: "Running" | "Stopped";
|
||||
autostartPolicy?: AutostartPolicy;
|
||||
endpointMode?: string;
|
||||
endpointAddress?: string;
|
||||
playersOnline: number;
|
||||
playersMax: number;
|
||||
export interface FleetServer extends ServerStatus {
|
||||
/** 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
|
||||
* failed — the cockpit renders that as "unclaimed". */
|
||||
@@ -191,7 +199,11 @@ export interface ServerJob {
|
||||
export interface WhitelistImage {
|
||||
image_ref: string;
|
||||
enabled: boolean;
|
||||
source?: string;
|
||||
source: string;
|
||||
/** Set when the image came out of a panel build (build.Image BuildID). */
|
||||
build_id?: string;
|
||||
added_by: string;
|
||||
added_at: string;
|
||||
}
|
||||
|
||||
/** CreateServerRequest is the §15 structured form — the ONLY create path. */
|
||||
@@ -288,6 +300,8 @@ export interface Build {
|
||||
error?: string;
|
||||
created_at: string;
|
||||
finished_at?: string;
|
||||
/** sha256 of the build context the Job actually fetched. */
|
||||
context_digest?: string;
|
||||
}
|
||||
|
||||
export type SubmissionStatus = "pending_review" | "approved" | "rejected";
|
||||
@@ -343,7 +357,8 @@ export interface DBBackupStatus {
|
||||
export interface UserView {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
/** Omitted for accounts created without one (bind-code / op-login only). */
|
||||
email?: string;
|
||||
role: "admin" | "user" | "owner";
|
||||
disabled: boolean;
|
||||
email_verified: boolean;
|
||||
@@ -359,8 +374,9 @@ export interface LinkedAccount {
|
||||
}
|
||||
|
||||
export interface UserDetail extends UserView {
|
||||
deleted_at?: string | null;
|
||||
linked_accounts: LinkedAccount[];
|
||||
deleted_at?: string;
|
||||
/** Omitted when the user has no linked Minecraft account. */
|
||||
linked_accounts?: LinkedAccount[];
|
||||
}
|
||||
|
||||
/** CreateUserRequest mirrors handlers_users.go createUserRequest — passwordless:
|
||||
@@ -398,12 +414,12 @@ export interface PasskeyCredential {
|
||||
name: string;
|
||||
aaguid?: string;
|
||||
created_at: string;
|
||||
last_used_at?: string | null;
|
||||
last_used_at?: string;
|
||||
}
|
||||
|
||||
export interface SessionView {
|
||||
token_hash: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
revoked_at?: string | null;
|
||||
revoked_at?: string;
|
||||
}
|
||||
Reference in new issue
Block a user