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
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it } from 'vitest';
import { buildCLISteps } from './useCLISteps';

describe('buildCLISteps', () => {
it('passes the cluster URL to create-harper, and nothing it no longer accepts', () => {
const steps = buildCLISteps('My App', 'https://my-cluster.example.com:9925/');
const create = steps.find((step) => step.title === 'Create your awesome app');
expect(create?.code).toBe('npm create harper my-app -- --deploymentURL="https://my-cluster.example.com:9925/"');
// create-harper dropped --deploymentUsername and the .env password when it moved to `harper login`.
for (const step of steps) {
expect(step.code).not.toMatch(/deploymentUsername/);
expect(step.note ?? '').not.toMatch(/password for this cluster|\.env file/);
}
});

it('tells people to take the harper login offer, and that the deploy uses it', () => {
const steps = buildCLISteps('my-app', 'https://my-cluster.example.com:9925/');
expect(steps.find((step) => step.title === 'Create your awesome app')?.note).toMatch(/harper login/);
expect(steps.find((step) => step.title === 'Deploy!')?.note).toMatch(/login from step 2/);
});

it('leaves the URL out when the cluster has none yet', () => {
const create = buildCLISteps('my-app', undefined).find((step) => step.title === 'Create your awesome app');
expect(create?.code).toBe('npm create harper my-app');
});
});
Original file line number Diff line number Diff line change
@@ -1,16 +1,54 @@
import { isLocalStudio } from '@/config/constants';
import { useInstanceClientParams } from '@/config/useInstanceClient';
import { useInstanceAuth } from '@/hooks/useAuth';
import { Cluster, Instance } from '@/integrations/api/api.patch';
import { excludeFalsy } from '@/lib/arrays/excludeFalsy';
import { toKebabCase } from '@/lib/string/to-kebab-case';
import { getOperationsUrlForCluster } from '@/lib/urls/getOperationsUrlForCluster';
import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance';
import { useRouteContext } from '@tanstack/react-router';
import { useMemo } from 'react';

export interface CLIStep {
title: string;
code: string;
note?: string;
}

/**
* The steps follow create-harper's own flow: it takes the cluster URL, offers to run `harper login`,
* and stores no username or password in the project.
*/
export function buildCLISteps(appName: string, target: string | null | undefined): CLIStep[] {
const directoryName = toKebabCase(appName);
return [
{
title: 'Install or update the Harper CLI',
code: 'npm install -g harper',
},
{
title: 'Create your awesome app',
code: `npm create harper ${directoryName}${target ? ` -- --deploymentURL="${target}"` : ''}`,
note: 'You will be asked some easy questions to get you started. When it offers to log in to this cluster,'
+ ' say yes: harper login saves a token in your home directory, so no password goes into your project.',
},
{
title: 'Start local Harper instance',
code: 'npm run dev',
note: 'You will be prompted to configure Harper.',
},
{
title: 'Learn, make changes, grow!',
code: `cat AGENTS.md`,
},
{
title: 'Deploy!',
code: `npm run deploy`,
note: "It signs in with the login from step 2. Your project's README explains what a deploy needs in its"
+ ' Deployment section.',
},
];
}

