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.
This commit is contained in:
Jannis Braun
2026-04-29 22:52:47 +02:00
parent 85987a891c
commit 9aa97f4552
2 changed files with 97 additions and 0 deletions
@@ -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<typeof vi.fn>).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();
});
});
+5
View File
@@ -347,6 +347,11 @@ export const useSpaceStore = create<SpaceState>((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');