Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 23 additions & 27 deletions src/common/components/UserAvatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse, delay } from 'msw';
import { server } from '../../../testing/msw/server';
import { UserAvatar } from './UserAvatar';
import { PAT_KEY, USER_KEY } from '../services/types';
import { ForgeConnectionContext } from '../context/ForgeConnectionProvider';
Expand All @@ -9,12 +11,7 @@ vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

const mockFetchUserInfo = vi.fn();
vi.mock('../services/source-control/useSourceControlService', () => ({
useSourceControlService: () => ({
fetchUserInfo: mockFetchUserInfo,
}),
}));
const GITHUB_API = 'https://api.github.com';

const testUser = { name: 'twoGiants' };

Expand All @@ -32,10 +29,6 @@ describe('UserAvatar', () => {
sessionStorage.clear();
});

afterEach(() => {
vi.restoreAllMocks();
});

afterAll(() => {
sessionStorage.clear();
});
Expand Down Expand Up @@ -112,10 +105,9 @@ describe('UserAvatar', () => {
expect(screen.getByRole('button', { name: 'Connect' })).toBeDisabled();
});

it('calls fetchUserInfo with PAT and updates UI on successful connect', async () => {
it('calls GitHub API with PAT and updates UI on successful connect', async () => {
const user = userEvent.setup();
const connectToForge = vi.fn();
mockFetchUserInfo.mockResolvedValue(testUser);

renderWithContext(<UserAvatar enableReconnect />, {
isActive: false,
Expand All @@ -128,25 +120,28 @@ describe('UserAvatar', () => {
await user.click(screen.getByRole('button', { name: 'Connect' }));

await waitFor(() => {
expect(mockFetchUserInfo).toHaveBeenCalledWith('ghp_valid');
expect(screen.getByText('twoGiants')).toBeInTheDocument();
});

expect(screen.getByText('twoGiants')).toBeInTheDocument();
expect(sessionStorage.getItem(PAT_KEY)).toBe('ghp_valid');
expect(JSON.parse(sessionStorage.getItem(USER_KEY)!)).toEqual(testUser);
expect(connectToForge).toHaveBeenCalled();
});

it('shows error alert when fetchUserInfo rejects', async () => {
it('shows error alert when GitHub API rejects', async () => {
const user = userEvent.setup();
mockFetchUserInfo.mockRejectedValue(new Error('Bad credentials'));
server.use(
http.get(`${GITHUB_API}/user`, () =>
HttpResponse.json({ message: 'Bad credentials' }, { status: 401 }),
),
);

renderWithContext(<UserAvatar enableReconnect />);

await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_bad');
await user.click(screen.getByRole('button', { name: 'Connect' }));

expect(await screen.findByText('Bad credentials')).toBeInTheDocument();
expect(await screen.findByText(/Bad credentials/)).toBeInTheDocument();
});

it('closes modal when Cancel is clicked', async () => {
Expand All @@ -164,7 +159,6 @@ describe('UserAvatar', () => {
it('clears PAT input after successful connect', async () => {
const user = userEvent.setup();
const connectToForge = vi.fn();
mockFetchUserInfo.mockResolvedValue(testUser);

renderWithContext(<UserAvatar enableReconnect />, {
isActive: false,
Expand All @@ -187,28 +181,32 @@ describe('UserAvatar', () => {

it('clears PAT input and error on cancel', async () => {
const user = userEvent.setup();
mockFetchUserInfo.mockRejectedValue(new Error('Bad credentials'));
server.use(
http.get(`${GITHUB_API}/user`, () =>
HttpResponse.json({ message: 'Bad credentials' }, { status: 401 }),
),
);

renderWithContext(<UserAvatar enableReconnect />);

await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_bad');
await user.click(screen.getByRole('button', { name: 'Connect' }));

expect(await screen.findByText('Bad credentials')).toBeInTheDocument();
expect(await screen.findByText(/Bad credentials/)).toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Cancel' }));
await user.click(screen.getByRole('button', { name: 'Connect to GitHub' }));

expect(screen.getByLabelText('Personal Access Token')).toHaveValue('');
expect(screen.queryByText('Bad credentials')).not.toBeInTheDocument();
expect(screen.queryByText(/Bad credentials/)).not.toBeInTheDocument();
});

it('disables Cancel button while validating', async () => {
const user = userEvent.setup();
let resolveConnect: () => void;
mockFetchUserInfo.mockReturnValue(
new Promise<void>((resolve) => {
resolveConnect = resolve;
server.use(
http.get(`${GITHUB_API}/user`, async () => {
await delay('infinite');
return HttpResponse.json({ login: 'twoGiants' });
}),
);

Expand All @@ -218,8 +216,6 @@ describe('UserAvatar', () => {
await user.click(screen.getByRole('button', { name: 'Connect' }));

expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();

resolveConnect!();
});
});
});
161 changes: 70 additions & 91 deletions src/common/services/cluster/OcpClusterService.test.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,72 @@
import { http, HttpResponse } from 'msw';
import { server } from '../../../../testing/msw/server';
import { OcpClusterService } from './OcpClusterService';

const mockGet = vi.fn();
const mockPost = vi.fn();

vi.mock('@openshift-console/dynamic-plugin-sdk', () => {
const fn = (...args: unknown[]) => mockGet(...args);
fn.post = (...args: unknown[]) => mockPost(...args);
return { consoleFetchJSON: fn };
async function handleResponse(res: Response) {
const json = await res.json();
if (!res.ok) throw json;
return json;
}

const consoleFetchJSON = Object.assign(
async (url: string) => {
const res = await fetch(new URL(url, 'http://localhost').href);
return handleResponse(res);
},
{
post: async (url: string, body: unknown) => {
const res = await fetch(new URL(url, 'http://localhost').href, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return handleResponse(res);
},
},
);

return { consoleFetchJSON };
});

const K8S_API = 'http://localhost/api/kubernetes';

function setupK8sHandlers(namespace: string) {
server.use(
http.post(`${K8S_API}/api/v1/namespaces/${namespace}/serviceaccounts`, () =>
HttpResponse.json({}),
),
http.post(`${K8S_API}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/roles`, () =>
HttpResponse.json({}),
),
http.post(
`${K8S_API}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings`,
() => HttpResponse.json({}),
),
http.post(`${K8S_API}/api/v1/namespaces/${namespace}/serviceaccounts/func-github/token`, () =>
HttpResponse.json({ status: { token: 'sa-token-value' } }),
),
);
}

describe('OcpClusterService', () => {
const namespace = 'my-ns';

beforeEach(() => {
(window as unknown as Record<string, unknown>).SERVER_FLAGS = {
kubeAPIServerURL: 'https://api.cluster.example.com:6443',
};
// POST calls: SA, Role, RoleBinding, ImageBuilderBinding, TokenRequest
mockPost
.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ status: { token: 'sa-token-value' } });
setupK8sHandlers(namespace);
});

afterEach(() => {
vi.clearAllMocks();
delete (window as unknown as Record<string, unknown>).SERVER_FLAGS;
});

it('creates SA, Role, RoleBinding, gets token, and returns kubeconfig', async () => {
const svc = new OcpClusterService();
const kubeconfig = await svc.generateKubeconfig(namespace);

// Verify SA creation
expect(mockPost).toHaveBeenCalledWith(
`/api/kubernetes/api/v1/namespaces/${namespace}/serviceaccounts`,
expect.objectContaining({
metadata: { name: 'func-github', namespace },
}),
);

// Verify Role creation
expect(mockPost).toHaveBeenCalledWith(
`/api/kubernetes/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/roles`,
expect.objectContaining({
metadata: { name: 'func-github-deployer', namespace },
}),
);

// Verify RoleBinding creation
expect(mockPost).toHaveBeenCalledWith(
`/api/kubernetes/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings`,
expect.objectContaining({
metadata: { name: 'func-github-deployer', namespace },
}),
);

// Verify image-builder RoleBinding
expect(mockPost).toHaveBeenCalledWith(
`/api/kubernetes/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings`,
expect.objectContaining({
metadata: { name: 'func-github-image-builder', namespace },
roleRef: expect.objectContaining({
kind: 'ClusterRole',
name: 'system:image-builder',
}),
}),
);

// Verify TokenRequest
expect(mockPost).toHaveBeenCalledWith(
`/api/kubernetes/api/v1/namespaces/${namespace}/serviceaccounts/func-github/token`,
expect.objectContaining({
kind: 'TokenRequest',
spec: { expirationSeconds: 31536000 },
}),
);

// Verify kubeconfig structure
const parsed = JSON.parse(kubeconfig);
expect(parsed.apiVersion).toBe('v1');
expect(parsed.kind).toBe('Config');
Expand All @@ -89,33 +76,20 @@ describe('OcpClusterService', () => {
expect(parsed.contexts[0].context.namespace).toBe(namespace);
});

it('treats 409 Conflict (response.status) on SA/Role/RoleBinding as success', async () => {
const conflict = Object.assign(new Error('Conflict'), { response: { status: 409 } });

mockPost
.mockReset()
.mockRejectedValueOnce(conflict) // SA already exists
.mockRejectedValueOnce(conflict) // Role already exists
.mockRejectedValueOnce(conflict) // RoleBinding already exists
.mockRejectedValueOnce(conflict) // ImageBuilderBinding already exists
.mockResolvedValueOnce({ status: { token: 'sa-token-value' } }); // TokenRequest

const svc = new OcpClusterService();
const kubeconfig = await svc.generateKubeconfig(namespace);

expect(JSON.parse(kubeconfig).users[0].user.token).toBe('sa-token-value');
});

it('treats K8s Status object with code 409 as success', async () => {
const k8sConflict = { code: 409, reason: 'AlreadyExists', message: 'already exists' };

mockPost
.mockReset()
.mockRejectedValueOnce(k8sConflict)
.mockRejectedValueOnce(k8sConflict)
.mockRejectedValueOnce(k8sConflict)
.mockRejectedValueOnce(k8sConflict)
.mockResolvedValueOnce({ status: { token: 'sa-token-value' } });
it('treats 409 Conflict on SA/Role/RoleBinding as success', async () => {
const conflict = { code: 409, reason: 'AlreadyExists', message: 'already exists' };
server.use(
http.post(`${K8S_API}/api/v1/namespaces/${namespace}/serviceaccounts`, () =>
HttpResponse.json(conflict, { status: 409 }),
),
http.post(`${K8S_API}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/roles`, () =>
HttpResponse.json(conflict, { status: 409 }),
),
http.post(
`${K8S_API}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings`,
() => HttpResponse.json(conflict, { status: 409 }),
),
);

const svc = new OcpClusterService();
const kubeconfig = await svc.generateKubeconfig(namespace);
Expand All @@ -124,12 +98,17 @@ describe('OcpClusterService', () => {
});

it('propagates non-409 API errors', async () => {
const forbidden = Object.assign(new Error('Forbidden'), { response: { status: 403 } });

mockPost.mockReset().mockRejectedValueOnce(forbidden);
server.use(
http.post(`${K8S_API}/api/v1/namespaces/${namespace}/serviceaccounts`, () =>
HttpResponse.json(
{ code: 403, reason: 'Forbidden', message: 'Forbidden' },
{ status: 403 },
),
),
);

const svc = new OcpClusterService();
await expect(svc.generateKubeconfig(namespace)).rejects.toThrow('Forbidden');
await expect(svc.generateKubeconfig(namespace)).rejects.toMatchObject({ code: 403 });
});

it('throws when SERVER_FLAGS is missing', async () => {
Expand Down
Loading