export function useCLISteps(appName: string) {
const { user } = useInstanceAuth();
const instanceParams = useInstanceClientParams();
const { instance, cluster }: { instance?: Instance; cluster?: Cluster } = useRouteContext({ strict: false });
const target = isLocalStudio
Expand All @@ -19,37 +57,5 @@ export function useCLISteps(appName: string) {
? getOperationsUrlForInstance(instance)
: getOperationsUrlForCluster(cluster);

return useMemo(() => {
const directoryName = toKebabCase(appName);
const createArgs = [
user?.username && ` --deploymentUsername="${user?.username}"`,
target && ` --deploymentURL="${target}"`,
].filter(excludeFalsy).join('');
return [
{
title: 'Install or update the Harper CLI',
code: 'npm install -g harper',
},
{
title: 'Create your awesome app',
code: `npm create harper ${directoryName}${createArgs.length ? ' --' : ''}${createArgs}`,
note: 'You will be asked some easy questions to get you started. To make deployments easy, enter your'
+ ' password for this cluster. It will get saved into your local .env file.',
},
{
title: 'Start local Harper instance',
code: 'npm run dev',
note: 'You will be prompted to configure Harper.',
},
{
title: 'Learn, make changes, grow!',
code: `cat AGENTS.md`,
},
{
title: 'Deploy!',
code: `npm run deploy`,
note: 'Remember that .env file? It makes this deployment easy!',
},
];
}, [appName, target, user?.username]);
return useMemo(() => buildCLISteps(appName, target), [appName, target]);
}
15 changes: 15 additions & 0 deletions src/features/instance/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { isLocalStudio } from '@/config/constants';
import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery';
import { useInstanceManagePermission } from '@/hooks/usePermissions';
import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged';
import { supportsTrustPolicies } from '@/integrations/api/instance/oidcTrust/oidcTrust';
import { RegistrationInfoResponse } from '@/integrations/api/instance/status/getRegistrationInfo';
import { wasAReleasedBeforeB } from '@/lib/string/wasAReleasedBeforeB';
import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useLoaderData, useParams } from '@tanstack/react-router';
import {
BadgeCheckIcon,
GlobeIcon,
HandshakeIcon,
KeyIcon,
Expand Down Expand Up @@ -37,6 +39,7 @@ export function ConfigIndex() {
// (harper#1554). Floor at the earliest 5.2 prerelease so alpha/beta dev builds pass too — a
// plain '5.2.0' gate would exclude every prerelease (SemVer ranks prereleases below the release).
const secretsSupported = wasAReleasedBeforeB('5.2.0-alpha.1', version);
const trustPoliciesSupported = supportsTrustPolicies(version);

const { clusterId } = params;
const canManage = useInstanceManagePermission();
Expand Down Expand Up @@ -124,6 +127,18 @@ export function ConfigIndex() {
</Link>
</li>
)}
{trustPoliciesSupported && (
<li>
<Link
to={buildAbsoluteLinkToPage(params, 'config/trust-policies')}
className={sharedClasses}
inactiveProps={inactiveProps}
activeProps={activeProps}
>
<BadgeCheckIcon className="hidden md:inline-block" /> <span className="ms-3">Trust Policies</span>
</Link>
</li>
)}
<li>
<Link
to={buildAbsoluteLinkToPage(params, 'config/users')}
Expand Down
10 changes: 10 additions & 0 deletions src/features/instance/config/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ConfigOverviewIndex } from '@/features/instance/config/overview';
import { ConfigRolesIndex } from '@/features/instance/config/roles';
import { ConfigSecretsIndex } from '@/features/instance/config/secrets';
import { ConfigSSHKeysIndex } from '@/features/instance/config/sshKeys';
import { ConfigTrustPoliciesIndex } from '@/features/instance/config/trustPolicies';
import { ConfigUsersIndex } from '@/features/instance/config/users';
import { createInstanceLayoutRoute } from '@/features/instance/instanceLayoutRoute';
import { registrationInfoLoader } from '@/features/instance/registrationInfoLoader';
Expand Down Expand Up @@ -99,6 +100,13 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType<typeof cre
component: ConfigSecretsIndex,
});

const instanceConfigTrustPoliciesRoute = createRoute({
getParentRoute: () => instanceConfigRoute,
path: 'trust-policies',
head: () => ({ meta: [{ title: 'Trust Policies — Harper Fabric' }] }),
component: ConfigTrustPoliciesIndex,
});

