mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(b2-2): declare protocol_epoch in the schema and generate both constants protocol/schema.json gains protocol_epoch (1). genprotocol emits ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go constant to the schema so a stale regeneration fails the required check. * feat(b2-2): check the client's protocol epoch in the auth handshake The auth payload gains epoch (absent = 0). Outside [minClientEpoch, ProtocolEpoch] the server answers one auth_error with code protocol_epoch_unsupported, the client/server/min epochs, and a message naming which side to update, then closes 1008 like every other handshake failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep connecting; the epoch-1 fixtures are unchanged. * feat(b2-2): send the protocol epoch and offer the update on a refused connect ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended on purpose). On auth_error code protocol_epoch_unsupported with a newer server the dispatcher records the host in ui.store.updateRequiredHost and main.ts mounts the UpdateNotifier on the connect page, so a refused client gets the same Update Now banner it would have had on the main page. * feat(b2-2): withhold client releases newer than the server's protocol epoch The signed server-update manifest gains protocol_epoch (release.yml reads it from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the manifest and reads it; the client-update endpoint answers 204 when the release's epoch is newer than ws.ProtocolEpoch or the manifest does not verify. Releases without a manifest are epoch 0 and advertised as before. Docs: protocol.md Compatibility section, api.md, deployment.md, protocol README, CHANGELOG Unreleased. * docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it * ci: prove the protocol_epoch manifest read on every PR, not only at tag time * fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal Codex P1: on a first login or startup auto-login no overlay exists before auth_ok, so a refusal never re-rendered the connect page and the one-time read of updateRequiredHost missed it. The connect page now subscribes to it, and a later refusal replaces the banner. Codex P2: a refusal on reconnect went through the generic logout and deleted the stored credential although the token is still valid. clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it (the skip-auto-login flag is still set and, being sessionStorage, does not survive the relaunch the update triggers).
152 lines
5.5 KiB
TypeScript
152 lines
5.5 KiB
TypeScript
// CONTRACT TEST. Pins the exact key set of the `auth` frame that
|
|
// Client/src/lib/ws.ts sends as the first message after the WebSocket opens
|
|
// (ws.ts:441-453) -- the client side of the same wire contract a sibling Go
|
|
// test freezes for the server. B2-2 added the `epoch` field (the wire epoch
|
|
// this client speaks, PROTOCOL_EPOCH from protocolTypes.ts); the key sets
|
|
// below include it deliberately. Any further field MUST fail here until it is
|
|
// added on purpose. Extend this file, do not replace or delete it.
|
|
//
|
|
// Assertions compare exact key sets (sorted Object.keys -- key order has no
|
|
// wire meaning), never toHaveProperty, so an unexpected added key fails just
|
|
// as loudly as a missing one.
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
// vi.mock is hoisted per file; the factories resolve to the shared handles
|
|
// exported from ../unit/helpers/ws-mocks (see that module's doc comment --
|
|
// it is shared across all ws-*.test.ts files, this one included).
|
|
vi.mock("@tauri-apps/api/core", async () => ({
|
|
invoke: (await import("../unit/helpers/ws-mocks")).mockInvoke,
|
|
}));
|
|
|
|
vi.mock("@tauri-apps/api/event", async () => ({
|
|
listen: (await import("../unit/helpers/ws-mocks")).mockListen,
|
|
}));
|
|
|
|
import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "../unit/helpers/ws-mocks";
|
|
import { createWsClient, setActiveChannelProvider } from "../../src/lib/ws";
|
|
import { PROTOCOL_EPOCH } from "../../src/lib/protocolTypes";
|
|
|
|
/** Parses the most recently sent `auth` frame (envelope + payload) from ws_send. */
|
|
function getAuthFrame(): { type: string; payload: Record<string, unknown> } {
|
|
const authCall = mockInvoke.mock.calls.find(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"auth"'),
|
|
);
|
|
expect(authCall).toBeDefined();
|
|
return JSON.parse((authCall![1] as { message: string }).message) as {
|
|
type: string;
|
|
payload: Record<string, unknown>;
|
|
};
|
|
}
|
|
|
|
describe("contract: auth frame key set (epoch 1)", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
// activeChannelProvider is a module-level singleton (registered once at
|
|
// app bootstrap in dispatcher.ts) -- reset it so state doesn't leak
|
|
// across tests/files.
|
|
setActiveChannelProvider(null);
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
setActiveChannelProvider(null);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("fresh connect: envelope keys are exactly [type, payload, id], payload keys exactly [token, last_seq, epoch]", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const frame = getAuthFrame();
|
|
// send() (ws.ts:631-637) wraps every outgoing message with a correlation
|
|
// `id` via `{ ...msg, id }` -- that's a generic per-send addition, not
|
|
// part of the auth-specific payload contract, but it IS part of what
|
|
// actually goes over the wire, so the envelope pin has three keys, not
|
|
// the two the auth message literal at ws.ts:446-453 has on its own.
|
|
expect(Object.keys(frame).sort()).toEqual(["id", "payload", "type"]);
|
|
expect(frame.type).toBe("auth");
|
|
expect(Object.keys(frame.payload).sort()).toEqual(["epoch", "last_seq", "token"]);
|
|
expect(frame.payload.token).toBe("t");
|
|
expect(frame.payload.last_seq).toBe(0);
|
|
expect(frame.payload.epoch).toBe(PROTOCOL_EPOCH);
|
|
expect(PROTOCOL_EPOCH).toBe(1);
|
|
});
|
|
|
|
it("resume with a registered active-channel provider: payload keys exactly [token, last_seq, active_channel_id, epoch]", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_ok",
|
|
seq: 7,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
setActiveChannelProvider(() => 42);
|
|
|
|
emitTauriEvent("ws-state", "closed");
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const frame = getAuthFrame();
|
|
expect(Object.keys(frame.payload).sort()).toEqual([
|
|
"active_channel_id",
|
|
"epoch",
|
|
"last_seq",
|
|
"token",
|
|
]);
|
|
expect(frame.payload.last_seq).toBe(7);
|
|
expect(frame.payload.active_channel_id).toBe(42);
|
|
});
|
|
|
|
it("resume without a provider registered: payload keys stay exactly [token, last_seq, epoch]", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_ok",
|
|
seq: 3,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// No setActiveChannelProvider call -- stays null from beforeEach reset.
|
|
emitTauriEvent("ws-state", "closed");
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const frame = getAuthFrame();
|
|
expect(Object.keys(frame.payload).sort()).toEqual(["epoch", "last_seq", "token"]);
|
|
expect(frame.payload.last_seq).toBe(3);
|
|
});
|
|
});
|