Files
portainer/app/react/kubernetes/configs/secrets/ItemView/LinkedServiceAccountsRow.test.tsx

322 lines
11 KiB
TypeScript

import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { ReactNode } from 'react';
import { vi } from 'vitest';
import { server } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { useAuthorizations } from '@/react/hooks/useUser';
import { useGetAllServiceAccountsQuery } from '@/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useGetAllServiceAccountsQuery';
import { LinkedServiceAccountsRow } from './LinkedServiceAccountsRow';
vi.mock(
'@/react/hooks/useUser',
async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useAuthorizations: vi.fn(),
})
);
vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: () => 1,
}));
vi.mock(
'@/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useGetAllServiceAccountsQuery'
);
vi.mock(
'@/react/kubernetes/configs/secrets/queries/useSecretsLinkedToDefaultSA',
() => ({
useSecretsLinkedToDefaultSA: () => ({ data: [], isLoading: false }),
})
);
vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: vi.fn(),
notifyError: vi.fn(),
}));
vi.mock('@@/Link', () => ({
Link: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock('@@/Tip/Tooltip', () => ({ Tooltip: () => null }));
vi.mock('@@/Tip/TooltipWithChildren', () => ({
TooltipWithChildren: ({ children }: { children: ReactNode }) => (
<>{children}</>
),
}));
// Captures the latest onChange so tests can programmatically drive selection
let latestOnChange: ((v: string[]) => void) | undefined;
vi.mock('@@/form-components/PortainerSelect', () => ({
MultiSelect: ({ onChange }: { onChange: (v: string[]) => void }) => {
latestOnChange = onChange;
return <div data-testid="multi-select" />;
},
}));
const LinkedServiceAccountsRowWithQuery = withTestQueryProvider(
LinkedServiceAccountsRow
);
type RowProps = React.ComponentProps<typeof LinkedServiceAccountsRow>;
function renderRow(props: Partial<RowProps> = {}) {
return render(
<LinkedServiceAccountsRowWithQuery
secretName="my-secret"
namespace="default"
isSystem={false}
{...props}
/>
);
}
describe('LinkedServiceAccountsRow', () => {
beforeEach(() => {
latestOnChange = undefined;
vi.mocked(useAuthorizations).mockImplementation(() => ({
authorized: false,
isLoading: false,
}));
vi.mocked(useGetAllServiceAccountsQuery).mockReturnValue({
data: [],
isLoading: false,
} as unknown as ReturnType<typeof useGetAllServiceAccountsQuery>);
});
describe('loading state', () => {
it('shows a loading indicator while service accounts are being fetched', () => {
vi.mocked(useGetAllServiceAccountsQuery).mockReturnValue({
data: undefined,
isLoading: true,
} as unknown as ReturnType<typeof useGetAllServiceAccountsQuery>);
renderRow();
expect(screen.getByText(/loading service accounts/i)).toBeVisible();
});
});
describe('empty state', () => {
it('shows guidance text when no service accounts are linked to the secret', () => {
renderRow();
expect(screen.getByText(/No service accounts linked/)).toBeVisible();
});
});
describe('linked service account badges', () => {
it('shows a badge for each service account that references this secret', () => {
vi.mocked(useGetAllServiceAccountsQuery).mockReturnValue({
data: [
{
name: 'app-sa',
namespace: 'default',
imagePullSecrets: [{ name: 'my-secret' }],
},
{
name: 'other-sa',
namespace: 'default',
imagePullSecrets: [{ name: 'my-secret' }],
},
],
isLoading: false,
} as unknown as ReturnType<typeof useGetAllServiceAccountsQuery>);
renderRow({ secretName: 'my-secret' });
expect(screen.getByText('app-sa')).toBeVisible();
expect(screen.getByText('other-sa')).toBeVisible();
});
it('only shows service accounts from the matching namespace', () => {
vi.mocked(useGetAllServiceAccountsQuery).mockReturnValue({
data: [
{
name: 'in-namespace-sa',
namespace: 'default',
imagePullSecrets: [{ name: 'my-secret' }],
},
{
name: 'other-namespace-sa',
namespace: 'staging',
imagePullSecrets: [{ name: 'my-secret' }],
},
],
isLoading: false,
} as unknown as ReturnType<typeof useGetAllServiceAccountsQuery>);
renderRow({ namespace: 'default' });
expect(screen.getByText('in-namespace-sa')).toBeVisible();
expect(screen.queryByText('other-namespace-sa')).not.toBeInTheDocument();
});
it('shows a "+N more" badge when there are more than 5 linked SAs', () => {
vi.mocked(useGetAllServiceAccountsQuery).mockReturnValue({
data: Array.from({ length: 7 }, (_, i) => ({
name: `sa-${i}`,
namespace: 'default',
imagePullSecrets: [{ name: 'my-secret' }],
})),
isLoading: false,
} as unknown as ReturnType<typeof useGetAllServiceAccountsQuery>);
renderRow();
expect(screen.getByText(/\+ 2 more/)).toBeVisible();
});
});
describe('edit button visibility by role', () => {
it('does not show edit button for a user without write permission', () => {
vi.mocked(useAuthorizations).mockImplementation(() => ({
authorized: false,
isLoading: false,
}));
renderRow();
expect(
screen.queryByRole('button', { name: /edit/i })
).not.toBeInTheDocument();
});
it('does not show edit button for system secrets, even for an admin', () => {
vi.mocked(useAuthorizations).mockImplementation(() => ({
authorized: true,
isLoading: false,
}));
renderRow({ isSystem: true });
expect(
screen.queryByRole('button', { name: /edit/i })
).not.toBeInTheDocument();
});
it('shows edit button for an authorized user on a non-system secret', () => {
vi.mocked(useAuthorizations).mockImplementation((permission) => ({
authorized: permission === 'K8sServiceAccountsW',
isLoading: false,
}));
renderRow({ isSystem: false });
expect(screen.getByRole('button', { name: /edit/i })).toBeVisible();
});
});
describe('edit flow', () => {
beforeEach(() => {
vi.mocked(useAuthorizations).mockImplementation((permission) => ({
authorized: permission === 'K8sServiceAccountsW',
isLoading: false,
}));
});
it('shows save and cancel buttons when editing', async () => {
renderRow();
await userEvent.click(screen.getByRole('button', { name: /edit/i }));
expect(screen.getByRole('button', { name: /save/i })).toBeVisible();
expect(screen.getByRole('button', { name: /cancel/i })).toBeVisible();
});
it('returns to view mode when cancel is clicked', async () => {
renderRow();
await userEvent.click(screen.getByRole('button', { name: /edit/i }));
await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(screen.getByRole('button', { name: /edit/i })).toBeVisible();
expect(
screen.queryByRole('button', { name: /save/i })
).not.toBeInTheDocument();
});
it('sends a PUT to the correct path and body when linking a new service account', async () => {
vi.mocked(useGetAllServiceAccountsQuery).mockReturnValue({
data: [{ name: 'app-sa', namespace: 'default', imagePullSecrets: [] }],
isLoading: false,
} as unknown as ReturnType<typeof useGetAllServiceAccountsQuery>);
let capturedPath: string | undefined;
let capturedBody: unknown;
server.use(
http.put(
'/api/kubernetes/:envId/namespaces/:namespace/service_accounts/:name/image_pull_secrets',
async ({ request }) => {
capturedPath = request.url;
capturedBody = await request.json();
return new HttpResponse(null, { status: 204 });
}
)
);
renderRow({ secretName: 'my-secret', namespace: 'default' });
await userEvent.click(screen.getByRole('button', { name: /edit/i }));
await act(async () => {
latestOnChange!(['app-sa']);
});
await userEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(capturedPath).toContain(
'/api/kubernetes/1/namespaces/default/service_accounts/app-sa/image_pull_secrets'
);
expect(capturedBody).toEqual({ secretNames: ['my-secret'] });
});
});
it('sends a PUT preserving remaining secrets when removing the secret from a linked SA', async () => {
vi.mocked(useGetAllServiceAccountsQuery).mockReturnValue({
data: [
{
name: 'app-sa',
namespace: 'default',
imagePullSecrets: [{ name: 'my-secret' }, { name: 'other-secret' }],
},
],
isLoading: false,
} as unknown as ReturnType<typeof useGetAllServiceAccountsQuery>);
let capturedBody: unknown;
server.use(
http.put(
'/api/kubernetes/:envId/namespaces/:namespace/service_accounts/:name/image_pull_secrets',
async ({ request }) => {
capturedBody = await request.json();
return new HttpResponse(null, { status: 204 });
}
)
);
renderRow({ secretName: 'my-secret', namespace: 'default' });
await userEvent.click(screen.getByRole('button', { name: /edit/i }));
await act(async () => {
latestOnChange!([]);
});
await userEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(capturedBody).toEqual({ secretNames: ['other-secret'] });
});
});
});
describe('admin vs standard user role', () => {
it('admin with registry access can enter edit mode', async () => {
vi.mocked(useAuthorizations).mockImplementation(() => ({
authorized: true,
isLoading: false,
}));
renderRow({ isSystem: false });
await userEvent.click(screen.getByRole('button', { name: /edit/i }));
expect(screen.getByRole('button', { name: /save/i })).toBeVisible();
});
it('standard user with write permission but no registry access can also edit', async () => {
vi.mocked(useAuthorizations).mockImplementation((permission) => ({
authorized: permission === 'K8sServiceAccountsW',
isLoading: false,
}));
renderRow({ isSystem: false });
await userEvent.click(screen.getByRole('button', { name: /edit/i }));
expect(screen.getByRole('button', { name: /save/i })).toBeVisible();
});
});
});