3626998a33
Eight ship-group operations land on the inspector behind a single inline-form panel: split, send, load, unload, modernize, dismantle, transfer, join fleet. Each action either appends a typed command to the local order draft or surfaces a tooltip explaining the disabled state. Partial-ship operations emit an implicit breakShipGroup command before the targeted action so the engine sees a clean (Break, Action) pair on the wire. `pkg/calc.BlockUpgradeCost` migrates from `game/internal/controller/ship_group_upgrade.go` so the calc bridge can wrap a pure pkg/calc formula; the controller now imports it. The bridge surfaces the function as `core.blockUpgradeCost`, which the inspector calls once per ship block to render the modernize cost preview. `GameReport.otherRaces` is decoded from the report's player block (non-extinct, ≠ self) and feeds the transfer-to-race picker. The planet inspector's stationed-ship rows become clickable for own groups so the actions panel is reachable from the standard click flow (the renderer continues to hide on-planet groups). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
223 lines
7.3 KiB
TypeScript
223 lines
7.3 KiB
TypeScript
// Verifies the orchestration order in `GalaxyClient.executeCommand`:
|
|
//
|
|
// core.signRequest(fields)
|
|
// → signer(canonicalBytes)
|
|
// → edge.executeCommand({ envelope, signature })
|
|
// → core.verifyPayloadHash(response payload, hash)
|
|
// → core.verifyResponse(envelope, signature)
|
|
// → return response.payloadBytes
|
|
//
|
|
// Tests use a hand-rolled mock `Core` and a Connect router transport
|
|
// from `@connectrpc/connect`, which lets us assert what the gateway
|
|
// would have received without standing up a real Connect server.
|
|
|
|
import { create } from "@bufbuild/protobuf";
|
|
import { createClient, createRouterTransport } from "@connectrpc/connect";
|
|
import { describe, expect, test, vi } from "vitest";
|
|
import { GalaxyClient } from "../src/api/galaxy-client";
|
|
import {
|
|
EdgeGateway,
|
|
ExecuteCommandResponseSchema,
|
|
type ExecuteCommandRequest,
|
|
} from "../src/proto/galaxy/gateway/v1/edge_gateway_pb";
|
|
import type {
|
|
Core,
|
|
RequestSigningFields,
|
|
ResponseSigningFields,
|
|
} from "../src/platform/core/index";
|
|
|
|
const FIXED_REQUEST_ID = "req-test-1";
|
|
const FIXED_TIMESTAMP = 1_700_000_000_000n;
|
|
|
|
describe("GalaxyClient.executeCommand", () => {
|
|
test("orchestrates sign → fetch → verify and returns the payload", async () => {
|
|
const canonicalBytes = new Uint8Array([0xca, 0xfe]);
|
|
const signature = new Uint8Array(64).fill(0x55);
|
|
const responsePayload = new TextEncoder().encode("hello-from-server");
|
|
const responseHash = new Uint8Array(32).fill(0x77);
|
|
const responseSignature = new Uint8Array(64).fill(0x99);
|
|
const responsePublicKey = new Uint8Array(32).fill(0x11);
|
|
|
|
const core = mockCore({
|
|
signRequestImpl: () => canonicalBytes,
|
|
verifyResponseImpl: () => true,
|
|
verifyPayloadHashImpl: () => true,
|
|
});
|
|
const signer = vi.fn(async () => signature);
|
|
const sha256 = vi.fn(async () => new Uint8Array(32).fill(0x33));
|
|
let captured: ExecuteCommandRequest | undefined;
|
|
const transport = createRouterTransport(({ service }) => {
|
|
service(EdgeGateway, {
|
|
executeCommand(req) {
|
|
captured = req;
|
|
return create(ExecuteCommandResponseSchema, {
|
|
protocolVersion: "v1",
|
|
requestId: FIXED_REQUEST_ID,
|
|
timestampMs: FIXED_TIMESTAMP,
|
|
resultCode: "ok",
|
|
payloadBytes: responsePayload,
|
|
payloadHash: responseHash,
|
|
signature: responseSignature,
|
|
});
|
|
},
|
|
subscribeEvents() {
|
|
throw new Error("not used in this test");
|
|
},
|
|
});
|
|
});
|
|
const edge = createClient(EdgeGateway, transport);
|
|
|
|
const client = new GalaxyClient({
|
|
core,
|
|
edge,
|
|
signer,
|
|
sha256,
|
|
deviceSessionId: "device-session-1",
|
|
gatewayResponsePublicKey: responsePublicKey,
|
|
clock: () => FIXED_TIMESTAMP,
|
|
requestIdFactory: () => FIXED_REQUEST_ID,
|
|
});
|
|
|
|
const out = await client.executeCommand(
|
|
"user.account.get",
|
|
new TextEncoder().encode("client-payload"),
|
|
);
|
|
|
|
expect(out.resultCode).toBe("ok");
|
|
expect(Array.from(out.payloadBytes)).toEqual(Array.from(responsePayload));
|
|
expect(signer).toHaveBeenCalledWith(canonicalBytes);
|
|
expect(sha256).toHaveBeenCalledTimes(1);
|
|
expect(core.signRequest).toHaveBeenCalledTimes(1);
|
|
const verifyHashCall = vi.mocked(core.verifyPayloadHash).mock.calls[0]!;
|
|
expect(Array.from(verifyHashCall[0])).toEqual(Array.from(responsePayload));
|
|
expect(Array.from(verifyHashCall[1])).toEqual(Array.from(responseHash));
|
|
const verifyRespCall = vi.mocked(core.verifyResponse).mock.calls[0]!;
|
|
expect(Array.from(verifyRespCall[0])).toEqual(Array.from(responsePublicKey));
|
|
expect(Array.from(verifyRespCall[1])).toEqual(Array.from(responseSignature));
|
|
expect(verifyRespCall[2]).toMatchObject({
|
|
protocolVersion: "v1",
|
|
requestId: FIXED_REQUEST_ID,
|
|
resultCode: "ok",
|
|
});
|
|
expect(captured?.deviceSessionId).toBe("device-session-1");
|
|
expect(captured?.messageType).toBe("user.account.get");
|
|
expect(captured?.requestId).toBe(FIXED_REQUEST_ID);
|
|
expect(Array.from(captured?.signature ?? [])).toEqual(
|
|
Array.from(signature),
|
|
);
|
|
});
|
|
|
|
test("throws when the response signature fails verification", async () => {
|
|
const core = mockCore({
|
|
signRequestImpl: () => new Uint8Array(),
|
|
verifyResponseImpl: () => false,
|
|
verifyPayloadHashImpl: () => true,
|
|
});
|
|
const transport = createRouterTransport(({ service }) => {
|
|
service(EdgeGateway, {
|
|
executeCommand: () =>
|
|
create(ExecuteCommandResponseSchema, {
|
|
protocolVersion: "v1",
|
|
requestId: FIXED_REQUEST_ID,
|
|
timestampMs: FIXED_TIMESTAMP,
|
|
resultCode: "ok",
|
|
payloadBytes: new Uint8Array(),
|
|
payloadHash: new Uint8Array(32),
|
|
signature: new Uint8Array(64),
|
|
}),
|
|
subscribeEvents() {
|
|
throw new Error("not used in this test");
|
|
},
|
|
});
|
|
});
|
|
const client = new GalaxyClient({
|
|
core,
|
|
edge: createClient(EdgeGateway, transport),
|
|
signer: async () => new Uint8Array(64),
|
|
sha256: async () => new Uint8Array(32),
|
|
deviceSessionId: "device-session-1",
|
|
gatewayResponsePublicKey: new Uint8Array(32),
|
|
clock: () => FIXED_TIMESTAMP,
|
|
requestIdFactory: () => FIXED_REQUEST_ID,
|
|
});
|
|
await expect(
|
|
client.executeCommand("user.account.get", new Uint8Array()),
|
|
).rejects.toThrow(/invalid response signature/);
|
|
});
|
|
|
|
test("throws when the response payload_hash does not match", async () => {
|
|
const core = mockCore({
|
|
signRequestImpl: () => new Uint8Array(),
|
|
verifyResponseImpl: () => true,
|
|
verifyPayloadHashImpl: () => false,
|
|
});
|
|
const transport = createRouterTransport(({ service }) => {
|
|
service(EdgeGateway, {
|
|
executeCommand: () =>
|
|
create(ExecuteCommandResponseSchema, {
|
|
protocolVersion: "v1",
|
|
requestId: FIXED_REQUEST_ID,
|
|
timestampMs: FIXED_TIMESTAMP,
|
|
resultCode: "ok",
|
|
payloadBytes: new Uint8Array(),
|
|
payloadHash: new Uint8Array(32),
|
|
signature: new Uint8Array(64),
|
|
}),
|
|
subscribeEvents() {
|
|
throw new Error("not used in this test");
|
|
},
|
|
});
|
|
});
|
|
const client = new GalaxyClient({
|
|
core,
|
|
edge: createClient(EdgeGateway, transport),
|
|
signer: async () => new Uint8Array(64),
|
|
sha256: async () => new Uint8Array(32),
|
|
deviceSessionId: "device-session-1",
|
|
gatewayResponsePublicKey: new Uint8Array(32),
|
|
clock: () => FIXED_TIMESTAMP,
|
|
requestIdFactory: () => FIXED_REQUEST_ID,
|
|
});
|
|
await expect(
|
|
client.executeCommand("user.account.get", new Uint8Array()),
|
|
).rejects.toThrow(/payload_hash mismatch/);
|
|
});
|
|
});
|
|
|
|
interface MockCoreOptions {
|
|
signRequestImpl: (fields: RequestSigningFields) => Uint8Array;
|
|
verifyResponseImpl: (
|
|
publicKey: Uint8Array,
|
|
signature: Uint8Array,
|
|
fields: ResponseSigningFields,
|
|
) => boolean;
|
|
verifyPayloadHashImpl: (
|
|
payload: Uint8Array,
|
|
hash: Uint8Array,
|
|
) => boolean;
|
|
}
|
|
|
|
function mockCore(opts: MockCoreOptions): Core & {
|
|
signRequest: ReturnType<typeof vi.fn>;
|
|
verifyResponse: ReturnType<typeof vi.fn>;
|
|
verifyPayloadHash: ReturnType<typeof vi.fn>;
|
|
verifyEvent: ReturnType<typeof vi.fn>;
|
|
} {
|
|
return {
|
|
signRequest: vi.fn(opts.signRequestImpl),
|
|
verifyResponse: vi.fn(opts.verifyResponseImpl),
|
|
verifyEvent: vi.fn(() => true),
|
|
verifyPayloadHash: vi.fn(opts.verifyPayloadHashImpl),
|
|
// `GalaxyClient` does not exercise the Phase 18 calc bridge,
|
|
// so these stubs only need to satisfy the `Core` interface.
|
|
driveEffective: () => 0,
|
|
emptyMass: () => 0,
|
|
weaponsBlockMass: () => 0,
|
|
fullMass: () => 0,
|
|
speed: () => 0,
|
|
cargoCapacity: () => 0,
|
|
carryingMass: () => 0,
|
|
blockUpgradeCost: () => 0,
|
|
};
|
|
}
|