Skip to content
Merged
106 changes: 106 additions & 0 deletions src/__testing__/UserSearchFieldInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,109 @@ describe('UserSearchField type-ahead wiring', () => {
expect(screen.queryByText('Jane Doe')).not.toBeNull();
});
});

describe('UserSearchField v1beta3 user records', () => {
// The v1beta3 user construct identifies users by `id` (the wire `userId` is
// a deprecated alias) and reduced projections may carry only a username.
const v1beta3Alex = {
id: 'u-alex',
username: 'alex',
firstName: 'Alex',
lastName: 'Rivera',
email: 'alex@example.com'
};
const usernameOnly = { id: 'u-min', username: 'minimal-user' };

it('renders and matches records that carry only the canonical id', async () => {
const { input } = renderField({
usersSearch: 'a',
searchedUsers: [v1beta3Alex, usernameOnly],
// same person as v1beta3Alex, but shaped like a legacy record
currentUserData: { userId: 'u-alex' }
});

fireEvent.change(input, { target: { value: 'a' } });

// current user is filtered out via id<->userId identity matching
await waitFor(() => {
expect(screen.queryByText('minimal-user')).not.toBeNull();
});
expect(screen.queryByText('Alex Rivera')).toBeNull();
});

it('renders soft-deleted v1beta3 records (deletedAt) as deleted options', async () => {
// v1beta3 signals soft deletion via `deletedAt`, not the component-local
// `deleted` boolean; both must render the "(deleted)" treatment.
const deletedByTimestamp = {
id: 'u-gone',
email: 'gone@example.com',
deletedAt: '2026-01-01T00:00:00Z'
};
const { input } = renderField({
usersSearch: 'gone',
searchedUsers: [deletedByTimestamp]
});

fireEvent.change(input, { target: { value: 'gone' } });

await waitFor(() => {
expect(screen.queryByText('gone@example.com (deleted)')).not.toBeNull();
});
});

it('removes only the targeted record when selected users carry no identifier', () => {
// Email-only invitees all collapse onto the '' identifier, so filtering
// by identifier string deleted every one of them at once. Deletion must
// match the record itself (isSameUser, then reference equality).
const inviteeOne = { email: 'one@example.com' };
const inviteeTwo = { email: 'two@example.com' };
const { props, container } = renderField({ usersData: [inviteeOne, inviteeTwo] });

fireEvent.click(screen.getByText('(+1)'));

const deleteIcons = container.querySelectorAll('.MuiChip-deleteIcon');
expect(deleteIcons).toHaveLength(2);
fireEvent.click(deleteIcons[0]);

expect(props.setUsersData).toHaveBeenCalledWith([inviteeTwo]);
});

it('removes records carrying neither identifier nor email by reference', () => {
// isSameUser never matches two records with nothing to compare; reference
// equality is the last resort that keeps such rows deletable.
const ghost = { username: 'ghost' };
const other = { username: 'other' };
const { props, container } = renderField({ usersData: [ghost, other] });

fireEvent.click(screen.getByText('(+1)'));

const deleteIcons = container.querySelectorAll('.MuiChip-deleteIcon');
expect(deleteIcons).toHaveLength(2);
fireEvent.click(deleteIcons[0]);

expect(props.setUsersData).toHaveBeenCalledWith([other]);
});

it('keeps MUI-generated option ids so aria-activedescendant resolves', async () => {
// renderOption must spread the Autocomplete-provided props untouched:
// overriding the option `id` with the user identifier orphans the
// input's aria-activedescendant reference and breaks keyboard focus.
const { input } = renderField({
usersSearch: 'a',
searchedUsers: [v1beta3Alex, usernameOnly]
});

fireEvent.change(input, { target: { value: 'a' } });
await waitFor(() => {
expect(screen.getAllByRole('option').length).toBeGreaterThan(0);
});

fireEvent.keyDown(input, { key: 'ArrowDown' });

const activeDescendant = input.getAttribute('aria-activedescendant');
expect(activeDescendant).toBeTruthy();
const highlighted = document.getElementById(activeDescendant as string);
expect(highlighted).not.toBeNull();
expect(highlighted?.getAttribute('role')).toBe('option');
});
});
119 changes: 119 additions & 0 deletions src/__testing__/UserShareSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import UserShareSearch from '../custom/UserSearchField/UserSearchField';
import { SistentThemeProvider } from '../theme';

// Regression guard for the Share Design people-picker against the v1beta3
// user construct. The searchable collaboration projection identifies users by
// `id` (the wire `userId` is a deprecated alias that newer providers omit)
// and may omit names/email on reduced projections. The picker must:
// - display first/last name and email for searchable-projection rows
// - fall back to username when a reduced projection omits names/email
// - key selection/identity on `id`, not on the retired `userId`

type SearchProps = React.ComponentProps<typeof UserShareSearch>;

const v1beta3Jane = {
id: 'b6e6d123-0000-4000-8000-000000000001',
username: 'jdoe',
firstName: 'Jane',
lastName: 'Doe',
email: 'jane@example.com'
};

const publicOnlyUser = {
id: 'b6e6d123-0000-4000-8000-000000000003',
username: 'publico'
};

const makeUseGetAllUsersQuery =
(users: unknown[]) => (_args: unknown, options?: { skip?: boolean }) => ({
data: options?.skip ? undefined : { data: users },
isLoading: false
});

