mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix: batch of 34 correctness fixes across server and client (#1372)
* fix(client): 3 defect(s) (OC-0037, OC-0063, OC-0116) Route the tray Status submenu through saveUserStatus() (mapping the legacy "offline" to "invisible") so notifications, autoIdle, and reconnect presence restore all agree with the tray's choice; build the connected overlay from the auth_ok payload instead of a pre-dispatch authStore snapshot; keep the TOTP overlay open across a rejected verify (totpPending latch) and retain the partial token for the retry instead of clearing it in finally. Hand-applied combined cluster preserved from the previous fix run's overlap-guard block (both clusters edit main.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 2 defect(s) (OC-0010, OC-0011) * fix(ws): 1 defect(s) (OC-0050) * fix(db): 1 defect(s) (OC-0052) * fix(client): 1 defect(s) (OC-0054) * fix(client): 1 defect(s) (OC-0059) * fix(auth): 1 defect(s) (OC-0061) * fix(ws): 1 defect(s) (OC-0062) * fix(client): 1 defect(s) (OC-0064) * fix(service): 1 defect(s) (OC-0070) * fix(ws): 1 defect(s) (OC-0073) * fix(service): 2 defect(s) (OC-0075, OC-0120) * fix(admin): 1 defect(s) (OC-0076) * fix(voice): 1 defect(s) (OC-0084) * fix(client): 2 defect(s) (OC-0085, OC-0094) Scope collapsed-category persistence to the connected host instead of the server display name, and stop the DM back button from jumping to the first text channel when DM mode was entered without recording channelBeforeDm. * fix(service): 1 defect(s) (OC-0087) * fix(client): 1 defect(s) (OC-0089) * fix(ws): 1 defect(s) (OC-0091) * fix(api): 1 defect(s) (OC-0093) * fix(identity): 1 defect(s) (OC-0118) * fix(dm): 1 defect(s) (OC-0119) * fix(voice): 1 defect(s) (OC-0135) * fix(api): 1 defect(s) (OC-0137) * fix(client): 1 defect(s) (OC-0142) * fix(client): 1 defect(s) (OC-0144) * fix(admin): 1 defect(s) (OC-0145) * fix(updater): 1 defect(s) (OC-0146) * fix(client): 1 defect(s) (OC-0150) * fix(mentions): 1 defect(s) (OC-0131) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
"decorations": true,
|
||||
"resizable": true,
|
||||
"center": true,
|
||||
"additionalBrowserArgs": "--autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream"
|
||||
"additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream"
|
||||
}
|
||||
],
|
||||
"withGlobalTauri": false,
|
||||
|
||||
@@ -866,6 +866,24 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
);
|
||||
unsubscribers.push(unsubAuth);
|
||||
|
||||
// canManageChannels()/canModerateVoice() read authStore.user.role and
|
||||
// channelsStore.roles at render time, but nothing above re-renders when
|
||||
// either changes on its own — a MEMBER_UPDATE for the signed-in user
|
||||
// (dispatcher.ts's self-branch) or a ROLES_UPDATE permission-mask edit
|
||||
// would otherwise leave the category "+", the channel context menu and
|
||||
// the voice-moderation menu stale until an unrelated channel/voice event
|
||||
// happened to fire renderChannels() (OC-0142).
|
||||
const unsubRole = authStore.subscribeSelector(
|
||||
(s) => s.user?.role ?? "",
|
||||
() => renderChannels(),
|
||||
);
|
||||
unsubscribers.push(unsubRole);
|
||||
const unsubRoles = channelsStore.subscribeSelector(
|
||||
(s) => s.roles,
|
||||
() => renderChannels(),
|
||||
);
|
||||
unsubscribers.push(unsubRoles);
|
||||
|
||||
// Subscribe to UI store for category collapse changes
|
||||
const unsubUi = uiStore.subscribeSelector(
|
||||
(s) => s.collapsedCategories,
|
||||
|
||||
@@ -152,11 +152,14 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(e: KeyboardEvent): void {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
if (root !== null && root.parentNode !== null) {
|
||||
options.onClose();
|
||||
}
|
||||
// Same case-insensitive, altKey-excluding match as
|
||||
// OverlayManagers.ts's open handler (OC-0150) — otherwise this close
|
||||
// path goes dead under CapsLock and AltGr swallows a keystroke for
|
||||
// nothing.
|
||||
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.key.toLowerCase() !== "k") return;
|
||||
e.preventDefault();
|
||||
if (root !== null && root.parentNode !== null) {
|
||||
options.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,15 @@ export function renderMentions(text: string, info?: MentionInfo): DocumentFragme
|
||||
}
|
||||
// Strip trailing punctuation that is likely sentence-level, not part of the URL
|
||||
const rawUrl = match[0];
|
||||
const stripped = rawUrl.replace(/[.,;:!?)]+$/, "");
|
||||
let stripped = rawUrl.replace(/[.,;:!?)]+$/, "");
|
||||
// Give back one trailing ")" if it balances an unmatched "(" earlier in
|
||||
// the URL — e.g. https://en.wikipedia.org/wiki/Rust_(programming_language)
|
||||
// is a real address, not prose wrapped in parens.
|
||||
if (rawUrl.length > stripped.length && rawUrl[stripped.length] === ")") {
|
||||
const opens = (stripped.match(/\(/g) ?? []).length;
|
||||
const closes = (stripped.match(/\)/g) ?? []).length;
|
||||
if (opens > closes) stripped = stripped + ")";
|
||||
}
|
||||
const trailing = rawUrl.slice(stripped.length);
|
||||
const url = stripped || rawUrl; // fallback if stripping emptied it
|
||||
if (isSafeUrl(url)) {
|
||||
|
||||
@@ -129,13 +129,20 @@ export class AudioElements {
|
||||
const userId = parseUserId(participant.identity);
|
||||
if (publication.source === Track.Source.ScreenShareAudio) {
|
||||
// Screenshare audio: manage via HTMLAudioElement volume (not participant.setVolume)
|
||||
for (const el of track.detach()) el.remove();
|
||||
// Look up the tracking set before detaching so a fast re-subscribe
|
||||
// (new TrackSubscribed before the old TrackUnsubscribed lands) drops
|
||||
// the stale element from the set instead of leaking it forever — same
|
||||
// hygiene as handleTrackUnsubscribedAudio below.
|
||||
let audioEls = this.screenshareAudioElements.get(userId);
|
||||
for (const el of track.detach()) {
|
||||
el.remove();
|
||||
audioEls?.delete(el);
|
||||
}
|
||||
const audioEl = track.attach();
|
||||
audioEl.style.display = "none";
|
||||
document.body.appendChild(audioEl);
|
||||
audioEl.volume = this.getEffectiveScreenshareVolume(userId);
|
||||
audioEl.muted = this.screenshareAudioMutedByUser.get(userId) ?? false;
|
||||
let audioEls = this.screenshareAudioElements.get(userId);
|
||||
if (audioEls === undefined) {
|
||||
audioEls = new Set();
|
||||
this.screenshareAudioElements.set(userId, audioEls);
|
||||
|
||||
@@ -1025,10 +1025,16 @@ export function wireDispatcher(
|
||||
// send/reaction rollback, not a capacity refusal) — this is the one
|
||||
// place every remaining server error lands (a rejected fire-and-forget
|
||||
// chat_edit, for one), so it must not be silently dropped just because
|
||||
// it isn't RATE_LIMITED/FORBIDDEN. Set synchronously, independent of
|
||||
// the video-rollback lookup below: both paths produce this exact same
|
||||
// message, so there is nothing left to gate on that lookup resolving.
|
||||
setTransientError(payload.message || "Server error");
|
||||
// it isn't RATE_LIMITED/FORBIDDEN. transientError has exactly one
|
||||
// reader — ConnectPage's login-screen banner — so writing it here is
|
||||
// invisible for the whole time the user is in-app (MainPage never
|
||||
// subscribes) and only resurfaces, stale and out of context, next time
|
||||
// the login screen mounts (OC-0064). Use the same in-app toast the
|
||||
// sibling CHANNEL_FULL/VIDEO_LIMIT branches above already use. Fire
|
||||
// synchronously, independent of the video-rollback lookup below: both
|
||||
// paths react to this exact same message, so there is nothing left to
|
||||
// gate on that lookup resolving.
|
||||
showToast(payload.message || "Server error", "error");
|
||||
|
||||
// A server refusal of a voice_camera/voice_screenshare enable other
|
||||
// than VIDEO_LIMIT (FORBIDDEN, RATE_LIMITED, INTERNAL, ...): roll back
|
||||
|
||||
@@ -207,9 +207,19 @@ const identityKeyPairCache = new Map<string, Promise<CryptoKeyPair>>();
|
||||
/** Composite keyring/memo key scoping the identity keypair by host AND user
|
||||
* id. The keyring commands only take a single opaque `host` string, so the
|
||||
* scope is folded into that one field rather than requiring a Rust-side
|
||||
* change. */
|
||||
* change.
|
||||
*
|
||||
* `userId` goes BEFORE `host`, joined with `@` rather than `:` (OC-0118):
|
||||
* `isValidHost` (api.ts) forbids '@' in any host — DNS name, IPv4, or
|
||||
* bracketed/bare IPv6 literal — so a scoped key can never collide with a
|
||||
* legacy host-only account (`identity:{host}`, pre-B3-3) OR with another
|
||||
* host's literal "host:port" string. The old `${host}:${userId}` format
|
||||
* had neither guarantee: `identityScopeKey("chat.example", 8443)` produced
|
||||
* the same string, "chat.example:8443", as the legacy host-only account of
|
||||
* a *different* server reachable at host "chat.example:8443" — silently
|
||||
* adopting that server's identity private key. */
|
||||
function identityScopeKey(host: string, userId: number): string {
|
||||
return `${host}:${userId}`;
|
||||
return `${userId}@${host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,6 +53,12 @@ export class E2EEManager {
|
||||
private _roomKey: Uint8Array | null = null;
|
||||
/** Peer ECDH public keys indexed by userId. */
|
||||
private _peerPublicKeys: Map<number, CryptoKey> = new Map();
|
||||
/** Ephemeral keys we've seen superseded for a given peer this session
|
||||
* (base64), indexed by userId. A signed announce carries no channel/epoch/
|
||||
* nonce (F3), so a validly-signed announce replays cleanly — this blocks a
|
||||
* replay of a key we already moved a peer off of from overwriting their
|
||||
* current live key (OC-0011). */
|
||||
private _retiredPeerKeys: Map<number, Set<string>> = new Map();
|
||||
/** This client's long-term ECDSA identity keypair (F3 TOFU), used to sign our
|
||||
* ephemeral announces. Loaded lazily from the OS keyring, cached per session. */
|
||||
private _identityKeyPair: CryptoKeyPair | null = null;
|
||||
@@ -158,6 +164,7 @@ export class E2EEManager {
|
||||
return false;
|
||||
}
|
||||
this._peerPublicKeys.clear();
|
||||
this._retiredPeerKeys.clear();
|
||||
clearPeerVerifications();
|
||||
const myPubKeyBase64 = await exportPublicKey(ecdhKeyPair.publicKey);
|
||||
// Build the signed announce up front — this loads the identity key from
|
||||
@@ -668,6 +675,23 @@ export class E2EEManager {
|
||||
return run;
|
||||
}
|
||||
|
||||
/** True if `publicKeyBase64` is a key we've already moved this peer off of
|
||||
* in the current session (see `_retiredPeerKeys`). */
|
||||
private isRetiredPeerKey(userId: number, publicKeyBase64: string): boolean {
|
||||
return this._retiredPeerKeys.get(userId)?.has(publicKeyBase64) ?? false;
|
||||
}
|
||||
|
||||
/** Record that `publicKeyBase64` is no longer this peer's live key —
|
||||
* a later announce carrying it again is a replay, not a legitimate change. */
|
||||
private retirePeerKey(userId: number, publicKeyBase64: string): void {
|
||||
const retired = this._retiredPeerKeys.get(userId);
|
||||
if (retired) {
|
||||
retired.add(publicKeyBase64);
|
||||
} else {
|
||||
this._retiredPeerKeys.set(userId, new Set([publicKeyBase64]));
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAnnounceInner(
|
||||
userId: number,
|
||||
publicKeyBase64: string,
|
||||
@@ -713,12 +737,42 @@ export class E2EEManager {
|
||||
isDuplicate = true;
|
||||
log.debug("E2EE: duplicate announce — will re-send offer if key holder", { userId });
|
||||
} else {
|
||||
// Reject a replay of a key we've already retired for this peer. The
|
||||
// signed announce message carries no channel/epoch/nonce (F3), so an
|
||||
// old, validly-signed announce replays cleanly — without this check a
|
||||
// malicious relay could re-emit a recorded announce and swap the live
|
||||
// key back to one nobody holds anymore, silently blackholing the peer
|
||||
// (OC-0011). A genuine peer never reuses an ephemeral key across
|
||||
// sessions (freshly generated every join), so this never rejects a
|
||||
// legitimate re-announce.
|
||||
if (this.isRetiredPeerKey(userId, publicKeyBase64)) {
|
||||
log.error("E2EE: rejecting replayed peer key announce (previously retired)", {
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.retirePeerKey(userId, existingB64);
|
||||
peerKey = await importPublicKey(publicKeyBase64);
|
||||
log.warn("E2EE: peer public key changed (reconnect?)", { userId });
|
||||
}
|
||||
} else {
|
||||
if (this.isRetiredPeerKey(userId, publicKeyBase64)) {
|
||||
log.error("E2EE: rejecting replayed peer key announce (previously retired)", { userId });
|
||||
return;
|
||||
}
|
||||
peerKey = await importPublicKey(publicKeyBase64);
|
||||
}
|
||||
// Re-check after the export/import awaits above: a clearState()+rejoin
|
||||
// landing during either one must not have this stale continuation write
|
||||
// a torn-down session's peer key into the map a NEW session now owns —
|
||||
// the generation guard above only covers the window up to verification,
|
||||
// not this later await (OC-0010).
|
||||
if (this._sessionGeneration !== myGeneration) {
|
||||
log.info("E2EE: discarding stale announce (session torn down during key import)", {
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isDuplicate) {
|
||||
this._peerPublicKeys.set(userId, peerKey);
|
||||
log.info("E2EE: received peer public key", { userId });
|
||||
@@ -828,6 +882,19 @@ export class E2EEManager {
|
||||
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
|
||||
log.info("E2EE: room key received and applied", { fromUserId });
|
||||
|
||||
// Re-check after the setKey await too: the guard above only covers the
|
||||
// window up to unwrap, not this call. A teardown-and-rejoin-as-holder
|
||||
// landing here would otherwise have this stale continuation read the
|
||||
// NEW session's live _isKeyHolder/_roomKeyResolver below and stand it
|
||||
// down / resolve it — corrupting a session this attempt no longer owns
|
||||
// (OC-0010).
|
||||
if (this._e2eeEpoch !== epochBefore || this._ecdhKeyPair !== keypair) {
|
||||
log.info("E2EE: discarding stale offer after setKey (epoch or session keypair changed)", {
|
||||
fromUserId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Accepting an offer proves the sender is the server-authoritative key
|
||||
// holder (the server gates outgoing offers on IsVoiceKeyHolder), so if we
|
||||
// still think we hold the key, we have been re-elected away — a lower
|
||||
@@ -1179,6 +1246,7 @@ export class E2EEManager {
|
||||
this._ecdhKeyPair = null;
|
||||
this._roomKey = null;
|
||||
this._peerPublicKeys.clear();
|
||||
this._retiredPeerKeys.clear();
|
||||
clearPeerVerifications();
|
||||
this._isKeyHolder = false;
|
||||
this._rotatingKey = false;
|
||||
|
||||
@@ -36,6 +36,7 @@ import { createCertMismatchModal, createCertFirstUseModal } from "@components/Ce
|
||||
import { reconnectAfterCertAccept } from "@lib/cert-reconnect";
|
||||
import { createProfileManager, createTauriBackend } from "@lib/profiles";
|
||||
import type { CertTofuEvent } from "@lib/ws";
|
||||
import { saveUserStatus } from "@lib/userStatus";
|
||||
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
@@ -243,15 +244,23 @@ ws.onCertMismatch((evt: CertTofuEvent) => {
|
||||
void ws.startCertListener();
|
||||
|
||||
// Route the tray's Status submenu (Online/Idle/Do Not Disturb/Offline) into
|
||||
// the same presence_update wire message the in-app StatusPicker sends
|
||||
// (UserBar.ts/MainPage.ts's applyPresence) — the Rust side only emitted
|
||||
// "status-change" with nothing in the webview listening for it. ws.send is a
|
||||
// safe no-op (logged) when there is no live session, so no auth guard is
|
||||
// needed here.
|
||||
// the same path the in-app StatusPicker uses (UserBar.ts): persist through
|
||||
// saveUserStatus() — lib/userStatus.ts's documented single source of truth —
|
||||
// before sending the wire message, not just a raw ws.send. Without this the
|
||||
// tray's choice never reaches loadUserStatus(), so notifications.ts's DND
|
||||
// gate, autoIdle's "never touch a manual DND/invisible" guard, and
|
||||
// restoreSavedPresence() on the next reconnect all silently disagree with
|
||||
// what the tray just set (OC-0037). ws.send is a safe no-op (logged) when
|
||||
// there is no live session, so no auth guard is needed here.
|
||||
void listen<string>("status-change", (e) => {
|
||||
const status = e.payload;
|
||||
if (status === "online" || status === "idle" || status === "dnd" || status === "offline") {
|
||||
ws.send({ type: "presence_update", payload: { status } });
|
||||
// The tray's legacy "offline" spelling maps to "invisible" the same way
|
||||
// userStatus.ts migrates an old client's stored "offline" value (see its
|
||||
// doc comment) — the local pref and the wire message must agree.
|
||||
const mapped = status === "offline" ? "invisible" : status;
|
||||
saveUserStatus(mapped);
|
||||
ws.send({ type: "presence_update", payload: { status: mapped } });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -379,33 +388,12 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
|
||||
log.debug("WS state change", { state: wsState });
|
||||
if (wsState === "connected") {
|
||||
// Stop listening once connected so a later transition can't fire this
|
||||
// handler again (which would append a second overlay).
|
||||
// handler again.
|
||||
unsubState();
|
||||
// Pre-warm the lazily-loaded MainPage chunk (and the LiveKit stack
|
||||
// behind it) so navigating past the connected overlay doesn't wait
|
||||
// on a dynamic import.
|
||||
void import("@pages/MainPage");
|
||||
const auth = authStore.getState();
|
||||
// Ensure exactly one overlay exists at a time.
|
||||
connectedOverlay?.destroy();
|
||||
connectedOverlay = createConnectedOverlay({
|
||||
serverName: auth.serverName ?? host,
|
||||
username: auth.user?.username ?? username,
|
||||
motd: auth.motd ?? "",
|
||||
onReady: () => {
|
||||
connectedOverlay?.destroy();
|
||||
connectedOverlay = null;
|
||||
router.navigate("main");
|
||||
},
|
||||
});
|
||||
appEl!.appendChild(connectedOverlay.element);
|
||||
connectedOverlay.show();
|
||||
|
||||
const unsubReady = ws.on("ready", () => {
|
||||
unsubReady();
|
||||
connectedOverlay?.markReady();
|
||||
});
|
||||
sessionUnsubs.push(unsubReady);
|
||||
} else if (wsState === "disconnected") {
|
||||
// Terminal non-connected transition (auth_error, cert-mismatch reject,
|
||||
// or intentional disconnect before ever connecting): drop the handler
|
||||
@@ -415,6 +403,38 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
|
||||
});
|
||||
sessionUnsubs.push(unsubState);
|
||||
|
||||
// Build the connected overlay from the auth_ok payload itself, not
|
||||
// authStore: ws.ts fires onStateChange("connected") synchronously BEFORE
|
||||
// dispatching the auth_ok message that carries server_name/motd
|
||||
// (setState() then dispatch() in the same handleMessage() call), so
|
||||
// authStore.setAuth() — run by the dispatcher's own auth_ok handler —
|
||||
// has not applied yet at that point. Reading straight from the payload
|
||||
// sidesteps the race instead of racing it (OC-0063).
|
||||
const unsubAuthOk = ws.on("auth_ok", (payload) => {
|
||||
unsubAuthOk();
|
||||
// Ensure exactly one overlay exists at a time.
|
||||
connectedOverlay?.destroy();
|
||||
connectedOverlay = createConnectedOverlay({
|
||||
serverName: payload.server_name ?? host,
|
||||
username: payload.user.username ?? username,
|
||||
motd: payload.motd ?? "",
|
||||
onReady: () => {
|
||||
connectedOverlay?.destroy();
|
||||
connectedOverlay = null;
|
||||
router.navigate("main");
|
||||
},
|
||||
});
|
||||
appEl!.appendChild(connectedOverlay.element);
|
||||
connectedOverlay.show();
|
||||
|
||||
const unsubReady = ws.on("ready", () => {
|
||||
unsubReady();
|
||||
connectedOverlay?.markReady();
|
||||
});
|
||||
sessionUnsubs.push(unsubReady);
|
||||
});
|
||||
sessionUnsubs.push(unsubAuthOk);
|
||||
|
||||
sessionCleanup = () => {
|
||||
for (const unsub of sessionUnsubs) unsub();
|
||||
sessionUnsubs.length = 0;
|
||||
@@ -521,28 +541,32 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
|
||||
log.error("TOTP submit without pending partial token");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api.verifyTotp(code, pendingTotpPartialToken);
|
||||
if (result.token) {
|
||||
const remember = connectPage.getRememberPassword();
|
||||
const savedPassword = remember ? connectPage.getPassword() : undefined;
|
||||
ensureProfileExists(
|
||||
pendingTotpHost,
|
||||
pendingTotpUsername,
|
||||
remember,
|
||||
connectPage.getAutoConnect(),
|
||||
);
|
||||
wirePostAuth(
|
||||
pendingTotpHost,
|
||||
result.token,
|
||||
pendingTotpUsername,
|
||||
savedPassword,
|
||||
remember,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Clear sensitive partial token immediately after use (success or failure)
|
||||
const result = await api.verifyTotp(code, pendingTotpPartialToken);
|
||||
if (result.token) {
|
||||
// Clear the sensitive partial token now that it has been
|
||||
// exchanged for a real session token. A rejected code must NOT
|
||||
// clear it here — the TOTP *code* is single-use (the server
|
||||
// 401s a replay), but the partial token is the short-lived 2FA
|
||||
// challenge itself and stays valid for a retry. LoginForm keeps
|
||||
// the TOTP overlay open across a failed verify for exactly that
|
||||
// retry; clearing this unconditionally (the old `finally`) made
|
||||
// every retry hit the guard above and silently do nothing.
|
||||
pendingTotpPartialToken = "";
|
||||
const remember = connectPage.getRememberPassword();
|
||||
const savedPassword = remember ? connectPage.getPassword() : undefined;
|
||||
ensureProfileExists(
|
||||
pendingTotpHost,
|
||||
pendingTotpUsername,
|
||||
remember,
|
||||
connectPage.getAutoConnect(),
|
||||
);
|
||||
wirePostAuth(
|
||||
pendingTotpHost,
|
||||
result.token,
|
||||
pendingTotpUsername,
|
||||
savedPassword,
|
||||
remember,
|
||||
);
|
||||
}
|
||||
},
|
||||
onAddProfile(name, host) {
|
||||
|
||||
@@ -519,7 +519,12 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
children.push(settingsOverlay);
|
||||
|
||||
// Quick switcher (Ctrl+K)
|
||||
const qsManager = createQuickSwitcherManager(() => root);
|
||||
// Don't fire while the settings panel is on top of it — same guard as
|
||||
// attachGlobalKeybinds below, reading the same source of truth.
|
||||
const qsManager = createQuickSwitcherManager(
|
||||
() => root,
|
||||
() => uiStore.getState().settingsOpen,
|
||||
);
|
||||
unsubscribers.push(qsManager.attach());
|
||||
|
||||
// The rest of the shortcuts listed on the settings Keybinds tab.
|
||||
|
||||
@@ -82,6 +82,11 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
let formState: FormState = "idle";
|
||||
let formMode: FormMode = "login";
|
||||
let errorMessage = "";
|
||||
// True while a TOTP challenge is outstanding (from showTotp() until it is
|
||||
// cancelled or resolved). A rejected verify moves formState to "error" for
|
||||
// the banner/shake, but the overlay must stay up so the code can be
|
||||
// re-entered — see updateTotpOverlay().
|
||||
let totpPending = false;
|
||||
|
||||
// --- cached DOM references ---
|
||||
let formTitle: HTMLHeadingElement;
|
||||
@@ -521,6 +526,11 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
totpOverlay.classList.remove("totp-overlay--hidden");
|
||||
totpInput.value = "";
|
||||
totpInput.focus();
|
||||
} else if (formState === "error" && totpPending) {
|
||||
// A rejected verify lands here — keep the overlay up (and the
|
||||
// already-entered code in place) instead of dropping the user back on
|
||||
// the login form with no way to retry.
|
||||
totpOverlay.classList.remove("totp-overlay--hidden");
|
||||
} else {
|
||||
totpOverlay.classList.add("totp-overlay--hidden");
|
||||
}
|
||||
@@ -672,6 +682,7 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
}
|
||||
|
||||
function handleTotpCancel(): void {
|
||||
totpPending = false;
|
||||
transitionTo("idle");
|
||||
}
|
||||
|
||||
@@ -686,6 +697,7 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
autoConnectOverlayElement: autoConnectOverlay,
|
||||
|
||||
showTotp(): void {
|
||||
totpPending = true;
|
||||
transitionTo("totp");
|
||||
},
|
||||
|
||||
@@ -703,6 +715,7 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
},
|
||||
|
||||
resetToIdle(): void {
|
||||
totpPending = false;
|
||||
transitionTo("idle");
|
||||
},
|
||||
|
||||
|
||||
@@ -119,7 +119,17 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
// controller scope does not turn it into a session-long transcript.
|
||||
const draftByCorrelation = new Map<
|
||||
string,
|
||||
{ content: string; replyTo: number | null; attachments: readonly string[] }
|
||||
{
|
||||
content: string;
|
||||
replyTo: number | null;
|
||||
attachments: readonly string[];
|
||||
// Which channel this cid was actually sent to. chat_send_ok and the
|
||||
// SLOW_MODE error are global ws.on subscriptions with no channel_id of
|
||||
// their own (OC-0059) — a late frame for a send made in a channel the
|
||||
// user has since left must not be attributed to whatever channel is
|
||||
// mounted when it arrives.
|
||||
channelId: number;
|
||||
}
|
||||
>();
|
||||
|
||||
function destroyChannel(): void {
|
||||
@@ -223,7 +233,7 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
// failed row with retry rather than silently dropping the message.
|
||||
const cid = crypto.randomUUID();
|
||||
addOptimisticMessage({ correlationId: cid, channelId, user, content, replyTo, timestamp });
|
||||
draftByCorrelation.set(cid, { content, replyTo, attachments });
|
||||
draftByCorrelation.set(cid, { content, replyTo, attachments, channelId });
|
||||
markSendFailed(cid, "OFFLINE");
|
||||
return;
|
||||
}
|
||||
@@ -232,7 +242,7 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
payload: { channel_id: channelId, content, reply_to: replyTo, attachments },
|
||||
});
|
||||
addOptimisticMessage({ correlationId: cid, channelId, user, content, replyTo, timestamp });
|
||||
draftByCorrelation.set(cid, { content, replyTo, attachments });
|
||||
draftByCorrelation.set(cid, { content, replyTo, attachments, channelId });
|
||||
}
|
||||
|
||||
function retrySend(correlationId: string): void {
|
||||
@@ -453,9 +463,23 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
};
|
||||
composerGatingUnsubs.push(stopSlowModeTicker);
|
||||
|
||||
// Both chat_send_ok and the SLOW_MODE error are global ws.on
|
||||
// subscriptions carrying no channel_id of their own — only the
|
||||
// correlation id ties a frame back to the send that produced it. A send
|
||||
// made in a channel the user has since left can still be in flight when
|
||||
// its ack/refusal arrives, and by then this listener belongs to whatever
|
||||
// channel is newly mounted (OC-0059). Absent/empty correlation ids never
|
||||
// happen over the real transport, so fall back to attributing to the
|
||||
// mounted channel rather than silently dropping every ack.
|
||||
const sentToMountedChannel = (correlationId: string | undefined): boolean =>
|
||||
correlationId === undefined ||
|
||||
correlationId === "" ||
|
||||
draftByCorrelation.get(correlationId)?.channelId === channelId;
|
||||
|
||||
// The server accepted a message — the next one is subject to the cooldown.
|
||||
composerGatingUnsubs.push(
|
||||
ws.on("chat_send_ok", (_payload, correlationId) => {
|
||||
const sameChannel = sentToMountedChannel(correlationId);
|
||||
// An accepted send can never be retried, so its draft is dead weight.
|
||||
// The map is controller-scoped (a failed row outlives a channel
|
||||
// switch, so its draft must too), which means nothing else would ever
|
||||
@@ -464,7 +488,7 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
draftByCorrelation.delete(correlationId);
|
||||
}
|
||||
const ch = channelsStore.getState().channels.get(channelId);
|
||||
if (ch !== undefined && ch.id === channelsStore.getState().activeChannelId) {
|
||||
if (ch !== undefined && ch.id === channelsStore.getState().activeChannelId && sameChannel) {
|
||||
startSlowMode(ch.slowMode);
|
||||
}
|
||||
}),
|
||||
@@ -472,8 +496,9 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
// A refused send restarts the full window: the server's limiter is the
|
||||
// authority on when the next one is allowed.
|
||||
composerGatingUnsubs.push(
|
||||
ws.on("error", (payload) => {
|
||||
ws.on("error", (payload, correlationId) => {
|
||||
if (payload.code !== "SLOW_MODE") return;
|
||||
if (!sentToMountedChannel(correlationId)) return;
|
||||
const ch = channelsStore.getState().channels.get(channelId);
|
||||
if (ch !== undefined) startSlowMode(ch.slowMode);
|
||||
}),
|
||||
|
||||
@@ -92,6 +92,8 @@ export interface QuickSwitcherManager {
|
||||
|
||||
export function createQuickSwitcherManager(
|
||||
getRoot: () => HTMLDivElement | null,
|
||||
/** Optional: suppress the shortcut while another overlay owns input (e.g. Settings). */
|
||||
isSuspended?: () => boolean,
|
||||
): QuickSwitcherManager {
|
||||
let instance: MountableComponent | null = null;
|
||||
|
||||
@@ -116,13 +118,18 @@ export function createQuickSwitcherManager(
|
||||
|
||||
function attach(): () => void {
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
if (instance !== null) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
// Mirrors GlobalKeybinds.ts's guard: `e.key` is layout-dependent and
|
||||
// uppercases under CapsLock/Shift, so compare case-insensitively;
|
||||
// exclude altKey so AltGr (reported as ctrlKey+altKey on Windows)
|
||||
// doesn't swallow a non-US character; and honour the same suspension
|
||||
// every other app-wide shortcut respects.
|
||||
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.key.toLowerCase() !== "k") return;
|
||||
if (isSuspended?.() === true) return;
|
||||
e.preventDefault();
|
||||
if (instance !== null) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
|
||||
@@ -232,9 +232,12 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
|
||||
sidebarWrapper.appendChild(serverHeader);
|
||||
|
||||
// Load per-server collapsed category state from localStorage
|
||||
const initialServerName = authStore.getState().serverName ?? "Server";
|
||||
loadCollapsedCategories(initialServerName);
|
||||
// Load per-server collapsed category state from localStorage, scoped to
|
||||
// the connected host (not the display name) — the same convention as
|
||||
// setChannelMutesHost/setNsfwGateHost/setAudioVolumeHost. The display name
|
||||
// defaults to "OwnCord Server" on every unmodified install, so keying on
|
||||
// it would collapse two different servers' saved state onto one entry.
|
||||
loadCollapsedCategories(api.getConfig().host);
|
||||
|
||||
// Keep server name in sync with auth store
|
||||
const unsubServerName = authStore.subscribeSelector(
|
||||
@@ -541,12 +544,21 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
if (channelBeforeDm !== null) {
|
||||
setActiveChannel(channelBeforeDm);
|
||||
channelBeforeDm = null;
|
||||
} else {
|
||||
for (const ch of channelsStore.getState().channels.values()) {
|
||||
if (ch.type === "text") {
|
||||
setActiveChannel(ch.id);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// No saved channel — this happens when DM mode was entered without
|
||||
// going through selectDmConversation (e.g. SidebarDmSection's "View
|
||||
// all messages" button, which does a bare setSidebarMode). If a real
|
||||
// non-DM channel is already active, leave it alone instead of
|
||||
// silently jumping to the first text channel in Map order.
|
||||
const st = channelsStore.getState();
|
||||
const current =
|
||||
st.activeChannelId !== null ? st.channels.get(st.activeChannelId) : undefined;
|
||||
if (current !== undefined && current.type !== "dm") return;
|
||||
for (const ch of channelsStore.getState().channels.values()) {
|
||||
if (ch.type === "text") {
|
||||
setActiveChannel(ch.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { UserWithRole } from "@lib/types";
|
||||
import { resetVoiceStore, voiceStore } from "@stores/voice.store";
|
||||
import { resetMessagesStore } from "@stores/messages.store";
|
||||
import { resetChannelsStore } from "@stores/channels.store";
|
||||
import { resetBlocksStore } from "@stores/blocks.store";
|
||||
import { cleanupNotificationAudio } from "@lib/notifications";
|
||||
import { clearNsfwAcknowledgements } from "@lib/nsfw-gate";
|
||||
import { createLogger } from "@lib/logger";
|
||||
@@ -71,7 +72,11 @@ export function setAuth(token: string, user: UserWithRole, serverName: string, m
|
||||
* previous session's messages, and same-account relogin would leave a
|
||||
* permanent hole for messages posted while logged out. Also clears
|
||||
* channelsStore: setChannels' DM-row carry otherwise re-inserts the
|
||||
* previous server's DM channel rows into the next server's channel map. */
|
||||
* previous server's DM channel rows into the next server's channel map.
|
||||
* Also clears blocksStore: block state is keyed by user id, which (like
|
||||
* channel/message ids) is only unique per-server — otherwise a previous
|
||||
* server's blocked-user ids would gate DM composers on the next server
|
||||
* until the next successful GET /blocks refetch. */
|
||||
export function clearAuth(reason: LogoutReason = "user"): void {
|
||||
// livekitSession (and the ~1.3 MB livekit-client SDK behind it) is loaded
|
||||
// lazily so it stays out of the startup path. Only import it when there is
|
||||
@@ -91,6 +96,7 @@ export function clearAuth(reason: LogoutReason = "user"): void {
|
||||
resetVoiceStore();
|
||||
resetMessagesStore();
|
||||
resetChannelsStore();
|
||||
resetBlocksStore();
|
||||
// NSFW acknowledgements are per-viewer consent, not per-device: without this
|
||||
// the next account signed into the same server inherits the previous user's
|
||||
// acks and the age gate silently never appears for them. Host-scoping the
|
||||
|
||||
@@ -64,6 +64,13 @@ export function clearBlockedByThem(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** Reset both block directions (called on clearAuth — user ids are only
|
||||
* unique per-server, so a previous server's block list must not carry
|
||||
* into the next session). */
|
||||
export function resetBlocksStore(): void {
|
||||
blocksStore.setState(() => INITIAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer disable reason for a DM with `recipientId`, or null if unblocked.
|
||||
* blockedByMe takes precedence so the user always sees that they are the blocker.
|
||||
|
||||
@@ -5024,6 +5024,10 @@ ul.md-list-nested {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-cell.track-muted video {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.video-username {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
|
||||
@@ -127,6 +127,42 @@ describe("AudioElements", () => {
|
||||
|
||||
expect(audioEl.muted).toBe(true);
|
||||
});
|
||||
|
||||
it("does not leak the previously-attached element into the tracking set on a fast re-subscribe (OC-0135)", () => {
|
||||
// LiveKit can fire TrackSubscribed for an already-attached screenshare-
|
||||
// audio track before the old TrackUnsubscribed lands (fast reconnect).
|
||||
// The same underlying track's detach() then returns the element from
|
||||
// the prior attach(), which the handler removes from the DOM but must
|
||||
// also drop from screenshareAudioElements — otherwise it lives on in
|
||||
// the Set forever.
|
||||
let lastEl: HTMLAudioElement | null = null;
|
||||
const track = {
|
||||
kind: "audio",
|
||||
sid: "track-ss-resub",
|
||||
attach: vi.fn(() => {
|
||||
const el = document.createElement("audio");
|
||||
lastEl = el;
|
||||
return el;
|
||||
}),
|
||||
detach: vi.fn(() => (lastEl === null ? [] : [lastEl])),
|
||||
};
|
||||
const publication = { source: "screenShareAudio" };
|
||||
const participant = { identity: "user-42", setVolume: vi.fn() };
|
||||
|
||||
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
||||
const firstEl = lastEl as unknown as HTMLAudioElement;
|
||||
|
||||
// Re-fire subscribe for the same track before any unsubscribe arrives.
|
||||
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
||||
const secondEl = lastEl as unknown as HTMLAudioElement;
|
||||
|
||||
const trackedEls = (elements as any).screenshareAudioElements.get(
|
||||
42,
|
||||
) as Set<HTMLAudioElement>;
|
||||
expect(trackedEls.has(firstEl)).toBe(false);
|
||||
expect(trackedEls.has(secondEl)).toBe(true);
|
||||
expect(trackedEls.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleTrackUnsubscribedAudio", () => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { clearAuth } from "../../src/stores/auth.store";
|
||||
import {
|
||||
blocksStore,
|
||||
setUserBlockedByMe,
|
||||
setUserBlockedByThem,
|
||||
} from "../../src/stores/blocks.store";
|
||||
|
||||
describe("clearAuth", () => {
|
||||
it("resets blocksStore.blockedByMe so the next server's session doesn't inherit it", () => {
|
||||
// Server A: block user 7.
|
||||
setUserBlockedByMe(7, true);
|
||||
expect(blocksStore.getState().blockedByMe.has(7)).toBe(true);
|
||||
|
||||
// Log out (as UserBar disconnect / Settings logout / quick-switch does).
|
||||
clearAuth();
|
||||
|
||||
// Server B: user id 7 is an unrelated person. A previous server's block
|
||||
// must not still gate their DM composer / offer "Unblock" for them.
|
||||
expect(blocksStore.getState().blockedByMe.has(7)).toBe(false);
|
||||
expect(blocksStore.getState().blockedByMe.size).toBe(0);
|
||||
});
|
||||
|
||||
it("resets blocksStore.blockedByThem too", () => {
|
||||
setUserBlockedByThem(9, true);
|
||||
expect(blocksStore.getState().blockedByThem.has(9)).toBe(true);
|
||||
|
||||
clearAuth();
|
||||
|
||||
expect(blocksStore.getState().blockedByThem.has(9)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1426,6 +1426,56 @@ describe("createChannelController", () => {
|
||||
expect(mockSetDisabled).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it("does not gate the newly mounted channel with a late ack for a message sent in the previous channel (OC-0059)", () => {
|
||||
// Channel A has slow mode; channel B does too, with a different value,
|
||||
// so a misattributed ack is unmistakable.
|
||||
setChannels([
|
||||
{
|
||||
id: 42,
|
||||
name: "general",
|
||||
type: "text",
|
||||
category: null,
|
||||
position: 0,
|
||||
can_send: true,
|
||||
slow_mode: 5,
|
||||
},
|
||||
{
|
||||
id: 43,
|
||||
name: "other",
|
||||
type: "text",
|
||||
category: null,
|
||||
position: 0,
|
||||
can_send: true,
|
||||
slow_mode: 7,
|
||||
},
|
||||
]);
|
||||
setActiveChannel(42);
|
||||
const opts = makeOpts();
|
||||
let n = 0;
|
||||
(opts.ws.send as ReturnType<typeof vi.fn>).mockImplementation(() => `cid-${++n}`);
|
||||
const ctrl = createChannelController(opts);
|
||||
ctrl.mountChannel(42, "general");
|
||||
|
||||
// cid-1 is channel_focus; the chat_send in A gets cid-2. The ack for it
|
||||
// does not arrive before the user switches away.
|
||||
capturedMessageInputOpts.onSend("hello", null, []);
|
||||
|
||||
// Switch to channel B before A's ack arrives.
|
||||
setActiveChannel(43);
|
||||
ctrl.mountChannel(43, "other");
|
||||
mockSetDisabled.mockClear();
|
||||
|
||||
// A's late chat_send_ok now arrives; only B's handler is subscribed.
|
||||
const ackCalls = (opts.ws.on as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(c: unknown[]) => c[0] === "chat_send_ok",
|
||||
);
|
||||
const onAck = ackCalls[ackCalls.length - 1]![1] as (payload: unknown, id?: string) => void;
|
||||
onAck({ message_id: 7, timestamp: "2024-01-01T00:00:00Z" }, "cid-2");
|
||||
|
||||
// B was never sent to and must not be gated by A's cooldown.
|
||||
expect(mockSetDisabled).not.toHaveBeenCalledWith(expect.stringContaining("Slow mode"));
|
||||
});
|
||||
|
||||
it("stops the countdown when the channel unmounts", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -1260,6 +1260,70 @@ describe("ChannelSidebar", () => {
|
||||
expect(container.querySelector(".category-add-btn")).toBeNull();
|
||||
});
|
||||
|
||||
// ── Live role-change repaint (OC-0142) ──
|
||||
// canManageChannels() is evaluated at render time from authStore.user.role,
|
||||
// but the sidebar's only authStore subscription selects serverName. A
|
||||
// MEMBER_UPDATE for the signed-in user (dispatcher.ts writes the new role
|
||||
// via updateUser) must still cause a repaint without any unrelated event.
|
||||
|
||||
it("repaints channel-management affordances when the signed-in user's own role changes", () => {
|
||||
const onCreateChannel = vi.fn();
|
||||
sidebar.destroy?.();
|
||||
authStore.setState(() => ({
|
||||
token: "tok",
|
||||
user: { id: 2, username: "Member", avatar: null, role: "member" },
|
||||
serverName: "Test Server",
|
||||
motd: null,
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onCreateChannel });
|
||||
|
||||
setChannels(testChannels);
|
||||
sidebar.mount(container);
|
||||
|
||||
// Starts as a plain member: no "+" button.
|
||||
expect(container.querySelector(".category-add-btn")).toBeNull();
|
||||
|
||||
// Promoted to admin (mirrors dispatcher.ts's MEMBER_UPDATE self-branch,
|
||||
// which patches authStore via updateUser({ role })) — no channel/voice
|
||||
// event fires alongside it.
|
||||
authStore.setState((prev) => ({
|
||||
...prev,
|
||||
user: prev.user === null ? null : { ...prev.user, role: "admin" },
|
||||
}));
|
||||
authStore.flush();
|
||||
|
||||
expect(container.querySelector(".category-add-btn")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("repaints channel-management affordances when the role list's permission mask changes", () => {
|
||||
const onCreateChannel = vi.fn();
|
||||
sidebar.destroy?.();
|
||||
setRoles([{ id: 3, name: "Moderator", color: null, permissions: Permission.SEND_MESSAGES }]);
|
||||
authStore.setState(() => ({
|
||||
token: "tok",
|
||||
user: { id: 3, username: "Mod", avatar: null, role: "moderator" },
|
||||
serverName: "Test Server",
|
||||
motd: null,
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onCreateChannel });
|
||||
|
||||
setChannels(testChannels);
|
||||
sidebar.mount(container);
|
||||
|
||||
// Moderator role holds no MANAGE_CHANNELS bit yet.
|
||||
expect(container.querySelector(".category-add-btn")).toBeNull();
|
||||
|
||||
// A ROLES_UPDATE grants MANAGE_CHANNELS to the same role (dispatcher.ts's
|
||||
// ROLES_UPDATE handler replaces the whole list via setRoles) — again with
|
||||
// no accompanying channel/voice event.
|
||||
setRoles([{ id: 3, name: "Moderator", color: null, permissions: Permission.MANAGE_CHANNELS }]);
|
||||
channelsStore.flush();
|
||||
|
||||
expect(container.querySelector(".category-add-btn")).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── Voice user volume context menu ──
|
||||
|
||||
it("right-click on other user's voice row opens volume context menu", () => {
|
||||
|
||||
@@ -2348,36 +2348,47 @@ describe("WS Dispatcher", () => {
|
||||
expect(mock.ws.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wires error RATE_LIMITED to transient error", () => {
|
||||
// OC-0064: transientError has exactly one reader in the whole client —
|
||||
// ConnectPage's login-screen subscription. Routing the catch-all fallback
|
||||
// through it means an error raised while the user is in-app (MainPage
|
||||
// never subscribes) is invisible until the user later lands back on the
|
||||
// login screen, where it resurfaces stale and out of context. The
|
||||
// catch-all must use the same in-app toast the sibling CHANNEL_FULL /
|
||||
// VIDEO_LIMIT branches already use, and must leave transientError alone.
|
||||
it("wires error RATE_LIMITED to an in-app toast (OC-0064)", () => {
|
||||
mockShowToast.mockClear();
|
||||
mock.dispatch("error", {
|
||||
code: "RATE_LIMITED",
|
||||
message: "Too many requests",
|
||||
});
|
||||
|
||||
const error = uiStore.getState().transientError;
|
||||
expect(error).toBe("Too many requests");
|
||||
expect(mockShowToast).toHaveBeenCalledWith("Too many requests", "error");
|
||||
expect(uiStore.getState().transientError).toBeNull();
|
||||
});
|
||||
|
||||
it("wires error FORBIDDEN to transient error", () => {
|
||||
it("wires error FORBIDDEN to an in-app toast (OC-0064)", () => {
|
||||
mockShowToast.mockClear();
|
||||
mock.dispatch("error", {
|
||||
code: "FORBIDDEN",
|
||||
message: "Insufficient permissions",
|
||||
});
|
||||
|
||||
const error = uiStore.getState().transientError;
|
||||
expect(error).toBe("Insufficient permissions");
|
||||
expect(mockShowToast).toHaveBeenCalledWith("Insufficient permissions", "error");
|
||||
expect(uiStore.getState().transientError).toBeNull();
|
||||
});
|
||||
|
||||
it("wires error RATE_LIMITED with empty message uses default", () => {
|
||||
it("wires error RATE_LIMITED with empty message uses default (OC-0064)", () => {
|
||||
mockShowToast.mockClear();
|
||||
mock.dispatch("error", { code: "RATE_LIMITED", message: "" });
|
||||
const error = uiStore.getState().transientError;
|
||||
expect(error).toBe("Server error");
|
||||
expect(mockShowToast).toHaveBeenCalledWith("Server error", "error");
|
||||
expect(uiStore.getState().transientError).toBeNull();
|
||||
});
|
||||
|
||||
it("wires error with an unrecognized code to the generic fallback banner", () => {
|
||||
it("wires error with an unrecognized code to the generic fallback toast (OC-0064)", () => {
|
||||
// The final fallthrough is the one place every unmatched error code
|
||||
// lands (e.g. a rejected fire-and-forget chat_edit) — it must not be
|
||||
// silently dropped just because it isn't RATE_LIMITED/FORBIDDEN.
|
||||
mockShowToast.mockClear();
|
||||
uiStore.setState((prev) => ({ ...prev, transientError: null }));
|
||||
|
||||
mock.dispatch("error", {
|
||||
@@ -2385,19 +2396,22 @@ describe("WS Dispatcher", () => {
|
||||
message: "Something odd",
|
||||
});
|
||||
|
||||
expect(uiStore.getState().transientError).toBe("Something odd");
|
||||
expect(mockShowToast).toHaveBeenCalledWith("Something odd", "error");
|
||||
expect(uiStore.getState().transientError).toBeNull();
|
||||
});
|
||||
|
||||
it("wires a BAD_REQUEST error with no pending correlation (e.g. a rejected chat_edit) to a transient error", () => {
|
||||
it("wires a BAD_REQUEST error with no pending correlation (e.g. a rejected chat_edit) to an in-app toast (OC-0064)", () => {
|
||||
// chat_edit is fire-and-forget: it never enters pendingSends, so a
|
||||
// rejection's envelope id matches nothing above and used to fall through
|
||||
// this handler silently, leaving the user's edited text destroyed with
|
||||
// no error shown (only RATE_LIMITED/FORBIDDEN were bannered).
|
||||
mockShowToast.mockClear();
|
||||
uiStore.setState((prev) => ({ ...prev, transientError: null }));
|
||||
|
||||
mock.dispatch("error", { code: "BAD_REQUEST", message: "Message too long" }, "edit-id-1");
|
||||
|
||||
expect(uiStore.getState().transientError).toBe("Message too long");
|
||||
expect(mockShowToast).toHaveBeenCalledWith("Message too long", "error");
|
||||
expect(uiStore.getState().transientError).toBeNull();
|
||||
});
|
||||
|
||||
it("wires an error carrying a pending send id to mark that row failed (not a toast)", () => {
|
||||
@@ -3448,8 +3462,11 @@ describe("WS Dispatcher", () => {
|
||||
vi.mocked(mockDisableCamera).mockClear();
|
||||
vi.mocked(mockDisableScreenshare).mockClear();
|
||||
uiStore.setState((prev) => ({ ...prev, transientError: null }));
|
||||
mockShowToast.mockClear();
|
||||
});
|
||||
|
||||
// OC-0064: the catch-all now toasts in-app instead of latching
|
||||
// transientError (which only the login screen ever reads).
|
||||
it("rolls back the camera publish on a correlated refusal", async () => {
|
||||
vi.mocked(mockRollbackPendingVideo).mockReturnValue("camera");
|
||||
|
||||
@@ -3459,7 +3476,8 @@ describe("WS Dispatcher", () => {
|
||||
expect(mockRollbackPendingVideo).toHaveBeenCalledWith("vid-1");
|
||||
expect(mockDisableCamera).toHaveBeenCalled();
|
||||
expect(mockDisableScreenshare).not.toHaveBeenCalled();
|
||||
expect(uiStore.getState().transientError).toBe("no permission");
|
||||
expect(mockShowToast).toHaveBeenCalledWith("no permission", "error");
|
||||
expect(uiStore.getState().transientError).toBeNull();
|
||||
});
|
||||
|
||||
it("rolls back the screenshare publish on a correlated refusal", async () => {
|
||||
@@ -3470,17 +3488,19 @@ describe("WS Dispatcher", () => {
|
||||
|
||||
expect(mockDisableScreenshare).toHaveBeenCalled();
|
||||
expect(mockDisableCamera).not.toHaveBeenCalled();
|
||||
expect(uiStore.getState().transientError).toBe("Server error");
|
||||
expect(mockShowToast).toHaveBeenCalledWith("Server error", "error");
|
||||
expect(uiStore.getState().transientError).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves an uncorrelated refusal as a plain transient error — no rollback", () => {
|
||||
it("leaves an uncorrelated refusal as a plain in-app toast — no rollback", () => {
|
||||
vi.mocked(mockRollbackPendingVideo).mockReturnValue(undefined);
|
||||
|
||||
mock.dispatch("error", { code: "FORBIDDEN", message: "nope" }, "unrelated-id");
|
||||
|
||||
expect(mockDisableCamera).not.toHaveBeenCalled();
|
||||
expect(mockDisableScreenshare).not.toHaveBeenCalled();
|
||||
expect(uiStore.getState().transientError).toBe("nope");
|
||||
expect(mockShowToast).toHaveBeenCalledWith("nope", "error");
|
||||
expect(uiStore.getState().transientError).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ import { authStore } from "@stores/auth.store";
|
||||
/**
|
||||
* Stateful keyring double for the legacy-migration tests: a Map keyed by the
|
||||
* exact `host` string each command receives (the scoped account
|
||||
* `chat.example:1` and the legacy account `chat.example` are just different
|
||||
* `1@chat.example` and the legacy account `chat.example` are just different
|
||||
* keys in the same map), so save/delete on one account cannot be confused
|
||||
* with another the way a host-agnostic mock would.
|
||||
*/
|
||||
@@ -169,7 +169,7 @@ describe("getOrCreateIdentityKeyPair", () => {
|
||||
expect(saveCall).toBeDefined();
|
||||
// Scoped by host AND user id (B3-3) — not just host — so two accounts
|
||||
// signed into the same host never share a keyring blob.
|
||||
expect((saveCall![1] as { host: string }).host).toBe("chat.example:1");
|
||||
expect((saveCall![1] as { host: string }).host).toBe("1@chat.example");
|
||||
});
|
||||
|
||||
it("reloads the persisted keypair on subsequent logins (no regenerate)", async () => {
|
||||
@@ -253,6 +253,26 @@ describe("getOrCreateIdentityKeyPair", () => {
|
||||
expect(await exportPublicKey(userB.publicKey)).not.toBe(await exportPublicKey(userA.publicKey));
|
||||
});
|
||||
|
||||
it("[OC-0118] a scoped host+userId account never collides with a legacy host-only account for a DIFFERENT host", async () => {
|
||||
// Pre-B3-3 install on some other server reachable as "chat.example:8443"
|
||||
// (host string carries an explicit port) stored its identity key under
|
||||
// the legacy host-only keyring account `identity:chat.example:8443`. A
|
||||
// completely different server reachable as "chat.example" (port 443)
|
||||
// signs in as the user whose id happens to be 8443:
|
||||
// identityScopeKey("chat.example", 8443) must NOT produce the same
|
||||
// string "chat.example:8443" as that unrelated legacy account, or this
|
||||
// login silently adopts (and later re-publishes) the other server's
|
||||
// identity private key.
|
||||
const otherServerLegacyKey = await generateIdentityKeyPair();
|
||||
const otherServerLegacyBlob = await exportIdentityKeyPair(otherServerLegacyKey.privateKey);
|
||||
const otherServerLegacyPub = await exportPublicKey(otherServerLegacyKey.publicKey);
|
||||
keyringDouble({ "chat.example:8443": otherServerLegacyBlob });
|
||||
|
||||
const kp = await getOrCreateIdentityKeyPair("chat.example", 8443);
|
||||
|
||||
expect(await exportPublicKey(kp.publicKey)).not.toBe(otherServerLegacyPub);
|
||||
});
|
||||
|
||||
it("reports a credential store that accepts the write but drops the value", async () => {
|
||||
invokeMock.mockImplementation((cmd: string) => {
|
||||
if (cmd === "load_identity_key") return Promise.resolve(null);
|
||||
@@ -446,7 +466,7 @@ describe("ensureIdentityKeyPublished (login/ready publish flow)", () => {
|
||||
// The legacy key must be untouched: no adopt-then-delete into a bogus
|
||||
// host:0 scope.
|
||||
expect(store.get("chat.example")).toBe(legacyBlob);
|
||||
expect(store.has("chat.example:0")).toBe(false);
|
||||
expect(store.has("0@chat.example")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -460,7 +480,7 @@ describe("legacy identity key migration (pre-B3-3 host-only account)", () => {
|
||||
const kp = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
|
||||
expect(await exportPublicKey(kp.publicKey)).toBe(legacyPub);
|
||||
expect(store.get("chat.example:1")).toBe(legacyBlob);
|
||||
expect(store.get("1@chat.example")).toBe(legacyBlob);
|
||||
// Deleted so it can never be adopted a second time.
|
||||
expect(store.has("chat.example")).toBe(false);
|
||||
});
|
||||
@@ -476,8 +496,8 @@ describe("legacy identity key migration (pre-B3-3 host-only account)", () => {
|
||||
|
||||
const second = await getOrCreateIdentityKeyPair("chat.example", 2);
|
||||
expect(await exportPublicKey(second.publicKey)).not.toBe(legacyPub);
|
||||
expect(store.get("chat.example:2")).toBeDefined();
|
||||
expect(store.get("chat.example:2")).not.toBe(legacyBlob);
|
||||
expect(store.get("2@chat.example")).toBeDefined();
|
||||
expect(store.get("2@chat.example")).not.toBe(legacyBlob);
|
||||
});
|
||||
|
||||
it("falls back to fresh generation, without throwing, when the legacy blob is corrupt", async () => {
|
||||
@@ -486,8 +506,8 @@ describe("legacy identity key migration (pre-B3-3 host-only account)", () => {
|
||||
const kp = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
|
||||
expect(kp.publicKey).toBeDefined();
|
||||
expect(store.get("chat.example:1")).toBeDefined();
|
||||
expect(store.get("chat.example:1")).not.toBe("!!not-valid-jwk!!");
|
||||
expect(store.get("1@chat.example")).toBeDefined();
|
||||
expect(store.get("1@chat.example")).not.toBe("!!not-valid-jwk!!");
|
||||
});
|
||||
|
||||
it("generates fresh, with no delete attempt, when there is no legacy key either (first login)", async () => {
|
||||
@@ -512,6 +532,6 @@ describe("legacy identity key migration (pre-B3-3 host-only account)", () => {
|
||||
await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
|
||||
expect(store.get("chat.example")).toBe(legacyBlob);
|
||||
expect(store.has("chat.example:1")).toBe(false);
|
||||
expect(store.has("1@chat.example")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
generateECDHKeyPair,
|
||||
generateRoomKey,
|
||||
importPublicKey,
|
||||
exportPublicKey,
|
||||
} from "@lib/e2eeCrypto";
|
||||
import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
@@ -981,6 +982,125 @@ describe("E2EEManager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Ledger findings OC-0010 / OC-0011 ─────────────────────────────────
|
||||
|
||||
it("[OC-0010] does not stand down a new session's key-holder role when a stale offer's setKey resolves after teardown+rejoin", async () => {
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
|
||||
// Session A: we are the holder in channel 1, with PEER_ID's key on file.
|
||||
await mgr.setupKeyExchange(true, 1);
|
||||
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
||||
|
||||
// An offer from PEER_ID arrives and stalls at the keyProvider.setKey
|
||||
// await — AFTER the epoch/keypair guard (checked right after unwrap) has
|
||||
// already passed.
|
||||
let releaseSetKey!: () => void;
|
||||
const stalledSetKey = new Promise<void>((resolve) => {
|
||||
releaseSetKey = resolve;
|
||||
});
|
||||
mockSetKey.mockClear();
|
||||
mockSetKey.mockImplementationOnce(() => stalledSetKey);
|
||||
const offerPromise = mgr.handleOffer(PEER_ID, "enc", "iv");
|
||||
await vi.waitFor(() => expect(mockSetKey).toHaveBeenCalled());
|
||||
|
||||
// Mid-flight: the user leaves channel 1 and rejoins channel 2 as the new
|
||||
// key holder — a distinct keypair, exactly as real ECDH keygen produces.
|
||||
mgr.clearState();
|
||||
vi.mocked(generateECDHKeyPair).mockResolvedValueOnce({
|
||||
publicKey: { type: "chan2-pub" } as unknown as CryptoKey,
|
||||
privateKey: { type: "chan2-priv" } as unknown as CryptoKey,
|
||||
});
|
||||
await mgr.setupKeyExchange(true, 2);
|
||||
expect((mgr as unknown as { _isKeyHolder: boolean })._isKeyHolder).toBe(true);
|
||||
|
||||
// The stale (session-1) offer's setKey now resolves.
|
||||
releaseSetKey();
|
||||
await offerPromise;
|
||||
|
||||
// Channel 2's holder role must survive — the stale continuation must not
|
||||
// stand it down (it re-checks staleness before the setKey await, not
|
||||
// after — the write happens on the far side of that await).
|
||||
expect((mgr as unknown as { _isKeyHolder: boolean })._isKeyHolder).toBe(true);
|
||||
});
|
||||
|
||||
it("[OC-0010] does not write a stale peer key into a new session's map when clearState()+rejoin lands during the announce's key-import await", async () => {
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
|
||||
// Session A: holder in channel 1.
|
||||
await mgr.setupKeyExchange(true, 1);
|
||||
|
||||
// The announce's importPublicKey stalls — the "final await" before the
|
||||
// _peerPublicKeys.set write, which today has no re-check after it.
|
||||
let releaseImport!: (v: CryptoKey) => void;
|
||||
const stalledImport = new Promise<CryptoKey>((resolve) => {
|
||||
releaseImport = resolve;
|
||||
});
|
||||
vi.mocked(importPublicKey).mockReturnValueOnce(stalledImport);
|
||||
|
||||
const announcePromise = mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
||||
await vi.waitFor(() => expect(importPublicKey).toHaveBeenCalled());
|
||||
|
||||
// Mid-flight: the user leaves channel 1 and rejoins channel 2.
|
||||
mgr.clearState();
|
||||
await mgr.setupKeyExchange(true, 2);
|
||||
|
||||
// The stale announce's import now resolves.
|
||||
releaseImport({ type: "stale-peer-key" } as unknown as CryptoKey);
|
||||
await announcePromise;
|
||||
|
||||
// The new session's peer map must not be polluted by the torn-down
|
||||
// session's announce.
|
||||
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it("[OC-0011] rejects a replayed announce carrying a previously-retired peer key instead of overwriting the live key", async () => {
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
await mgr.setupKeyExchange(true, 1); // establishes our keypair
|
||||
|
||||
// Make import/export round-trip faithfully on the announced base64
|
||||
// string (the shared mock default returns a fixed constant from
|
||||
// exportPublicKey regardless of input, which would mask this bug).
|
||||
vi.mocked(importPublicKey).mockImplementation(
|
||||
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
|
||||
);
|
||||
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
|
||||
(key as unknown as { type: string }).type.replace("peer-key-", ""),
|
||||
);
|
||||
|
||||
// Valid base64 (must decode cleanly — rawFromBase64 uses atob() to build
|
||||
// the signed message bytes). "b2xk"/"bmV3" already prove out elsewhere in
|
||||
// this suite as distinct valid ephemeral-key payloads.
|
||||
const KEY_A = "b2xk";
|
||||
const KEY_B = "bmV3";
|
||||
|
||||
try {
|
||||
// Peer announces key A — accepted as their first (live) key.
|
||||
await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA");
|
||||
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_A}` });
|
||||
|
||||
// Peer reconnects and announces a genuinely new key B — a legitimate
|
||||
// change, so key A is now retired.
|
||||
await mgr.handleAnnounce(PEER_ID, KEY_B, "sigB");
|
||||
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` });
|
||||
|
||||
// A malicious relay re-emits the OLD, still validly-signed announce for
|
||||
// key A. No channel/epoch/nonce binds the signed message, so it
|
||||
// verifies cleanly — it must still be rejected as a replay, not
|
||||
// overwrite the live key B.
|
||||
await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA");
|
||||
|
||||
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` });
|
||||
} finally {
|
||||
vi.mocked(importPublicKey).mockImplementation(
|
||||
async () => ({ type: "public" }) as unknown as CryptoKey,
|
||||
);
|
||||
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
|
||||
}
|
||||
});
|
||||
|
||||
it("[OC-0007] confirms the room key after a reconnect re-announce instead of declaring it fresh unconditionally", async () => {
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Regression test for OC-0116: a rejected TOTP verify tore down the TOTP
|
||||
// overlay (transitionTo("error", ...) leaves formState "totp", and
|
||||
// updateTotpOverlay hides the overlay for every non-"totp" state), so the
|
||||
// user was dropped back on the login form with no way to re-enter the code.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createConnectPage } from "../../src/pages/ConnectPage";
|
||||
import type { ConnectPageCallbacks, SimpleProfile } from "../../src/pages/ConnectPage";
|
||||
|
||||
vi.mock("../../src/lib/credentials", () => ({
|
||||
loadCredential: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/SettingsOverlay", () => ({
|
||||
createSettingsOverlay: () => ({
|
||||
mount: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeCallbacks(overrides: Partial<ConnectPageCallbacks> = {}): ConnectPageCallbacks {
|
||||
return {
|
||||
onLogin: vi.fn().mockResolvedValue(undefined),
|
||||
onRegister: vi.fn().mockResolvedValue(undefined),
|
||||
onTotpSubmit: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const testProfiles: SimpleProfile[] = [{ name: "Test Server", host: "localhost:8443" }];
|
||||
|
||||
describe("LoginForm TOTP retry after a rejected verify", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("keeps the TOTP overlay open and lets a second code be submitted", async () => {
|
||||
const onTotpSubmit = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("Invalid verification code"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const page = createConnectPage(makeCallbacks({ onTotpSubmit }), testProfiles);
|
||||
page.mount(container);
|
||||
page.showTotp();
|
||||
|
||||
const totpOverlay = container.querySelector(".totp-overlay") as HTMLDivElement;
|
||||
const totpInput = container.querySelector(".totp-overlay input") as HTMLInputElement;
|
||||
const verifyBtn = container.querySelector(".totp-overlay .btn-primary") as HTMLButtonElement;
|
||||
|
||||
totpInput.value = "111111";
|
||||
verifyBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(onTotpSubmit).toHaveBeenCalledTimes(1));
|
||||
// Verify button re-enables once the rejected promise settles.
|
||||
await vi.waitFor(() => expect(verifyBtn.disabled).toBe(false));
|
||||
|
||||
// The overlay must stay up so the code can be re-entered, instead of
|
||||
// being hidden because formState moved to "error".
|
||||
expect(totpOverlay.classList.contains("totp-overlay--hidden")).toBe(false);
|
||||
|
||||
// A retry with a fresh code must actually reach onTotpSubmit again.
|
||||
totpInput.value = "222222";
|
||||
verifyBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(onTotpSubmit).toHaveBeenCalledTimes(2));
|
||||
expect(onTotpSubmit).toHaveBeenNthCalledWith(2, "222222");
|
||||
|
||||
page.destroy?.();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 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 { 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.
|
||||
const mockLogin = vi.fn();
|
||||
vi.mock("@lib/api", () => ({
|
||||
createApiClient: vi.fn(() => ({
|
||||
setConfig: vi.fn(),
|
||||
getConfig: vi.fn(() => ({ 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(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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 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 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)
|
||||
});
|
||||
});
|
||||
@@ -890,6 +890,48 @@ describe("createQuickSwitcherManager", () => {
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("opens on Ctrl+K with CapsLock on (KeyboardEvent.key reports uppercase 'K')", () => {
|
||||
// OC-0150: `e.key` reflects CapsLock/Shift state. A case-sensitive `=== "k"`
|
||||
// check means CapsLock (or Ctrl+Shift+K) silently does nothing.
|
||||
const manager = createQuickSwitcherManager(() => root);
|
||||
const cleanup = manager.attach();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "K", ctrlKey: true }));
|
||||
|
||||
expect(createQuickSwitcher).toHaveBeenCalledOnce();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("does not open on AltGr+K (Windows reports AltGr as ctrlKey+altKey)", () => {
|
||||
// OC-0150: without an altKey exclusion, AltGr-produced characters on
|
||||
// non-US layouts get swallowed by an unwanted preventDefault().
|
||||
const manager = createQuickSwitcherManager(() => root);
|
||||
const cleanup = manager.attach();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true, altKey: true }));
|
||||
|
||||
expect(createQuickSwitcher).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("does not open while isSuspended() reports true (e.g. settings overlay open)", () => {
|
||||
// OC-0150: every other global shortcut honours isSuspended; the quick
|
||||
// switcher never got the guard, so it could stack on top of Settings.
|
||||
const manager = createQuickSwitcherManager(
|
||||
() => root,
|
||||
() => true,
|
||||
);
|
||||
const cleanup = manager.attach();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
|
||||
|
||||
expect(createQuickSwitcher).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -204,6 +204,28 @@ describe("QuickSwitcher", () => {
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("Ctrl+K closes the switcher with CapsLock on (KeyboardEvent.key reports uppercase 'K')", () => {
|
||||
// OC-0150: the global close handler compares `e.key === "k"`
|
||||
// case-sensitively, so CapsLock (or Ctrl+Shift+K) leaves the switcher
|
||||
// stuck open.
|
||||
switcher.mount(container);
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "K", ctrlKey: true, bubbles: true }),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("AltGr+K (ctrlKey+altKey) does not close the switcher", () => {
|
||||
// OC-0150: Windows/WebView2 reports AltGr as ctrlKey+altKey, so without
|
||||
// an altKey exclusion an AltGr-produced 'k' character both fails to
|
||||
// reach the composer and closes the switcher underneath it.
|
||||
switcher.mount(container);
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "k", ctrlKey: true, altKey: true, bubbles: true }),
|
||||
);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Enter is a no-op when search returns no results", () => {
|
||||
switcher.mount(container);
|
||||
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
|
||||
|
||||
@@ -1514,6 +1514,29 @@ describe("renderers", () => {
|
||||
expect(container.textContent).toContain("before");
|
||||
expect(container.textContent).toContain("after");
|
||||
});
|
||||
|
||||
it("keeps a balanced trailing paren that is part of the URL", () => {
|
||||
const url = "https://en.wikipedia.org/wiki/Rust_(programming_language)";
|
||||
const fragment = renderMentions(url);
|
||||
container.appendChild(fragment);
|
||||
|
||||
const link = container.querySelector("a.msg-link") as HTMLAnchorElement;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.getAttribute("href")).toBe(url);
|
||||
expect(link.textContent).toBe(url);
|
||||
// No stray ")" left dangling as separate trailing text
|
||||
expect(container.textContent).toBe(url);
|
||||
});
|
||||
|
||||
it("still strips a genuinely unbalanced trailing paren used as sentence punctuation", () => {
|
||||
const fragment = renderMentions("(see https://example.com/page)");
|
||||
container.appendChild(fragment);
|
||||
|
||||
const link = container.querySelector("a.msg-link") as HTMLAnchorElement;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.getAttribute("href")).toBe("https://example.com/page");
|
||||
expect(container.textContent).toBe("(see https://example.com/page)");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -493,6 +493,39 @@ describe("SidebarArea", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Collapsed category persistence (OC-0085)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("collapsed category persistence", () => {
|
||||
afterEach(() => {
|
||||
localStorage.removeItem("owncord:collapsed:server-a.example.com");
|
||||
localStorage.removeItem("owncord:collapsed:OwnCord Server");
|
||||
});
|
||||
|
||||
it("scopes collapsed categories to the connected host, not the server display name", () => {
|
||||
// Two servers left at the operator default name collide on one
|
||||
// localStorage entry if persistence is keyed by display name instead
|
||||
// of host — same reason setChannelMutesHost/setNsfwGateHost/
|
||||
// setAudioVolumeHost are all host-scoped.
|
||||
authStore.setState((prev) => ({ ...prev, serverName: "OwnCord Server" }));
|
||||
localStorage.setItem("owncord:collapsed:server-a.example.com", JSON.stringify(["General"]));
|
||||
localStorage.setItem("owncord:collapsed:OwnCord Server", JSON.stringify(["Text Channels"]));
|
||||
|
||||
const opts = defaultOpts();
|
||||
(opts.api as unknown as { getConfig: () => { host: string } }).getConfig = () => ({
|
||||
host: "server-a.example.com",
|
||||
});
|
||||
|
||||
const result = createSidebarArea(opts);
|
||||
|
||||
expect(uiStore.getState().collapsedCategories.has("General")).toBe(true);
|
||||
expect(uiStore.getState().collapsedCategories.has("Text Channels")).toBe(false);
|
||||
|
||||
cleanup(result);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Channels mode
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -1435,6 +1468,65 @@ describe("SidebarArea", () => {
|
||||
cleanup(result);
|
||||
});
|
||||
|
||||
it("onBack keeps the current channel when DM mode was entered without recording channelBeforeDm (OC-0094: 'View all messages' bypass)", () => {
|
||||
channelsStore.setState((prev) => {
|
||||
const next = new Map(prev.channels);
|
||||
next.set(1, {
|
||||
id: 1,
|
||||
name: "general",
|
||||
type: "text",
|
||||
category: null,
|
||||
position: 0,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
lastMessageId: null,
|
||||
canSend: true,
|
||||
topic: "",
|
||||
slowMode: 0,
|
||||
nsfw: false,
|
||||
voiceMaxUsers: 0,
|
||||
voiceMaxVideo: 0,
|
||||
});
|
||||
next.set(2, {
|
||||
id: 2,
|
||||
name: "random",
|
||||
type: "text",
|
||||
category: null,
|
||||
position: 0,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
lastMessageId: null,
|
||||
canSend: true,
|
||||
topic: "",
|
||||
slowMode: 0,
|
||||
nsfw: false,
|
||||
voiceMaxUsers: 0,
|
||||
voiceMaxVideo: 0,
|
||||
});
|
||||
// #random (2) is on screen, and is not first in Map insertion order.
|
||||
return { ...prev, channels: next, activeChannelId: 2 };
|
||||
});
|
||||
|
||||
// Enter DM mode the way SidebarDmSection's "View all messages" button
|
||||
// does: a bare setSidebarMode with no selectDmConversation call, so
|
||||
// channelBeforeDm is never recorded.
|
||||
uiStore.setState((prev) => ({ ...prev, sidebarMode: "dms" }));
|
||||
|
||||
const result = createSidebarArea(defaultOpts());
|
||||
container.appendChild(result.sidebarWrapper);
|
||||
|
||||
const dmSidebarCalls = (createDmSidebar as MockedFn).mock.calls;
|
||||
const lastCall = dmSidebarCalls[dmSidebarCalls.length - 1]![0];
|
||||
lastCall.onBack();
|
||||
|
||||
expect(uiStore.getState().sidebarMode).toBe("channels");
|
||||
// Must not silently jump to #general (1), the first text channel in
|
||||
// Map iteration order — the user never asked to leave #random.
|
||||
expect(channelsStore.getState().activeChannelId).toBe(2);
|
||||
|
||||
cleanup(result);
|
||||
});
|
||||
|
||||
it("onCloseDm removes DM and calls closeDm API", () => {
|
||||
const dm = makeDm({
|
||||
channelId: 100,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Regression guard for WebView2's default `--disable-features` flag.
|
||||
//
|
||||
// Tauri/wry pass `--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection`
|
||||
// to the WebView2 browser process by default, but setting `additionalBrowserArgs`
|
||||
// REPLACES that default string rather than appending to it (see
|
||||
// WindowConfig::additional_browser_args / WebViewBuilder::with_additional_browser_args).
|
||||
// Our config sets additionalBrowserArgs for autoplay/fake-media-stream, which
|
||||
// silently re-enables SmartScreen (URL-reputation lookups against Microsoft for
|
||||
// in-webview navigations/downloads — a leak for a self-hosted, TOFU-pinned
|
||||
// client) and the msWebOOUI/msPdfOOUI overlays. The dropped default must be
|
||||
// re-added explicitly.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri.conf.json — Windows WebView2 additionalBrowserArgs", () => {
|
||||
it("keeps wry's default --disable-features flag alongside the custom args", () => {
|
||||
const win = tauriConf.app.windows[0] as { additionalBrowserArgs?: string };
|
||||
expect(win.additionalBrowserArgs).toBeDefined();
|
||||
const args = win.additionalBrowserArgs ?? "";
|
||||
expect(args).toContain("--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection");
|
||||
});
|
||||
|
||||
it("still passes the autoplay and fake-media-stream flags this client needs", () => {
|
||||
const win = tauriConf.app.windows[0] as { additionalBrowserArgs?: string };
|
||||
const args = win.additionalBrowserArgs ?? "";
|
||||
expect(args).toContain("--autoplay-policy=no-user-gesture-required");
|
||||
expect(args).toContain("--use-fake-ui-for-media-stream");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// jsdom never applies app.css, so a computed-style assertion against the
|
||||
// rendered tile would pass whether or not the rule exists (see
|
||||
// appearance-high-contrast.test.ts / status-picker-userbar.test.ts for the
|
||||
// same pattern). This pins the CSS *source* instead.
|
||||
//
|
||||
// VideoGrid.ts's onTrackMute toggles `.track-muted` on the `.video-cell` to
|
||||
// hide a stalled remote camera's last frame. If app.css has no rule for that
|
||||
// class, the toggle is a no-op and the viewer keeps seeing a frozen frame
|
||||
// with no indication the track stalled.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("VideoGrid track-muted CSS", () => {
|
||||
it("app.css hides the video element while .video-cell.track-muted is active", () => {
|
||||
const css = readFileSync(join(process.cwd(), "src/styles/app.css"), "utf8");
|
||||
|
||||
// Look for a rule targeting the video (or the cell itself) scoped under
|
||||
// .video-cell.track-muted -- accept either ordering / whitespace.
|
||||
const match = /\.video-cell\.track-muted[^{]*\{([^}]*)\}/.exec(css);
|
||||
expect(
|
||||
match,
|
||||
"expected a `.video-cell.track-muted { ... }` (or descendant `video`) rule in app.css " +
|
||||
"so the mute handler's class toggle actually hides the stalled frame",
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user