const instanceConfigCertificatesRoute = createRoute({
getParentRoute: () => instanceConfigRoute,
path: 'certificates',
Expand Down Expand Up @@ -132,6 +140,8 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType<typeof cre
instanceConfigSecretsRoute,
instanceConfigSecretRoute,

instanceConfigTrustPoliciesRoute,

instanceConfigCertificatesRoute,
instanceConfigCertificateRoute,
]);
Expand Down
199 changes: 199 additions & 0 deletions src/features/instance/config/trustPolicies/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* @vitest-environment jsdom
*/
import { ConfigTrustPoliciesIndex } from '@/features/instance/config/trustPolicies';
import type { OidcTrustPolicy } from '@/integrations/api/instance/oidcTrust/oidcTrust';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { state, setEnabled, dropPolicy, refetch, queryOptionsSeen } = vi.hoisted(() => ({
state: {
canManage: true,
version: '5.3.0' as string | undefined,
policies: [] as unknown[] | undefined,
isError: false,
isFetching: false,
versionError: false,
},
setEnabled: vi.fn(async () => ({ message: 'ok' })),
dropPolicy: vi.fn(async () => ({ message: 'ok' })),
refetch: vi.fn(async () => undefined),
queryOptionsSeen: [] as Array<{ queryKey: readonly unknown[]; enabled?: boolean }>,
}));

vi.mock('@tanstack/react-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@tanstack/react-query')>()),
useQuery: (options: { queryKey: readonly unknown[]; enabled?: boolean }) => {
queryOptionsSeen.push(options);
if (options.queryKey[0] === 'registration_info') {
return {
data: state.version === undefined ? undefined : { version: state.version },
isError: state.versionError,
};
}
return { data: state.policies, isFetching: state.isFetching, isError: state.isError, refetch };
},
}));
vi.mock('@/config/useInstanceClient', () => ({
useInstanceClientIdParams: () => ({ instanceClient: {}, entityId: 'entity-1' }),
}));
vi.mock('@/hooks/usePermissions', () => ({ useInstanceManagePermission: () => state.canManage }));
vi.mock('@/integrations/api/instance/oidcTrust/oidcTrust', async (importOriginal) => ({
...(await importOriginal<typeof import('@/integrations/api/instance/oidcTrust/oidcTrust')>()),
useSetTrustPolicyEnabled: () => ({ mutateAsync: setEnabled, isPending: false }),
useDropTrustPolicy: () => ({ mutateAsync: dropPolicy, isPending: false }),
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), loading: vi.fn() } }));

function policy(overrides: Partial<OidcTrustPolicy> = {}): OidcTrustPolicy {
return {
id: 'github-actions-my-app',
issuer: 'https://token.actions.githubusercontent.com',
audience: 'https://my-cluster.example.com:9925/',
claims: { repository_id: '123456', environment: ['production', 'staging'] },
user: 'my-app-ci-deploy',
operations: ['deploy_component', 'get_job'],
enabled: true,
description: null,
...overrides,
};
}

function card(id: string) {
return screen.getByRole('listitem', { name: `Trust policy ${id}` });
}

beforeEach(() => {
state.canManage = true;
state.version = '5.3.0';
state.policies = [];
state.isError = false;
state.isFetching = false;
state.versionError = false;
});

afterEach(() => {
cleanup();
vi.clearAllMocks();
queryOptionsSeen.length = 0;
});

function listQueryEnabled() {
return queryOptionsSeen.filter((options) => options.queryKey[1] === 'list_oidc_trust').at(-1)?.enabled;
}