const renderSearch = (overrides: Partial<SearchProps> = {}) => {
const props: SearchProps = {
usersData: [],
shareWithNewUsers: jest.fn().mockResolvedValue({ error: '' }),
useGetAllUsersQuery: makeUseGetAllUsersQuery([v1beta3Jane, publicOnlyUser]),
...overrides
};
const utils = render(
<SistentThemeProvider>
<UserShareSearch {...props} />
</SistentThemeProvider>
);
const input = screen.getByRole('combobox') as HTMLInputElement;
return { props, input, ...utils };
};

const typeSearch = async (input: HTMLInputElement, value: string) => {
fireEvent.change(input, { target: { value } });
// the query is debounced by 300ms
await waitFor(() => expect(screen.queryByText('jane@example.com')).not.toBeNull(), {
timeout: 2000
});
};

describe('UserShareSearch with v1beta3 user records', () => {
it('displays name and email for searchable-projection rows', async () => {
const { input } = renderSearch();

await typeSearch(input, 'jane');

expect(screen.queryByText('Jane Doe')).not.toBeNull();
expect(screen.queryByText('jane@example.com')).not.toBeNull();
});

it('falls back to username for rows from reduced projections', async () => {
const { input } = renderSearch();

await typeSearch(input, 'p');

expect(screen.queryByText('publico')).not.toBeNull();
});

it('selects and shares by canonical id when the wire userId is absent', async () => {
const { props, input } = renderSearch();

await typeSearch(input, 'jane');
fireEvent.click(screen.getByText('Jane Doe'));

fireEvent.click(screen.getByRole('button', { name: 'Share' }));

await waitFor(() =>
expect(props.shareWithNewUsers).toHaveBeenCalledWith([expect.objectContaining(v1beta3Jane)])
);
});

it('leaves the search field usable before any user is selected', () => {
// Only the Share button is gated on having a selection. Rendering the
// search field as disabled while it still accepts input tells the user
// the picker is unavailable when it is the only way to make a selection.
const { input } = renderSearch();

expect(input.disabled).toBe(false);
expect(input.closest('.MuiOutlinedInput-root')?.className).not.toContain('Mui-disabled');
expect((screen.getByRole('button', { name: 'Share' }) as HTMLButtonElement).disabled).toBe(true);
});

it('disables the search field when the consumer disables the picker', () => {
const { input } = renderSearch({ disabled: true });

expect(input.disabled).toBe(true);
});

it('does not resurface users that already have access', async () => {
// Existing access list arrives as canonical v1beta3 records (id, no
// userId); the suggestion list must recognize them as the same user.
const { input } = renderSearch({
usersData: [{ id: v1beta3Jane.id, email: v1beta3Jane.email }]
});

fireEvent.change(input, { target: { value: 'jane' } });

await waitFor(() => expect(screen.queryByText('publico')).not.toBeNull(), { timeout: 2000 });
expect(screen.queryByText('Jane Doe')).toBeNull();
});
});
55 changes: 55 additions & 0 deletions src/__testing__/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { canShareResourceWithNewUsers, canUpdateResource } from '../utils/permissions';

// The permission checks accept nullish users by contract: consumers call them
// during auth-state transitions (initial load, logout) when currentUser or the
// owner record is not yet available. No user means no permission.

const privateResource = { visibility: 'private' };
const publicResource = { visibility: 'public' };

describe('canUpdateResource', () => {
it('grants the resource owner, matching canonical id against the deprecated alias', () => {
expect(canUpdateResource(privateResource, { id: 'u-1' }, { userId: 'u-1' })).toBe(true);
});

it('grants admins even when they do not own the resource', () => {
expect(
canUpdateResource(privateResource, { id: 'u-2', roleNames: ['admin'] }, { id: 'u-1' })
).toBe(true);
});

it('denies non-owner non-admin users', () => {
expect(canUpdateResource(privateResource, { id: 'u-2' }, { id: 'u-1' })).toBe(false);
});

it('denies when currentUser is nullish (auth-state transitions)', () => {
expect(canUpdateResource(privateResource, null, { id: 'u-1' })).toBe(false);
expect(canUpdateResource(privateResource, undefined, { id: 'u-1' })).toBe(false);
});

it('never treats an identifier-less owner as matching an identifier-less user', () => {
expect(canUpdateResource(privateResource, {}, null)).toBe(false);
expect(canUpdateResource(privateResource, {}, {})).toBe(false);
});

it('denies when the resource is nullish, even for admins', () => {
expect(canUpdateResource(null, { id: 'u-1', roleNames: ['admin'] }, { id: 'u-1' })).toBe(false);
expect(canUpdateResource(undefined, { id: 'u-1' }, { id: 'u-1' })).toBe(false);
});
});

describe('canShareResourceWithNewUsers', () => {
it('always allows sharing public resources', () => {
expect(canShareResourceWithNewUsers(publicResource, null, null)).toBe(true);
});

it('falls back to the update permission for private resources', () => {
expect(canShareResourceWithNewUsers(privateResource, { id: 'u-1' }, { id: 'u-1' })).toBe(true);
expect(canShareResourceWithNewUsers(privateResource, null, { id: 'u-1' })).toBe(false);
});

it('denies when the resource is nullish', () => {
expect(canShareResourceWithNewUsers(null, { id: 'u-1' }, { id: 'u-1' })).toBe(false);
expect(canShareResourceWithNewUsers(undefined, { id: 'u-1' }, { id: 'u-1' })).toBe(false);
});
});
Loading
Loading