From 9aa97f45528ca3fa6f814378fe0102342353480d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:52:47 +0200 Subject: [PATCH] fix(web): joinByCode normalizes explicit home origin to local When a caller passes the user's own instance origin (e.g. from an invite snapshot's spaceInstanceOrigin), strip it to undefined before the remote branch so the local api path is taken instead of erroneously failing with NotConnectedError. Mirrors inviteParser's identical normalization. --- .../src/stores/spaceStore.joinByCode.test.ts | 92 +++++++++++++++++++ packages/web/src/stores/spaceStore.ts | 5 + 2 files changed, 97 insertions(+) create mode 100644 packages/web/src/stores/spaceStore.joinByCode.test.ts diff --git a/packages/web/src/stores/spaceStore.joinByCode.test.ts b/packages/web/src/stores/spaceStore.joinByCode.test.ts new file mode 100644 index 00000000..4ac00a43 --- /dev/null +++ b/packages/web/src/stores/spaceStore.joinByCode.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +// Stub instanceStore to avoid initialization ordering issues +vi.mock('./instanceStore', () => ({ + useInstanceStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ instances: [], _autoConnectDone: true }), + { + getState: () => ({ instances: [], _autoConnectDone: true }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +// Stub authStore to avoid localStorage access during module init +vi.mock('./authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { + getState: () => ({ user: null, token: null }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +// Stub the api client — use vi.fn() inline (hoisting constraint) +vi.mock('../api/client', () => ({ + api: { + spaces: { joinByCode: vi.fn() }, + }, + BackspaceApiClient: vi.fn(), +})); + +// Stub crossStoreResolvers — use vi.fn() inline +vi.mock('../utils/crossStoreResolvers', () => ({ + getApiForOrigin: vi.fn(), + resolveOriginFromHostname: vi.fn(), + resolveUserIdFromInstances: vi.fn(), + getCachedUserIdForOrigin: vi.fn(), + clearMyUserIdCache: vi.fn(), +})); + +// Import after mocks so we get the mocked versions +import { useSpaceStore } from './spaceStore'; +import { api } from '../api/client'; +import { getApiForOrigin } from '../utils/crossStoreResolvers'; + +const FAKE_SPACE = { + id: 'S1', + name: 'Aether', + icon: null, + banner: null, + _instanceOrigin: '', + description: null, + isPublic: false, + isDiscoverable: false, + ownerId: 'U1', + createdAt: 1000, + memberCount: 1, +}; + +beforeEach(() => { + vi.clearAllMocks(); + useSpaceStore.getState().reset(); + (api.spaces.joinByCode as ReturnType).mockResolvedValue(FAKE_SPACE); +}); + +describe('spaceStore.joinByCode — origin normalization', () => { + it('treats explicit home origin as local (does NOT call getApiForOrigin)', async () => { + const homeOrigin = window.location.origin; + await useSpaceStore.getState().joinByCode('abc', homeOrigin); + expect(api.spaces.joinByCode).toHaveBeenCalledTimes(1); + expect(getApiForOrigin).not.toHaveBeenCalled(); + }); + + it('treats undefined origin as local (existing behavior preserved)', async () => { + await useSpaceStore.getState().joinByCode('abc'); + expect(api.spaces.joinByCode).toHaveBeenCalledTimes(1); + expect(getApiForOrigin).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index df4f424b..6d953950 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -347,6 +347,11 @@ export const useSpaceStore = create((set, get) => ({ }, joinByCode: async (inviteCode: string, origin?: string) => { + // Normalize: an explicit home origin is equivalent to undefined (local). + // Mirrors inviteParser's same normalization at the URL boundary. + if (origin && typeof window !== 'undefined' && origin === window.location.origin) { + origin = undefined; + } if (origin) { // Remote instance — verify connectivity via dynamic import (avoids circular dep) const { useInstanceStore } = await import('./instanceStore');