mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(identity): 1 defect(s) (OC-0151)
* fix(ws): 1 defect(s) (OC-0152)
* fix(admin): 1 defect(s) (OC-0153)
* fix(admin): 1 defect(s) (OC-0154)
* fix(voice): 2 defect(s) (OC-0155, OC-0167)
Replace distributeRoomKey's per-call offer counter with an instance-level
sliding-window budget shared by every voice_e2ee_offer send path.
- OC-0155: back-to-back rotations (the second run immediately by
drainPendingRotationOrArmTimer) each got a fresh pacing budget, so their
combined sends could exceed the server's single per-second cap.
- OC-0167: handleAnnounceInner's drain-time offer send bypassed pacing
entirely, letting a key holder joining a large ongoing call burst every
queued announce's offer unpaced.
The shared budget is reset in clearState() since the server's limit is
scoped per (sender, channel).
* fix(client): 1 defect(s) (OC-0156)
createPresenceSender dropped a queued custom_status when a later plain
status change superseded the pending retry. The retry now carries the
last committed custom_status forward.
* fix(client): 2 defect(s) (OC-0160, OC-0163)
OC-0160: exempt the handshake frames (ready, auth_ok) from the ws message
size limit and run the guard after parsing. A 'ready' frame grows unbounded
with member/channel/DM counts and carries no seq, so dropping it left the
client on empty stores with no error and no recovery path.
OC-0163: bracket a bare IPv6 host when building the wss:// URL so the
authority parses, and collapse bracketed/bare IPv6 literals to the same
cert_store_key so one server is not pinned (and user-confirmed) twice.
* fix(voice): 1 defect(s) (OC-0162)
updatePttKey armed the Rust poller when a PTT key was bound mid-call but
never applied the gate. The poller only emits 'ptt-state' on a press/release
transition, so an idle key produced no event and the already-published mic
stayed hot until the user's first physical press+release. Mirror the join-time
gate computation in updatePttKey, guarded on being in a call, polling actually
being live, and the mic not already being gated.
* fix(client): 1 defect(s) (OC-0164)
* fix(plugin): 1 defect(s) (OC-0165)
scanPluginDirectory now skips a malformed plugin subdirectory and joins its
error instead of aborting the whole scan, and LoadAll logs-and-continues so
one bad plugin directory cannot disable every other plugin.
* fix(ws): 1 defect(s) (OC-0166)
Route PresenceSelfEvent onto the owner's normal-priority queue instead of
letting it fall through to the UserTargetedEvent high-priority case, so a
user's own presence frames all share one FIFO and cannot be delivered out
of order relative to the visible presence_update path.
* fix(db): 1 defect(s) (OC-0168)
* fix(client): 1 defect(s) (OC-0169)
* fix(client): 1 defect(s) (OC-0171)
addMessage appended a broadcast at the tail even when trailing optimistic
rows were still unreconciled, so a message that committed while our own
send was in flight ended up ordered behind the row confirmSend later
stamped with a higher server id/timestamp. Insert before the trailing
unreconciled run instead.
* fix(voice): 1 defect(s) (OC-0172)
* fix(client): 1 defect(s) (OC-0174)
* fix(ws): 1 defect(s) (OC-0175)
* fix(client): 1 defect(s) (OC-0177)
* fix(client): 1 defect(s) (OC-0178)
* fix(voice): 1 defect(s) (OC-0179)
Undeafening no longer sends a voice_mute{muted:false} the server will
refuse while a moderator-imposed mute stands, matching the localServerMuted
guard already present in onMuteToggle.
* fix(client): 1 defect(s) (OC-0182)
* fix(plugin): 1 defect(s) (OC-0183)
* fix(client): 1 defect(s) (OC-0184)
Treat a trailing underscore as an emphasis delimiter, not part of the URL,
when scanning for the end of an autolinked URL.
* fix(client): 1 defect(s) (OC-0185)
Reveal .msg-actions-bar on .message:focus-within, not only on hover, so
keyboard users can see the per-message action buttons they Tab into
instead of activating them at opacity: 0.
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): 1 defect(s) (OC-0186)
* fix(client): 1 defect(s) (OC-0187)
The Add Server modal validated addresses with its own narrower regex that
never gained IPv6 support when api.ts's validator did, so an IPv6 server
could be logged into but never saved as a profile. Extract the validator
into src/lib/hostValidation.ts and use it from both call sites.
* fix(client): 1 defect(s) (OC-0189)
DM sidebar rows dropped mention counts entirely and the header total
excluded muted conversations outright, so a direct mention in a muted DM
was invisible. Render a mention badge that outranks the plain unread
badge, and count a muted channel's mentionCount toward the header total.
* fix(client): 1 defect(s) (OC-0190)
* fix(client): 1 defect(s) (OC-0191)
* fix(client): 2 defect(s) (OC-0157, OC-0176)
* fix(client): 1 defect(s) (OC-0161)
confirmTotp answers 401 for a wrong enrollment code while the session is still valid; firing the global onUnauthorized sink signed the user out and deleted their stored credential. Opt that one call out via a skipUnauthorized flag on doFetch.
* fix(admin): 1 defect(s) (OC-0173)
* fix(identity): 1 defect(s) (OC-0180)
* fix(admin): archived channel PATCH skips voice eviction and fan-out (OC-0158)
handlePatchChannel commits the AdminUpdateChannel write, then re-reads the
channel to drive voice eviction and the visibility fan-out. When that
post-commit re-read failed, the handler returned early: the archive was
durable but connected clients were never told and voice members were never
evicted, leaving users talking in a channel that no longer exists for them.
Drive the post-commit work off the values already in hand rather than
abandoning it when the re-read fails.
Adds SetPatchChannelPostCommitHook so the test can land a cancellation in
that exact window deterministically instead of racing wall-clock timing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(admin): role changes commit with no client ever notified (OC-0170)
broadcastRoles derived its context from the inbound *http.Request, so the
roles_update fan-out was tied to the request lifetime. A role create,
update, or delete could commit to the database and then broadcast nothing
once that request context was done, leaving every connected client on a
stale role list until the next full resync.
Decouple the fan-out from the request context so the broadcast follows the
commit rather than the caller.
Adds BroadcastRolesForTest to reach broadcastRoles from the external test
package.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): username rename stomps the profile card header (OC-0188)
The account profile card's header is a resolveDisplayName() slot, but the
username-rename save path wrote the raw username straight into it. A user
with a display name set would see the header switch from their display
name to their new username after a rename, disagreeing with every other
surface that renders the same identity.
Resolve the header through the same display-name path the initial render
uses, so a rename updates the username field without touching the header.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): settings overlay never focuses when mounted already-open (OC-0181)
mount() synced initial state — including the show() that calls
focusDialog() — before appending root to the container. .focus() on a
still-detached subtree is a silent no-op, so a caller that mounts while
uiStore.settingsOpen is already true (ConnectPage's lazy first-open path)
got a visible overlay whose focus trap never captured focus: keyboard
users landed outside the dialog with Tab escaping to the page behind it.
Attach root before syncing initial state so focusDialog() runs against a
connected subtree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* chore: satisfy the CI gates for this fix batch
The fix batch's own commits left three CI gates red. Nothing here changes
behaviour; every edit is a lint, type, or formatting correction to code
this batch introduced.
golangci-lint:
- OC-0153 and OC-0173 replaced the last two uses of admin's setupSanitizer,
and OC-0151 the last use of api's sanitizer, leaving both package-level
bluemonday vars unused. Remove them along with the now-unused imports,
and reword the comments that named them so they still explain why the
fixpoint sanitizer is the right one without pointing at deleted symbols.
- Modernize the new handshake-deadline test's loop to range-over-int.
tsc --noEmit:
- jsdom ships no types and @types/jsdom is not a dependency, so declare the
surface the new admin-panel test uses, following src/types/jitsi-rnnoise.d.ts.
- Narrow the last-call lookup instead of indexing under
noUncheckedIndexedAccess, with an explicit failure message.
- membersStore.setState replaces whole state, so the presence-sender mocks
must supply typingUsers.
prettier: reformat the five files this batch touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* chore(ledger): record the 2026-08-19 hunt and its fixes
Adds the 41 findings confirmed by the 2026-08-19 hunt and marks the 40
fixed on this branch, each with its commit, the test that pins it, and
revertProof "pass".
"pass" means an independent check, not the fixing agent's self-report:
every commit had its source diff reverted against the working tree, its
own test re-run and required to FAIL, then the source restored and the
test required to PASS. Commits whose tests live inline in Rust
#[cfg(test)] blocks were proven the same way at hunk level, splicing the
pre-fix source onto the post-fix test module.
OC-0159 is recorded as a duplicate of OC-0152: the flow-reconnect and
flow-message lenses independently found the same unbounded handshake
write and proposed the same helper over the same call sites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* test(e2e): make the voice-roster join fixture self-consistent
The voice-widget join test emitted a voice_state for user_id 4 claiming
username "newvoiceuser", but id 4 is "member2" in MOCK_MEMBERS_MULTI_ROLE.
A real server never sends a voice_state whose username disagrees with the
member record for that id, and the same file's VOICE_STATE_EVENT already
pairs id 1 with "testuser" correctly — this one event was the outlier.
The contradiction was invisible while the roster rendered the payload's
raw username. OC-0177 makes it resolve identity through membersStore so a
nickname shows the same in voice as everywhere else, at which point the
fixture's own inconsistency surfaced as a failure.
Send id 4's real username and assert on it. The test still covers what it
did before — a genuine join by a user not previously in voice, asserted by
name and by roster count.
Verified against the app unchanged: with the old fixture the spec fails
1/5 (matching CI), with this one it passes 5/5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
---------
Co-authored-by: Claude <noreply@anthropic.com>
382 lines
17 KiB
TypeScript
382 lines
17 KiB
TypeScript
/**
|
|
* Tests for src/main.ts's post-auth wiring.
|
|
*
|
|
* main.ts is excluded from unit coverage (vitest.config.ts) — "no seam to
|
|
* test below the e2e level; covered by tests/e2e." This file creates one:
|
|
* every direct dependency of main.ts that is not needed to observe the two
|
|
* behaviors below is stubbed out (mirroring the pattern main-page.test.ts
|
|
* uses for MainPage.ts), while ws.ts, authStore, router.ts, safe-render.ts,
|
|
* navigation-guard.ts and ConnectedOverlay.ts run for real — so the actual
|
|
* event-ordering bug (OC-0063) is exercised, not simulated, and the tray
|
|
* listener (OC-0037) is driven through the same Tauri event mock ws.ts's own
|
|
* tests use.
|
|
*
|
|
* Covers:
|
|
* - OC-0037: the tray's "status-change" event must persist the choice
|
|
* through saveUserStatus() (the documented single source of truth for the
|
|
* selected status), not just fire a raw ws.send.
|
|
* - OC-0063: the connected overlay must read serverName/motd from the
|
|
* auth_ok payload, not from authStore snapshotted before dispatch() has
|
|
* run the dispatcher's own auth_ok handler (which is what actually writes
|
|
* authStore).
|
|
*/
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tauri API mocks — reuse the ws-mocks.ts event-registry helper so ws.ts's
|
|
// real state machine can be driven with simulated Tauri events (same
|
|
// mechanism ws-lifecycle.test.ts uses), and so the tray's "status-change"
|
|
// listen() call registered by main.ts is capturable via the same
|
|
// emitTauriEvent().
|
|
// ---------------------------------------------------------------------------
|
|
vi.mock("@tauri-apps/api/core", async () => ({
|
|
invoke: (await import("./helpers/ws-mocks")).mockInvoke,
|
|
}));
|
|
vi.mock("@tauri-apps/api/event", async () => ({
|
|
listen: (await import("./helpers/ws-mocks")).mockListen,
|
|
}));
|
|
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() }));
|
|
|
|
// CSS imports are handled natively by vite/vitest — no mock needed.
|
|
|
|
vi.mock("@lib/appearance", () => ({ applyStoredAppearance: vi.fn() }));
|
|
vi.mock("@lib/themes", () => ({ restoreTheme: vi.fn() }));
|
|
vi.mock("@lib/ptt", () => ({ initPtt: vi.fn().mockResolvedValue(undefined) }));
|
|
vi.mock("@lib/logPersistence", () => ({
|
|
initLogPersistence: vi.fn().mockResolvedValue(undefined),
|
|
flushLogs: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
vi.mock("@lib/credentials", () => ({
|
|
saveCredential: vi.fn().mockResolvedValue(true),
|
|
loadCredential: vi.fn().mockResolvedValue(null),
|
|
deleteCredential: vi.fn().mockResolvedValue(undefined),
|
|
createUserUpdateCredentialSaver: vi.fn(() => vi.fn()),
|
|
}));
|
|
vi.mock("@lib/window-state", () => ({ initWindowState: vi.fn().mockResolvedValue(undefined) }));
|
|
vi.mock("@lib/deep-link", () => ({ initDeepLinks: vi.fn().mockResolvedValue(undefined) }));
|
|
vi.mock("@lib/message-navigation", () => ({ jumpToMessage: vi.fn() }));
|
|
vi.mock("@components/CertMismatchModal", () => ({
|
|
createCertMismatchModal: vi.fn(() => ({ mount: vi.fn(), destroy: vi.fn() })),
|
|
createCertFirstUseModal: vi.fn(() => ({ mount: vi.fn(), destroy: vi.fn() })),
|
|
}));
|
|
vi.mock("@lib/cert-reconnect", () => ({ reconnectAfterCertAccept: vi.fn() }));
|
|
vi.mock("@lib/profiles", () => ({
|
|
createTauriBackend: vi.fn(() => ({})),
|
|
createProfileManager: vi.fn(() => ({
|
|
loadProfiles: vi.fn().mockResolvedValue(undefined),
|
|
saveProfiles: vi.fn().mockResolvedValue(undefined),
|
|
getAll: vi.fn(() => []),
|
|
addProfile: vi.fn((data: unknown) => ({ id: "profile-1", ...(data as object) })),
|
|
updateProfile: vi.fn(() => null),
|
|
removeProfile: vi.fn(() => true),
|
|
getAutoConnectProfile: vi.fn(() => null),
|
|
setAutoLogin: vi.fn(),
|
|
setLastConnected: vi.fn(),
|
|
})),
|
|
}));
|
|
|
|
// api.ts — only login() is exercised (it drives wirePostAuth); nothing else
|
|
// in this flow touches the REST client. getConfig()/setConfig() track a real
|
|
// host so OC-0028's test can reproduce the isAuthenticated subscriber's
|
|
// `api.getConfig().host` read (main.ts:776) after a login sets it via
|
|
// `api.setConfig({ host })` (main.ts:515).
|
|
const mockLogin = vi.fn();
|
|
const mockApiState = { host: "" };
|
|
vi.mock("@lib/api", () => ({
|
|
createApiClient: vi.fn(() => ({
|
|
setConfig: vi.fn((cfg: { host?: string; token?: string }) => {
|
|
if (cfg.host !== undefined) mockApiState.host = cfg.host;
|
|
}),
|
|
getConfig: vi.fn(() => ({ host: mockApiState.host })),
|
|
login: (...args: unknown[]) => mockLogin(...args),
|
|
getHealth: vi.fn().mockResolvedValue({ version: null, online_users: null }),
|
|
})),
|
|
}));
|
|
|
|
// ConnectPage — captures the real onLogin callback main.ts wires up so the
|
|
// test can drive wirePostAuth exactly the way a real login does, without
|
|
// building the actual login form DOM.
|
|
const capturedConnectCallbacks: {
|
|
onLogin?: (host: string, username: string, password: string) => Promise<void>;
|
|
} = {};
|
|
vi.mock("@pages/ConnectPage", () => ({
|
|
createConnectPage: vi.fn((callbacks: typeof capturedConnectCallbacks) => {
|
|
Object.assign(capturedConnectCallbacks, callbacks);
|
|
return {
|
|
mount: vi.fn(),
|
|
destroy: vi.fn(),
|
|
showTotp: vi.fn(),
|
|
showConnecting: vi.fn(),
|
|
showAutoConnecting: vi.fn(),
|
|
showError: vi.fn(),
|
|
resetToIdle: vi.fn(),
|
|
updateHealthStatus: vi.fn(),
|
|
getRememberPassword: vi.fn(() => false),
|
|
getAutoConnect: vi.fn(() => false),
|
|
getPassword: vi.fn(() => ""),
|
|
refreshProfiles: vi.fn(),
|
|
selectServer: vi.fn(),
|
|
applyInviteLink: vi.fn(),
|
|
};
|
|
}),
|
|
}));
|
|
|
|
// MainPage.ts pulls in the whole chat/voice UI stack. OC-0028 needs a real
|
|
// "main" -> "connect" round trip through the router (main.ts only navigates
|
|
// away from "connect" via the connected overlay's onReady -> router.navigate
|
|
// ("main") callback), but doesn't care what MainPage renders, so stand in
|
|
// with the same lightweight shape main-page.test.ts's own mocks return.
|
|
vi.mock("@pages/MainPage", () => ({
|
|
createMainPage: vi.fn(() => ({ mount: vi.fn(), destroy: vi.fn() })),
|
|
}));
|
|
|
|
// dispatcher.ts pulls in nearly every store/service in the app. Stand in
|
|
// with a slim replacement that reproduces the one behavior these tests must
|
|
// stay faithful to: the real dispatcher's auth_ok handler calls setAuth() on
|
|
// the REAL authStore (imported below, not mocked) — so main.ts's own race
|
|
// against that write is exercised unmodified, not sidestepped.
|
|
vi.mock("@lib/dispatcher", async () => {
|
|
const { authStore, setAuth } = await import("@stores/auth.store");
|
|
return {
|
|
wireDispatcher: (ws: { on: (type: string, cb: (payload: unknown) => void) => () => void }) => {
|
|
const unsub = ws.on("auth_ok", (payload) => {
|
|
const p = payload as { user: unknown; server_name: string; motd: string };
|
|
setAuth(authStore.getState().token ?? "", p.user as never, p.server_name, p.motd);
|
|
});
|
|
return () => unsub();
|
|
},
|
|
wireConnectionStatus: vi.fn(() => () => {}),
|
|
};
|
|
});
|
|
|
|
import { mockInvoke, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks";
|
|
import { clearAuth } from "@stores/auth.store";
|
|
import { loadUserStatus, loadUserStatusOrigin } from "@lib/userStatus";
|
|
import { createMainPage } from "@pages/MainPage";
|
|
import { setActivePresenceSender, type PresenceSender } from "@lib/presence";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import the module under test AFTER all mocks are registered. #app must
|
|
// exist first: main.ts reads document.getElementById("app") synchronously
|
|
// at module top level, and a static `import` line would be hoisted above any
|
|
// DOM setup written before it in source order — so this runs inside an async
|
|
// beforeAll instead of a top-level import.
|
|
// ---------------------------------------------------------------------------
|
|
beforeAll(async () => {
|
|
document.body.innerHTML = '<div id="app"></div>';
|
|
await import("../../src/main");
|
|
// Flush the microtask the mocked (async) listen() call resolves on, so the
|
|
// "status-change" handler main.ts registers at module load is actually in
|
|
// eventHandlers before any test fires it.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset().mockResolvedValue(undefined);
|
|
localStorage.clear();
|
|
clearAuth();
|
|
});
|
|
|
|
/** Drive a full login → WS connect → auth_ok cycle through the captured
|
|
* ConnectPage callback and the real ws.ts client living inside main.ts. */
|
|
async function loginAndReachAuthOk(
|
|
host: string,
|
|
username: string,
|
|
authOkPayload: { user: unknown; server_name: string; motd: string },
|
|
): Promise<void> {
|
|
mockLogin.mockResolvedValue({ token: "test-token", requires_2fa: false });
|
|
await capturedConnectCallbacks.onLogin!(host, username, "hunter2");
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
emitTauriEvent("ws-message", JSON.stringify({ type: "auth_ok", payload: authOkPayload }));
|
|
}
|
|
|
|
describe("main.ts tray status-change listener (OC-0037)", () => {
|
|
it("persists a tray-selected status through saveUserStatus, not just the wire", async () => {
|
|
expect(eventHandlers.has("status-change")).toBe(true);
|
|
|
|
emitTauriEvent("status-change", "dnd");
|
|
|
|
// This is the crux of OC-0037: the tray path must agree with the
|
|
// client's own documented "single source of truth" for the selected
|
|
// status (lib/userStatus.ts), the same way UserBar's StatusPicker does.
|
|
// Before the fix nothing here ever calls saveUserStatus, so this stays
|
|
// "online" forever regardless of what the tray sent over the wire.
|
|
expect(loadUserStatus()).toBe("dnd");
|
|
expect(loadUserStatusOrigin()).toBe("manual");
|
|
});
|
|
|
|
it("maps the tray's legacy offline value to invisible, matching userStatus.ts's migration", async () => {
|
|
emitTauriEvent("status-change", "offline");
|
|
|
|
expect(loadUserStatus()).toBe("invisible");
|
|
});
|
|
});
|
|
|
|
describe("main.ts tray status-change routes through the shared PresenceSender (OC-0176)", () => {
|
|
afterEach(() => {
|
|
setActivePresenceSender(null);
|
|
});
|
|
|
|
it("sends the tray's chosen status through the session's registered PresenceSender, not a raw ws.send", () => {
|
|
// Stand in for the one PresenceSender MainPage.ts registers for the
|
|
// session (via setActivePresenceSender) — the shared limiter token, the
|
|
// coalescing retry, and the optimistic update all live inside it.
|
|
const fakeSender: PresenceSender = { send: vi.fn(), destroy: vi.fn() };
|
|
setActivePresenceSender(fakeSender);
|
|
|
|
emitTauriEvent("status-change", "dnd");
|
|
|
|
// Before the fix, main.ts calls ws.send({ type: "presence_update", ... })
|
|
// directly — bypassing this sender (and the shared rate-limit budget it
|
|
// enforces) entirely, so fakeSender.send is never called.
|
|
expect(fakeSender.send).toHaveBeenCalledExactlyOnceWith("dnd");
|
|
});
|
|
|
|
it("is a safe no-op (no throw) when no session's PresenceSender is registered", () => {
|
|
setActivePresenceSender(null);
|
|
|
|
expect(() => emitTauriEvent("status-change", "idle")).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("main.ts connected overlay (OC-0063)", () => {
|
|
it("shows the auth_ok payload's server_name and motd, not the pre-handshake authStore snapshot", async () => {
|
|
await loginAndReachAuthOk("192.168.1.10:8443", "alex", {
|
|
user: { id: 1, username: "alex", avatar: null, role: "member" },
|
|
server_name: "My Guild",
|
|
motd: "Welcome to My Guild!",
|
|
});
|
|
|
|
const overlay = document.querySelector('[data-testid="connected-overlay"]');
|
|
expect(overlay).not.toBeNull();
|
|
|
|
// ws.ts fires onStateChange("connected") synchronously BEFORE dispatching
|
|
// the auth_ok message that carries server_name/motd (ws.ts: setState()
|
|
// then dispatch() in the same handleMessage() call) — so a handler that
|
|
// reads authStore.getState() at that point sees the pre-auth_ok snapshot.
|
|
// Reading directly from the payload sidesteps the race.
|
|
const motdEl = overlay?.querySelector(".connected-motd");
|
|
expect(motdEl?.textContent).toBe("Welcome to My Guild!");
|
|
|
|
const iconEl = overlay?.querySelector(".connected-srv-icon");
|
|
expect(iconEl?.textContent).toBe("M"); // first letter of "My Guild", not "1" (host) or "" (blank auth)
|
|
});
|
|
});
|
|
|
|
describe("main.ts connected overlay teardown on mid-handshake session end (OC-0157)", () => {
|
|
it('destroys the connected overlay when auth clears before the router leaves "connect"', async () => {
|
|
await loginAndReachAuthOk("mid-handshake.example:8443", "casey", {
|
|
user: { id: 7, username: "casey", avatar: null, role: "member" },
|
|
server_name: "Mid Handshake Co",
|
|
motd: "",
|
|
});
|
|
|
|
// auth_ok landed: the overlay is mounted over #app while the router is
|
|
// still "connect" — it only moves to "main" from the overlay's own
|
|
// onReady, 800ms after the `ready` event arrives.
|
|
expect(document.querySelector('[data-testid="connected-overlay"]')).not.toBeNull();
|
|
|
|
// Simulate a session that ends here — a ban, an auth_error on an
|
|
// intervening reconnect, or a server shutdown — none of which the
|
|
// client ever receives `ready` for. dispatcher.ts's handlers for all
|
|
// three do `ws.disconnect(); clearAuth();` before `ready` can arrive.
|
|
clearAuth();
|
|
// authStore notifications are microtask-deferred (see store.ts).
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
// Before the fix, the isAuthenticated subscriber's gate is
|
|
// `router.getCurrentPage() === "main"` — since the router never left
|
|
// "connect", the subscriber no-ops entirely and the overlay
|
|
// (position:fixed, opaque, z-index 200) is orphaned over the connect
|
|
// page forever; only an app restart clears it.
|
|
expect(document.querySelector('[data-testid="connected-overlay"]')).toBeNull();
|
|
});
|
|
|
|
it("cancels the overlay's armed onReady timer when the session ends inside the 800ms ready window", async () => {
|
|
const mainPageCallsBefore = vi.mocked(createMainPage).mock.calls.length;
|
|
|
|
await loginAndReachAuthOk("wide-window.example:8443", "riley", {
|
|
user: { id: 8, username: "riley", avatar: null, role: "member" },
|
|
server_name: "Wide Window Co",
|
|
motd: "",
|
|
});
|
|
|
|
// `ready` arrives and arms the overlay's 800ms onReady timer (which
|
|
// would otherwise call router.navigate("main") on its own).
|
|
emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} }));
|
|
|
|
// The ban/shutdown lands partway through that 800ms window — well after
|
|
// `ready`, well before the timer fires.
|
|
await vi.advanceTimersByTimeAsync(300);
|
|
clearAuth();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(document.querySelector('[data-testid="connected-overlay"]')).toBeNull();
|
|
|
|
// Advance past the timer's original 800ms deadline. Before the fix, the
|
|
// subscriber never called connectedOverlay.destroy() (its AbortController
|
|
// is what cancels the pending setTimeout — see ConnectedOverlay.ts), so
|
|
// the already-armed timer still fires onReady() -> router.navigate("main"),
|
|
// mounting MainPage on a cleared authStore and a disconnected socket even
|
|
// though the isAuthenticated subscriber already ran and won't run again.
|
|
await vi.advanceTimersByTimeAsync(600);
|
|
|
|
expect(vi.mocked(createMainPage).mock.calls.length).toBe(mainPageCallsBefore);
|
|
});
|
|
});
|
|
|
|
describe("main.ts connect-page skip-auto-login flag (OC-0028)", () => {
|
|
afterEach(() => {
|
|
sessionStorage.clear();
|
|
mockApiState.host = "";
|
|
});
|
|
|
|
it("consumes owncord:skip-auto-login on a quick-switch mount, not just on a plain logout", async () => {
|
|
// Reach "main" for host A via an ordinary login — the quick-switch
|
|
// overlay (SidebarArea.ts) can only fire from a live session.
|
|
await loginAndReachAuthOk("server-a.example:8443", "alex", {
|
|
user: { id: 1, username: "alex", avatar: null, role: "member" },
|
|
server_name: "Server A",
|
|
motd: "",
|
|
});
|
|
emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} }));
|
|
// ConnectedOverlay.markReady() fires onReady after READY_DELAY_MS (800ms),
|
|
// which calls router.navigate("main") — main.ts's only route away from
|
|
// "connect", needed so a later navigate("connect") is a real transition
|
|
// and not a same-page no-op.
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
|
|
// Quick-switch overlay's flow (SidebarArea.ts:756-760): stash the target
|
|
// host, then log out via a bare clearAuth() (reason defaults to "user").
|
|
// The isAuthenticated subscriber below (main.ts:750-788) turns that into
|
|
// a stored "owncord:skip-auto-login" flag, since host is set and
|
|
// logoutReason !== "server_shutdown".
|
|
sessionStorage.setItem("owncord:quick-switch-target", "server-b.example:8443");
|
|
clearAuth();
|
|
|
|
// authStore notifications are microtask-deferred (see store.ts), and the
|
|
// connect page's own load-profiles IIFE awaits a mocked (but still
|
|
// native-Promise) loadProfiles() before reaching the quick-switch check —
|
|
// flush both hops.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
// Quick-switch consumed its own key...
|
|
expect(sessionStorage.getItem("owncord:quick-switch-target")).toBeNull();
|
|
// ...and per OC-0028 must ALSO consume skip-auto-login on this same
|
|
// mount. Before the fix, the quick-switch branch returns early (line 669)
|
|
// without ever reaching the skip-auto-login read/remove at line 680-683,
|
|
// so the flag set by the clearAuth() above survives indefinitely — and
|
|
// would go on to suppress the auto-login that a later, unrelated
|
|
// clearAuth("server_shutdown") deliberately relies on.
|
|
expect(sessionStorage.getItem("owncord:skip-auto-login")).toBeNull();
|
|
});
|
|
});
|