Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/features/admin/__tests__/adminRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('admin section routes', () => {
it.each([
['/admin/notifications', '/notifications'],
['/admin/regions', '/regions'],
['/admin/hosts', '/hosts'],
['/admin/api-token', '/api-token'],
])('%s resolves to its own route', (path, expectedId) => {
expect(matchLeaf(path).routeId).toContain(expectedId);
Expand Down
15 changes: 8 additions & 7 deletions src/features/admin/__tests__/adminShellVisibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ const user = (fabricRole: User['fabricRole'], staffPermissions?: string[]) =>
const labels = (u: User | null) => visibleAdminItems(u).map((item) => item.label);

describe('admin section visibility', () => {
it('shows every page to a holder of all three page permissions', () => {
const admin = user('fabric_admin', ['systemStatus:write', 'region:read', 'apiToken:create']);
expect(labels(admin)).toEqual(['Notifications', 'Regions', 'API Token']);
it('shows every page to a holder of all four page permissions', () => {
const admin = user('fabric_admin', ['systemStatus:write', 'region:read', 'host:read', 'apiToken:create']);
expect(labels(admin)).toEqual(['Notifications', 'Regions', 'Hosts', 'API Token']);
});

it('shows only the pages a narrower role holds', () => {
Expand All @@ -21,6 +21,7 @@ describe('admin section visibility', () => {
'Regions',
]);
expect(labels(user('fabric_readonly', ['region:read']))).toEqual(['Regions']);
expect(labels(user('fabric_readonly', ['region:read', 'host:read']))).toEqual(['Regions', 'Hosts']);
});

it('hides the section entirely from customers', () => {
Expand All @@ -31,13 +32,13 @@ describe('admin section visibility', () => {
// super_user may password-login; without a Google SSO session the token mint
// 403s, so the API Token page stays hidden even though it holds the permission.
it('never shows the API Token page to super_user', () => {
const withField = user('super_user', ['systemStatus:write', 'region:read', 'apiToken:create']);
expect(labels(withField)).toEqual(['Notifications', 'Regions']);
const withField = user('super_user', ['systemStatus:write', 'region:read', 'host:read', 'apiToken:create']);
expect(labels(withField)).toEqual(['Notifications', 'Regions', 'Hosts']);
// Legacy API without staffPermissions: same carve-out via the fallback.
expect(labels(user('super_user'))).toEqual(['Notifications', 'Regions']);
expect(labels(user('super_user'))).toEqual(['Notifications', 'Regions', 'Hosts']);
});

it('keeps the whole section for a legacy-API fabric_admin', () => {
expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'API Token']);
expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'Hosts', 'API Token']);
});
});
3 changes: 2 additions & 1 deletion src/features/admin/components/AdminShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { SubNavItem, SubNavRail } from '@/components/SubNavRail';
import { hasStaffPermission, useCloudAuth } from '@/hooks/useAuth';
import { LocalUser, StaffPermission, User } from '@/integrations/api/api.patch';
import { Navigate, Outlet, useLocation } from '@tanstack/react-router';
import { BellIcon, GlobeIcon, KeyRoundIcon } from 'lucide-react';
import { BellIcon, GlobeIcon, KeyRoundIcon, ServerIcon } from 'lucide-react';

/**
* Shell for the Admin section: a responsive sub-nav rail (so future admin
Expand All @@ -16,6 +16,7 @@ import { BellIcon, GlobeIcon, KeyRoundIcon } from 'lucide-react';
const items: Array<SubNavItem & { permission: StaffPermission; ssoAccountOnly?: boolean }> = [
{ to: '/admin/notifications', label: 'Notifications', icon: BellIcon, permission: 'systemStatus:write' },
{ to: '/admin/regions', label: 'Regions', icon: GlobeIcon, permission: 'region:read' },
{ to: '/admin/hosts', label: 'Hosts', icon: ServerIcon, permission: 'host:read' },
// Minting a token requires the Google SSO session only staff sign-ins have;
// super_user may password-login, so the mint would 403 for it.
{
Expand Down
252 changes: 252 additions & 0 deletions src/features/admin/hosts/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
/**
* @vitest-environment jsdom
*/
import { apiClient } from '@/config/apiClient';
import {
describeUnresolvedOrganization,
hostMatchesSearch,
HostsIndex,
resolveOrganization,
} from '@/features/admin/hosts/index';
import type { AdminHost } from '@/integrations/api/api.patch';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@/config/apiClient', () => ({ apiClient: { get: vi.fn() } }));
const mockedGet = vi.mocked(apiClient.get);

const auth = vi.hoisted(() => ({ canReadOrganizations: true }));
vi.mock('@/hooks/useAuth', () => ({
useStaffPermission: (permission: string) => permission !== 'org:read' || auth.canReadOrganizations,
}));

function host(overrides: Partial<AdminHost>): AdminHost {
return {
id: 'h-1',
name: 'host-1',
status: 'RUNNING',
fqdn: null,
ipAddress: null,
cloudInstanceType: null,
cloudInstanceId: null,
locationId: 'linode-us-ord',
location: 'us-ord',
locationName: 'Chicago, Illinois, USA',
cloudProvider: 'linode',
regions: ['US'],
organizationIds: null,
dynamicallyAssignable: null,
underMaintenance: null,
gpu: null,
gpuType: null,
gpuCount: null,
embedHub: null,
canaryHost: null,
maxCPUCores: 8,
maxMemoryMb: 16384,
maxStorageGb: 100,
usedCPUCores: 2,
usedMemoryMb: 4096,
usedStorageGb: 25,
instanceCount: 0,
organizations: [],
createdAt: null,
updated: null,
terminatedAt: null,
...overrides,
};
}

const ORGS = [{ id: 'org-1', name: 'Acme' }, { id: 'org-2', name: 'Globex' }, { id: 'org-3', name: 'Acme' }];
const HOSTS = [
host({
id: 'h-shared',
name: 'shared-1',
ipAddress: '10.0.0.7',
instanceCount: 3,
organizations: [{ id: 'org-1', instanceCount: 3 }],
}),
host({
id: 'h-dedicated',
name: 'dedicated-1',
organizationIds: ['org-2'],
gpu: true,
gpuCount: 1,
gpuType: 'nvidia-l4',
}),
];

const requests = (prefix: string) =>
mockedGet.mock.calls.map(([url]) => url as string).filter((url) => url.startsWith(prefix));
const hostRequests = () => requests('/Admin/Host/');

let failHosts = false;
let organizationsResponse: () => Promise<unknown>;

beforeEach(() => {
auth.canReadOrganizations = true;
failHosts = false;
organizationsResponse = async () => ({ data: ORGS });
mockedGet.mockReset();
mockedGet.mockImplementation(async (url) => {
const path = url as string;
if (path.startsWith('/Admin/Organization/')) { return organizationsResponse() as never; }
if (path.startsWith('/Location/')) { return { data: [] } as never; }
if (failHosts) { throw new Error('boom'); }
return { data: HOSTS } as never;
});
});
afterEach(cleanup);

function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<HostsIndex />
</QueryClientProvider>,
);
}

describe('HostsIndex', () => {
it('asks for RUNNING hosts by default', async () => {
renderPage();
await screen.findByText('shared-1');
expect(hostRequests()).toEqual(['/Admin/Host/?status=RUNNING']);
});

it('labels tenant and dedicated organizations by name', async () => {
renderPage();
await waitFor(() => expect(screen.getByText('org-1 (Acme)')).toBeTruthy());
expect(screen.getByText('Dedicated to org-2 (Globex)')).toBeTruthy();
expect(screen.getByText('GPU 1Γ— nvidia-l4')).toBeTruthy();
});

it('narrows the rows client-side without another request', async () => {
renderPage();
await screen.findByText('shared-1');
await act(async () => {
fireEvent.change(screen.getByLabelText('Search hosts'), { target: { value: '10.0.0.7' } });
});
expect(screen.queryByText('dedicated-1')).toBeNull();
expect(screen.getByText('1 of 2 hosts')).toBeTruthy();
expect(hostRequests()).toHaveLength(1);
});

it('sends the picked organization to the server', async () => {
renderPage();
await screen.findByText('shared-1');
await act(async () => {
fireEvent.change(screen.getByLabelText('Organization'), { target: { value: 'org-1 (Acme)' } });
});
await waitFor(() => expect(hostRequests()).toContain('/Admin/Host/?status=RUNNING&organizationId=org-1'));
});

async function typeOrganization(value: string) {
await screen.findByText('shared-1');
await act(async () => {
fireEvent.change(screen.getByLabelText('Organization'), { target: { value } });
});
}

it('never sends text that names no organization', async () => {
renderPage();
await typeOrganization('Ac');
expect(await screen.findByText('No organization matches β€œAc”.')).toBeTruthy();
expect(hostRequests()).toEqual(['/Admin/Host/?status=RUNNING']);
});

it('asks which organization when a name is shared', async () => {
renderPage();
await typeOrganization('acme');
expect(await screen.findByText('2 organizations are named β€œacme” β€” pick one from the list.')).toBeTruthy();
expect(hostRequests()).toEqual(['/Admin/Host/?status=RUNNING']);
});

it('skips the directory without org:read but still accepts a pasted id', async () => {
auth.canReadOrganizations = false;
renderPage();
await typeOrganization('org-2');
await waitFor(() => expect(hostRequests()).toContain('/Admin/Host/?status=RUNNING&organizationId=org-2'));
expect(requests('/Admin/Organization/')).toEqual([]);

await act(async () => {
fireEvent.change(screen.getByLabelText('Organization'), { target: { value: 'Globex' } });
});
expect(await screen.findByText('Enter an organization id (org-…).')).toBeTruthy();
});

// A name the directory hasn't listed yet is not a name that matches nothing.
it('says the organization list is loading rather than that nothing matches', async () => {
organizationsResponse = () => new Promise(() => {});
renderPage();
await typeOrganization('Globex');
expect(await screen.findByText('The organization list is still loading. Paste an org- id to filter now.'))
.toBeTruthy();
expect(screen.queryByText(/No organization matches/)).toBeNull();
expect(hostRequests()).toEqual(['/Admin/Host/?status=RUNNING']);

await act(async () => {
fireEvent.change(screen.getByLabelText('Organization'), { target: { value: 'org-2' } });
});
await waitFor(() => expect(hostRequests()).toContain('/Admin/Host/?status=RUNNING&organizationId=org-2'));
});

it('says the organization list failed when it cannot load', async () => {
organizationsResponse = async () => {
throw new Error('boom');
};
renderPage();
await typeOrganization('Globex');
expect(await screen.findByText("Couldn't load the organization list. Paste an org- id to filter.")).toBeTruthy();
});

it('says so when the host list fails to load', async () => {
failHosts = true;
renderPage();
expect(await screen.findByText("Couldn't load hosts.")).toBeTruthy();
});
});

describe('describeUnresolvedOrganization', () => {
it('claims no match only for a fully loaded directory', () => {
expect(describeUnresolvedOrganization('Acme', 'complete')).toBe('No organization matches β€œAcme”.');
for (const state of ['loading', 'failed', 'truncated', 'unavailable'] as const) {
expect(describeUnresolvedOrganization('Acme', state)).not.toMatch(/No organization matches/);
expect(describeUnresolvedOrganization('Acme', state)).toMatch(/org-/);
}
});
});

describe('resolveOrganization', () => {
it('maps a picked label, an exact id, or a unique name to the id', () => {
expect(resolveOrganization('org-1 (Acme)', ORGS)).toEqual({ id: 'org-1' });
expect(resolveOrganization(' org-2 ', ORGS)).toEqual({ id: 'org-2' });
expect(resolveOrganization('globex', ORGS)).toEqual({ id: 'org-2' });
});

// Names repeat across tenants; picking the first match would show the wrong tenant's fleet.
it('refuses a name more than one organization shares', () => {
expect(resolveOrganization('Acme', ORGS)).toEqual({ ambiguous: 2 });
});

it('accepts a pasted id the directory does not list, and nothing else', () => {
expect(resolveOrganization('org-pasted9', ORGS)).toEqual({ id: 'org-pasted9' });
expect(resolveOrganization('Ac', ORGS)).toEqual({ unresolved: true });
expect(resolveOrganization('Org-Chart', [])).toEqual({ unresolved: true });
});

it('treats blank as no filter', () => {
expect(resolveOrganization(' ', ORGS)).toBeNull();
});
});

describe('hostMatchesSearch', () => {
it('matches name, id, FQDN, IP and cloud instance id, case-insensitively', () => {
const target = host({ name: 'Edge-1', fqdn: 'edge-1.example.net', ipAddress: '10.1.2.3', cloudInstanceId: '987' });
for (const query of ['edge-1', 'H-1', 'example.net', '10.1.2', '987', '']) {
expect(hostMatchesSearch(target, query)).toBe(true);
}
expect(hostMatchesSearch(target, 'nope')).toBe(false);
});
});
Loading
Loading