computePermissions() returned the space @everyone role's permissions without verifying the caller had joined the space. Because CREATE_INVITE is in DEFAULT_EVERYONE_PERMISSIONS, any authenticated user could mint an invite code for a request-only space — whose id is listed by /api/spaces/explore — and then self-join via /api/spaces/:id/join, bypassing the join-request approval flow. The same gap let non-members read message history and search default channels. Root cause: - computePermissions now returns 0n for non-members (space owner and instance admin still short-circuit first, so they are unaffected). Defense in depth (request-only spaces are approval-gated, never invite-joinable): - both invite-code join endpoints reject visibility='request' (private stays invite-joinable — its only entry path; public too). - POST /api/spaces/:id/invite refuses to hand out a code for request spaces. - POST /api/dm/space-invite refuses to card a local request space, checked by space id against the local table so a spoofed spaceInstanceOrigin can't slip past it. - InviteModal hides the invite affordances for request spaces. Also removes the unused computeCategoryPermissions(), which duplicated the resolution algorithm without the membership gate. Adds unit + route + component tests covering non-member/member/owner/admin resolution and the request/private/public visibility matrix. Reported-by: BadAtCaptchas (#2)
490 lines
14 KiB
TypeScript
490 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom.
|
|
// Reached transitively via spaceStore → chatStore → useWebSocket → voiceStore.
|
|
vi.mock('../../audio/AudioManager', () => ({
|
|
AudioManager: {
|
|
getInstance: vi.fn().mockReturnValue({
|
|
setOutputDevice: vi.fn(),
|
|
setVolume: vi.fn(),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
// Mock the API client so we can assert spaceInvite calls.
|
|
const mockSpaceInvite = vi.fn();
|
|
vi.mock('../../api/client', () => ({
|
|
api: {
|
|
dm: {
|
|
spaceInvite: (...args: unknown[]) => mockSpaceInvite(...args),
|
|
},
|
|
},
|
|
}));
|
|
|
|
import { InviteModal } from './InviteModal';
|
|
import { useUIStore } from '../../stores/uiStore';
|
|
import { useSpaceStore } from '../../stores/spaceStore';
|
|
import { useSocialStore } from '../../stores/socialStore';
|
|
import { useAuthStore } from '../../stores/authStore';
|
|
|
|
// Helpers — minimal fixtures for store state.
|
|
function makeFriend(overrides: Partial<any> = {}) {
|
|
return {
|
|
id: 'friend-1',
|
|
username: 'alex',
|
|
displayName: 'Alex',
|
|
avatar: null,
|
|
banner: null,
|
|
accentColor: null,
|
|
avatarColor: null,
|
|
bio: null,
|
|
status: 'online' as const,
|
|
customStatus: null,
|
|
createdAt: 0,
|
|
addedAt: 0,
|
|
homeUserId: null,
|
|
homeInstance: null,
|
|
_instanceOrigin: '',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeMember(overrides: Partial<any> = {}) {
|
|
return {
|
|
spaceId: 'space-1',
|
|
userId: 'member-1',
|
|
nickname: null,
|
|
joinedAt: 0,
|
|
roles: [],
|
|
user: {
|
|
id: 'member-1',
|
|
username: 'memberA',
|
|
displayName: null,
|
|
avatar: null,
|
|
banner: null,
|
|
accentColor: null,
|
|
avatarColor: null,
|
|
bio: null,
|
|
status: 'offline',
|
|
customStatus: null,
|
|
isAdmin: false,
|
|
createdAt: 0,
|
|
homeInstance: null,
|
|
homeUserId: null,
|
|
replicatedInstances: [],
|
|
...(overrides.user ?? {}),
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeSpace(overrides: Partial<any> = {}) {
|
|
return {
|
|
id: 'space-1',
|
|
name: 'Test Space',
|
|
icon: null,
|
|
avatarColor: null,
|
|
description: null,
|
|
ownerId: 'owner-1',
|
|
public: false,
|
|
discoverable: false,
|
|
joinPolicy: 'invite',
|
|
createdAt: 0,
|
|
_instanceOrigin: '',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function setUpStore({
|
|
friends = [],
|
|
members = [],
|
|
myUser = { id: 'me', username: 'me' } as any,
|
|
generateInvite = vi.fn().mockResolvedValue('test-code'),
|
|
}: {
|
|
friends?: any[];
|
|
members?: any[];
|
|
myUser?: any;
|
|
generateInvite?: ReturnType<typeof vi.fn>;
|
|
} = {}) {
|
|
useUIStore.setState({ activeModal: 'invite', modalData: {} });
|
|
useSpaceStore.setState({
|
|
currentSpaceId: 'space-1',
|
|
spaces: [makeSpace()] as any,
|
|
members,
|
|
generateInvite,
|
|
} as any);
|
|
useSocialStore.setState({ friends } as any);
|
|
useAuthStore.setState({ user: myUser } as any);
|
|
return { generateInvite };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockSpaceInvite.mockReset();
|
|
|
|
useUIStore.setState({ activeModal: null, modalData: {} });
|
|
useSpaceStore.setState({
|
|
currentSpaceId: null,
|
|
spaces: [],
|
|
members: [],
|
|
} as any);
|
|
useSocialStore.setState({ friends: [] } as any);
|
|
useAuthStore.setState({ user: null } as any);
|
|
|
|
// Mock clipboard.
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
describe('InviteModal', () => {
|
|
it('does not render when activeModal is not "invite"', () => {
|
|
render(<InviteModal />);
|
|
expect(screen.queryByText('Invite Friends')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders friend list, excluding self', async () => {
|
|
setUpStore({
|
|
friends: [
|
|
makeFriend({ id: 'me', username: 'me', displayName: 'Me' }),
|
|
makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }),
|
|
makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }),
|
|
],
|
|
myUser: { id: 'me', username: 'me' },
|
|
});
|
|
|
|
render(<InviteModal />);
|
|
|
|
expect(screen.getByText('Invite Friends')).toBeInTheDocument();
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Alex')).toBeInTheDocument();
|
|
});
|
|
expect(screen.getByText('Sam')).toBeInTheDocument();
|
|
// Self is filtered out.
|
|
expect(screen.queryByText('Me')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('grays out friends already in the space using federated identity', async () => {
|
|
// Friend on remote instance orbit.ddns.net, member entry replicated
|
|
// locally with user.homeUserId+homeInstance matching that identity.
|
|
setUpStore({
|
|
friends: [
|
|
makeFriend({
|
|
id: 'local-shadow-id',
|
|
username: 'remoteFriend',
|
|
displayName: 'Remote Friend',
|
|
homeUserId: 'remote-uid-7',
|
|
homeInstance: 'https://orbit.ddns.net',
|
|
}),
|
|
],
|
|
members: [
|
|
makeMember({
|
|
userId: 'local-replicated-id',
|
|
user: {
|
|
id: 'local-replicated-id',
|
|
username: 'remoteFriend',
|
|
homeUserId: 'remote-uid-7',
|
|
homeInstance: 'https://orbit.ddns.net',
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
|
|
render(<InviteModal />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Already in space')).toBeInTheDocument();
|
|
});
|
|
|
|
// Clicking the disabled row does not select the friend.
|
|
const button = screen.getByRole('button', { name: /Remote Friend/ });
|
|
expect(button).toBeDisabled();
|
|
});
|
|
|
|
it('selecting friends and submitting calls api.dm.spaceInvite once per friend', async () => {
|
|
const user = userEvent.setup();
|
|
mockSpaceInvite.mockResolvedValue({});
|
|
|
|
setUpStore({
|
|
friends: [
|
|
makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }),
|
|
makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }),
|
|
],
|
|
});
|
|
|
|
render(<InviteModal />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Alex')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByRole('button', { name: /Alex/ }));
|
|
await user.click(screen.getByRole('button', { name: /Sam/ }));
|
|
|
|
const submit = screen.getByRole('button', { name: /Send 2 Invites/ });
|
|
await user.click(submit);
|
|
|
|
await waitFor(() => {
|
|
expect(mockSpaceInvite).toHaveBeenCalledTimes(2);
|
|
});
|
|
expect(mockSpaceInvite).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
spaceId: 'space-1',
|
|
inviteCode: 'test-code',
|
|
target: { userId: 'f1' },
|
|
}),
|
|
);
|
|
expect(mockSpaceInvite).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
spaceId: 'space-1',
|
|
inviteCode: 'test-code',
|
|
target: { userId: 'f2' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('closes the modal after a fully successful send', async () => {
|
|
const user = userEvent.setup();
|
|
mockSpaceInvite.mockResolvedValue({});
|
|
|
|
setUpStore({
|
|
friends: [makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' })],
|
|
});
|
|
|
|
render(<InviteModal />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Alex')).toBeInTheDocument();
|
|
});
|
|
await user.click(screen.getByRole('button', { name: /Alex/ }));
|
|
await user.click(screen.getByRole('button', { name: /Send 1 Invite/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(useUIStore.getState().activeModal).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('shows the results view with reason text on partial failure', async () => {
|
|
const user = userEvent.setup();
|
|
// f1 succeeds, f2 fails with `not_a_friend`.
|
|
mockSpaceInvite.mockImplementation(({ target }: any) => {
|
|
if (target.userId === 'f1') return Promise.resolve({});
|
|
return Promise.reject(new Error('not_a_friend'));
|
|
});
|
|
|
|
setUpStore({
|
|
friends: [
|
|
makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }),
|
|
makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }),
|
|
],
|
|
});
|
|
|
|
render(<InviteModal />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Alex')).toBeInTheDocument();
|
|
});
|
|
await user.click(screen.getByRole('button', { name: /Alex/ }));
|
|
await user.click(screen.getByRole('button', { name: /Sam/ }));
|
|
await user.click(screen.getByRole('button', { name: /Send 2 Invites/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('✓ Sent')).toBeInTheDocument();
|
|
});
|
|
expect(screen.getByText('✗ Not a friend')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Retry failed' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument();
|
|
// Modal must remain open while results are visible.
|
|
expect(useUIStore.getState().activeModal).toBe('invite');
|
|
});
|
|
|
|
it('Retry failed re-runs only the failed subset', async () => {
|
|
const user = userEvent.setup();
|
|
mockSpaceInvite.mockImplementation(({ target }: any) => {
|
|
if (target.userId === 'f1') return Promise.resolve({});
|
|
return Promise.reject(new Error('upstream'));
|
|
});
|
|
|
|
setUpStore({
|
|
friends: [
|
|
makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }),
|
|
makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }),
|
|
],
|
|
});
|
|
|
|
render(<InviteModal />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Alex')).toBeInTheDocument();
|
|
});
|
|
await user.click(screen.getByRole('button', { name: /Alex/ }));
|
|
await user.click(screen.getByRole('button', { name: /Sam/ }));
|
|
await user.click(screen.getByRole('button', { name: /Send 2 Invites/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: 'Retry failed' })).toBeInTheDocument();
|
|
});
|
|
expect(mockSpaceInvite).toHaveBeenCalledTimes(2);
|
|
|
|
// Second pass: Sam succeeds.
|
|
mockSpaceInvite.mockReset();
|
|
mockSpaceInvite.mockResolvedValue({});
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Retry failed' }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockSpaceInvite).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(mockSpaceInvite).toHaveBeenCalledWith(
|
|
expect.objectContaining({ target: { userId: 'f2' } }),
|
|
);
|
|
});
|
|
|
|
it('share-link footer shows the invite URL and Copy writes to clipboard', async () => {
|
|
setUpStore({
|
|
friends: [],
|
|
generateInvite: vi.fn().mockResolvedValue('abc123'),
|
|
});
|
|
|
|
render(<InviteModal />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByDisplayValue(/\/join\/abc123/)).toBeInTheDocument();
|
|
});
|
|
|
|
// Re-install clipboard spy after render. userEvent.setup() may have replaced
|
|
// navigator.clipboard during initialization; we need a fresh spy that
|
|
// matches what the component will call directly.
|
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
value: { writeText },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
const copyBtn = screen.getByRole('button', { name: 'Copy' });
|
|
copyBtn.click();
|
|
|
|
await waitFor(() => {
|
|
expect(writeText).toHaveBeenCalledWith(
|
|
expect.stringContaining('/join/abc123'),
|
|
);
|
|
});
|
|
expect(await screen.findByText('Copied!')).toBeInTheDocument();
|
|
});
|
|
|
|
it('Copy button is disabled while the invite code is loading', () => {
|
|
setUpStore({
|
|
friends: [],
|
|
generateInvite: vi.fn().mockReturnValue(new Promise(() => {})),
|
|
});
|
|
|
|
render(<InviteModal />);
|
|
|
|
const copyButton = screen.getByRole('button', { name: 'Copy' });
|
|
expect(copyButton).toBeDisabled();
|
|
});
|
|
|
|
it('shows an error in the share-link footer when generateInvite fails', async () => {
|
|
setUpStore({
|
|
friends: [],
|
|
generateInvite: vi.fn().mockRejectedValue(new Error('Not authorized')),
|
|
});
|
|
|
|
render(<InviteModal />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Not authorized')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('search filters the friend list', async () => {
|
|
const user = userEvent.setup();
|
|
setUpStore({
|
|
friends: [
|
|
makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }),
|
|
makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }),
|
|
],
|
|
});
|
|
|
|
render(<InviteModal />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Alex')).toBeInTheDocument();
|
|
});
|
|
|
|
const search = screen.getByPlaceholderText('Search friends...');
|
|
await user.type(search, 'sam');
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('Alex')).not.toBeInTheDocument();
|
|
});
|
|
expect(screen.getByText('Sam')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows an approval-required notice and hides invite affordances for request-only spaces', async () => {
|
|
const generateInvite = vi.fn().mockResolvedValue('should-not-be-used');
|
|
useUIStore.setState({ activeModal: 'invite', modalData: {} });
|
|
useSpaceStore.setState({
|
|
currentSpaceId: 'space-1',
|
|
spaces: [makeSpace({ visibility: 'request' })] as any,
|
|
members: [],
|
|
generateInvite,
|
|
} as any);
|
|
useSocialStore.setState({
|
|
friends: [makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' })],
|
|
} as any);
|
|
useAuthStore.setState({ user: { id: 'me', username: 'me' } } as any);
|
|
|
|
render(<InviteModal />);
|
|
|
|
// Explanatory copy replaces the invite UI.
|
|
expect(screen.getByText(/join request/i)).toBeInTheDocument();
|
|
// None of the invite affordances render.
|
|
expect(screen.queryByPlaceholderText('Search friends...')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Or share a link')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Alex')).not.toBeInTheDocument();
|
|
// No invite code is requested for a request-only space (the endpoint 403s).
|
|
expect(generateInvite).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes federated target shape for remote friends', async () => {
|
|
const user = userEvent.setup();
|
|
mockSpaceInvite.mockResolvedValue({});
|
|
|
|
setUpStore({
|
|
friends: [
|
|
makeFriend({
|
|
id: 'local-shadow',
|
|
username: 'remoteAlex',
|
|
displayName: 'Remote Alex',
|
|
homeUserId: 'home-uid-9',
|
|
homeInstance: 'https://orbit.ddns.net',
|
|
}),
|
|
],
|
|
});
|
|
|
|
render(<InviteModal />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Remote Alex')).toBeInTheDocument();
|
|
});
|
|
await user.click(screen.getByRole('button', { name: /Remote Alex/ }));
|
|
await user.click(screen.getByRole('button', { name: /Send 1 Invite/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockSpaceInvite).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
target: {
|
|
homeUserId: 'home-uid-9',
|
|
homeInstance: 'https://orbit.ddns.net',
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|