describe('ConfigTrustPoliciesIndex', () => {
it('never asks a non-super user instance for its trust policies', () => {
state.canManage = false;
render(<ConfigTrustPoliciesIndex />);
expect(screen.getByText(/Only super users/)).toBeTruthy();
expect(listQueryEnabled()).toBe(false);
});

it('says which Harper version it needs instead of listing on an older instance', () => {
state.version = '5.2.15';
render(<ConfigTrustPoliciesIndex />);
expect(screen.getByText(/need Harper 5.3 or later\. This instance runs 5\.2\.15/)).toBeTruthy();
expect(listQueryEnabled()).toBe(false);
});

it('lists what each policy trusts, who it signs in as, and what its token may run', () => {
state.policies = [policy()];
render(<ConfigTrustPoliciesIndex />);
expect(listQueryEnabled()).toBe(true);
const item = within(card('github-actions-my-app'));
expect(item.getByText('Enabled')).toBeTruthy();
expect(item.getByText('https://my-cluster.example.com:9925/')).toBeTruthy();
expect(item.getByText('repository_id = 123456')).toBeTruthy();
expect(item.getByText('environment = production or staging')).toBeTruthy();
expect(item.getByText('my-app-ci-deploy')).toBeTruthy();
expect(item.getByText('deploy_component, get_job')).toBeTruthy();
});

it("says an unscoped policy's token carries the user's whole role", () => {
state.policies = [policy({ operations: null })];
render(<ConfigTrustPoliciesIndex />);
expect(within(card('github-actions-my-app')).getByText(/the token carries everything my-app-ci-deploy's role/))
.toBeTruthy();
});

it('shows why Harper refuses a policy, and offers only Drop since re-saving would fail the same check', () => {
state.policies = [policy({ invalid_reason: "names user 'my-app-ci-deploy', which does not exist" })];
render(<ConfigTrustPoliciesIndex />);
const item = within(card('github-actions-my-app'));
expect(item.getByText("Can't match")).toBeTruthy();
expect(item.getByText(/Harper refuses every token for this policy: names user/)).toBeTruthy();
expect(item.queryByRole('button', { name: 'Disable' })).toBeNull();
expect(item.getByRole('button', { name: 'Drop' })).toBeTruthy();
});

it('renders a malformed row as text instead of crashing the page', () => {
state.policies = [
policy({
id: 'odd',
claims: { repository_id: { nested: true } },
operations: 'deploy_component',
enabled: 'false',
}),
];
render(<ConfigTrustPoliciesIndex />);
const item = within(card('odd'));
expect(item.getByText('repository_id = {"nested":true}')).toBeTruthy();
// enabled is not a boolean, so the policy is neither shown as live nor offered a toggle.
expect(item.getByText('Disabled')).toBeTruthy();
expect(item.queryByRole('button', { name: /able$/ })).toBeNull();
});

it('disables an enabled policy from the row it was loaded as, then refetches', async () => {
const loaded = policy();
state.policies = [loaded];
render(<ConfigTrustPoliciesIndex />);
fireEvent.click(within(card('github-actions-my-app')).getByRole('button', { name: 'Disable' }));
await waitFor(() => expect(refetch).toHaveBeenCalled());
expect(setEnabled).toHaveBeenCalledWith(expect.objectContaining({ loaded, enabled: false }));
});

it('offers Enable on a disabled policy', () => {
state.policies = [policy({ enabled: false })];
render(<ConfigTrustPoliciesIndex />);
expect(within(card('github-actions-my-app')).getByRole('button', { name: 'Enable' })).toBeTruthy();
});

it('drops a policy only after confirmation', async () => {
state.policies = [policy()];
render(<ConfigTrustPoliciesIndex />);
fireEvent.click(within(card('github-actions-my-app')).getByRole('button', { name: 'Drop' }));
expect(dropPolicy).not.toHaveBeenCalled();
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: /Drop/ }));
// The row as shown, so the drop can refuse one replaced under the same id since.
await waitFor(() => expect(dropPolicy).toHaveBeenCalledWith(expect.objectContaining({ loaded: policy() })));
await waitFor(() => expect(refetch).toHaveBeenCalled());
});

it('holds the actions while the list is refetching, so a click never acts on a replaced row', () => {
state.policies = [policy()];
state.isFetching = true;
render(<ConfigTrustPoliciesIndex />);
const item = within(card('github-actions-my-app'));
expect((item.getByRole('button', { name: 'Disable' }) as HTMLButtonElement).disabled).toBe(true);
expect((item.getByRole('button', { name: 'Drop' }) as HTMLButtonElement).disabled).toBe(true);
});

it('says so when the instance version cannot be read, instead of loading forever', () => {
state.version = undefined;
state.versionError = true;
render(<ConfigTrustPoliciesIndex />);
expect(screen.getByText(/version could not be read/)).toBeTruthy();
expect(listQueryEnabled()).toBe(false);
});

it('says when there are no policies, and when they could not be loaded', () => {
render(<ConfigTrustPoliciesIndex />);
expect(screen.getByText(/No trust policies yet/)).toBeTruthy();
cleanup();
state.isError = true;
render(<ConfigTrustPoliciesIndex />);
expect(screen.getByText(/could not be loaded/)).toBeTruthy();
});
});
Loading
Loading