mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 23:28:58 +00:00
9834b4e4a4
Three frontend call sites (OnboardingWizard.tsx:603, CertificatesPage.tsx:52,
CertificateDetailPage.tsx:169) populated the renewal_policy_id dropdown from
getPolicies() — the compliance-rule endpoint returning pol-* IDs — which
violated the FK managed_certificates.renewal_policy_id REFERENCES
renewal_policies(id) ON DELETE RESTRICT. Create would fail pg 23503 at insert.
Backend (new):
- RenewalPolicyRepository CRUD + ListAll/ExistsByID (pg 23503 → ErrRenewalPolicyInUse
→ HTTP 409; pg 23505 → ErrRenewalPolicyDuplicateName → HTTP 409)
- RenewalPolicyService with repo-only constructor. Service sentinels
var-alias the repo sentinels so errors.Is walks across layers.
- RenewalPolicyHandler with validation bounds: name 1–255;
renewal_window_days [1,365] default 30; max_retries [0,10] not defaulted;
retry_interval_seconds [60,86400] default 3600; alert_thresholds_days
[0,365] default [30,14,7,0]. Auto-generated IDs rp-<slug(name)>.
- Router registers 5 routes under /api/v1/renewal-policies[/{id}].
Frontend:
- CertificatesPage/CertificateDetailPage/OnboardingWizard now call
getRenewalPolicies() and render rp-* IDs.
- client.ts adds getRenewalPolicies/createRenewalPolicy/updateRenewalPolicy/
deleteRenewalPolicy. types.ts adds the RenewalPolicy shape.
OpenAPI: RenewalPolicies tag + 5 operations + 3 schemas (RenewalPolicy,
RenewalPolicyCreateRequest, RenewalPolicyUpdateRequest). 409 responses
on create/update duplicate-name and delete FK-in-use.
No migration — renewal_policies table already exists from the initial
schema (000001).
Tests:
- internal/service/renewal_policy_test.go: CRUD + validation + sentinel
error wrapping.
- internal/api/handler/renewal_policy_handler_test.go: handler endpoint
contracts including 400/404/409.
- web/src/api/client.test.ts: 4 subtests covering the 4 new API functions.
Phase 3 gates all green: go vet, build, short tests, race tests (service/
handler/router/scheduler), staticcheck (G-1 packages), govulncheck (0
reachable), coverage (service 69.7%, handler 79.0%, domain 86.9%,
middleware 80.6% — all above thresholds), tsc, vitest (256 passed),
vite build, OpenAPI structural validation.
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// UX-001 Phase 3 — CertificateStep inline team + owner creation contract
|
|
//
|
|
// The wizard has to satisfy C-001's six required certificate fields (name,
|
|
// common_name, issuer_id, owner_id, team_id, renewal_policy_id). Before Phase
|
|
// 3, a fresh install had no teams + no owners, so the two required `<select>`s
|
|
// were empty and the only way forward was to leave the wizard (losing state)
|
|
// and visit /owners + /teams. The inline modals close that dead end by letting
|
|
// users create a team or owner without leaving CertificateStep.
|
|
//
|
|
// These tests pin the contract on the inline modals specifically:
|
|
//
|
|
// 1. Skip-skip navigation reaches CertificateStep with the "+ New team" and
|
|
// "+ New owner" buttons present.
|
|
// 2. "+ New team" opens the inline modal; submit calls `createTeam`, the
|
|
// React Query cache invalidates, and the parent team `<select>` auto-
|
|
// selects the new team's id.
|
|
// 3. "+ New owner" does the same for owners.
|
|
// 4. Cancel closes the modal without firing the mutation — pins the
|
|
// "nothing leaks on abort" guarantee.
|
|
//
|
|
// DashboardPage's outer wizard entry/exit contract is covered in
|
|
// DashboardPage.test.tsx. Layout's sidebar re-entry button is covered in
|
|
// Layout.test.tsx.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// Mock the entire API client. vi.mock factories are hoisted above the imports
|
|
// that follow, so these stubs are in effect when OnboardingWizard's module
|
|
// graph resolves.
|
|
vi.mock('../api/client', () => ({
|
|
getApiKey: vi.fn(() => 'test-api-key'),
|
|
getIssuers: vi.fn(),
|
|
getAgents: vi.fn(),
|
|
getProfiles: vi.fn(),
|
|
getOwners: vi.fn(),
|
|
getTeams: vi.fn(),
|
|
// G-1: wizard populates the renewal_policy_id dropdown from
|
|
// getRenewalPolicies (rp-* ids), not getPolicies (which returns compliance
|
|
// rules with pol-* ids and violates the FK).
|
|
getRenewalPolicies: vi.fn(),
|
|
createIssuer: vi.fn(),
|
|
testIssuerConnection: vi.fn(),
|
|
createCertificate: vi.fn(),
|
|
triggerRenewal: vi.fn(),
|
|
createTeam: vi.fn(),
|
|
createOwner: vi.fn(),
|
|
}));
|
|
|
|
import OnboardingWizard from './OnboardingWizard';
|
|
import * as client from '../api/client';
|
|
|
|
function renderWizard() {
|
|
const qc = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
|
},
|
|
});
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
<MemoryRouter initialEntries={['/']}>
|
|
<OnboardingWizard onDismiss={vi.fn()} />
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
// Canonical "empty but well-formed" stubs for every query the wizard calls.
|
|
// Returning data (rather than leaving queries pending) lets CertificateStep's
|
|
// dropdowns render their placeholder options immediately.
|
|
function stubAllQueriesEmpty() {
|
|
vi.mocked(client.getIssuers).mockResolvedValue({
|
|
data: [], total: 0, page: 1, per_page: 100,
|
|
} as never);
|
|
vi.mocked(client.getAgents).mockResolvedValue({
|
|
data: [], total: 0, page: 1, per_page: 100,
|
|
} as never);
|
|
vi.mocked(client.getProfiles).mockResolvedValue({
|
|
data: [], total: 0, page: 1, per_page: 100,
|
|
} as never);
|
|
vi.mocked(client.getOwners).mockResolvedValue({
|
|
data: [], total: 0, page: 1, per_page: 500,
|
|
} as never);
|
|
vi.mocked(client.getTeams).mockResolvedValue({
|
|
data: [], total: 0, page: 1, per_page: 500,
|
|
} as never);
|
|
// G-1: wizard populates renewal_policy_id from getRenewalPolicies, not
|
|
// getPolicies. See comment on the mock factory above.
|
|
vi.mocked(client.getRenewalPolicies).mockResolvedValue({
|
|
data: [], total: 0, page: 1, per_page: 500,
|
|
} as never);
|
|
}
|
|
|
|
// Drive through Skip to reach CertificateStep. Each step renders its own
|
|
// "Skip this step" button in the WizardFooter; clicking it advances via the
|
|
// parent goTo() state machine. "Skip setup" in the header (top-right) is a
|
|
// different button tied to onDismiss and is intentionally not clicked here.
|
|
async function advanceToCertificateStep() {
|
|
// IssuerStep renders first. Wait for its heading before clicking skip so
|
|
// we don't race ahead of the initial render.
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole('heading', { name: /Connect a Certificate Authority/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: /Skip this step/i }));
|
|
|
|
// AgentStep — wait for its heading, then skip.
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole('heading', { name: /Deploy a certctl Agent/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: /Skip this step/i }));
|
|
|
|
// CertificateStep — wait for its heading. Caller can now exercise the
|
|
// inline modals.
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole('heading', { name: /Add a Certificate/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
}
|
|
|
|
describe('OnboardingWizard — UX-001 inline team + owner creation in CertificateStep', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
cleanup();
|
|
stubAllQueriesEmpty();
|
|
});
|
|
|
|
it('skip-skip reaches CertificateStep with "+ New team" and "+ New owner" buttons', async () => {
|
|
renderWizard();
|
|
await advanceToCertificateStep();
|
|
|
|
// The contract buttons are the whole point of UX-001 Phase 3 — if they
|
|
// disappear, the dead-end is back.
|
|
expect(screen.getByRole('button', { name: /\+ New team/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /\+ New owner/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('+ New team opens the inline modal, calls createTeam, invalidates the cache, and auto-selects the new team', async () => {
|
|
// Drive getTeams from a closure variable so React Query's post-mutation
|
|
// refetch observes the newly-created team. Without this, the parent
|
|
// <select> would auto-select 't-platform' but the DOM would have no
|
|
// matching <option> and the browser normalizes select.value back to ''.
|
|
let teamsData: Array<{
|
|
id: string; name: string; description: string; created_at: string; updated_at: string;
|
|
}> = [];
|
|
vi.mocked(client.getTeams).mockImplementation(() =>
|
|
Promise.resolve({
|
|
data: teamsData,
|
|
total: teamsData.length,
|
|
page: 1,
|
|
per_page: 500,
|
|
} as never),
|
|
);
|
|
|
|
vi.mocked(client.createTeam).mockImplementation(async (data) => {
|
|
const team = {
|
|
id: 't-platform',
|
|
name: data?.name ?? 'unnamed',
|
|
description: data?.description ?? '',
|
|
created_at: '2026-04-19T00:00:00Z',
|
|
updated_at: '2026-04-19T00:00:00Z',
|
|
};
|
|
// Mutate the closure so the subsequent invalidation-triggered refetch
|
|
// of ['teams'] returns the new row. Per
|
|
// OnboardingWizard.tsx:411-419 the success branch invalidates
|
|
// queryKey ['teams'] before firing onCreated(team.id) + onClose().
|
|
teamsData = [team];
|
|
return team as never;
|
|
});
|
|
|
|
renderWizard();
|
|
await advanceToCertificateStep();
|
|
|
|
// Open the inline team modal.
|
|
fireEvent.click(screen.getByRole('button', { name: /\+ New team/i }));
|
|
|
|
// Modal is open — "Create Team" heading + the autofocused Name input
|
|
// are both present.
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('heading', { name: /Create Team/i })).toBeInTheDocument();
|
|
});
|
|
const nameInput = screen.getByPlaceholderText(/Platform Engineering/i);
|
|
fireEvent.change(nameInput, { target: { value: 'Platform Eng' } });
|
|
|
|
// Submit — anchored regex so we don't accidentally match "+ New team"
|
|
// or any "Create Team" banner elsewhere on the page.
|
|
fireEvent.click(screen.getByRole('button', { name: /^Create Team$/ }));
|
|
|
|
// Mutation fires with trimmed name + empty description — mirrors the
|
|
// contract in OnboardingWizard.tsx:411.
|
|
await waitFor(() => {
|
|
expect(vi.mocked(client.createTeam)).toHaveBeenCalledWith({
|
|
name: 'Platform Eng',
|
|
description: '',
|
|
});
|
|
});
|
|
|
|
// Modal tears down on success (onClose() in the mutation's onSuccess).
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('heading', { name: /Create Team/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
// Parent <select> auto-selected the new team. Locate the select by
|
|
// finding the new team's <option> (which only exists on the parent
|
|
// form's team dropdown after the refetch populates it), then assert
|
|
// the select's current value is the new id. This avoids relying on
|
|
// label-for-select association, which the current markup doesn't
|
|
// provide (label is a sibling, not htmlFor-linked).
|
|
const newTeamOption = await screen.findByRole('option', { name: /Platform Eng/i });
|
|
const teamSelect = newTeamOption.closest('select') as HTMLSelectElement;
|
|
expect(teamSelect).not.toBeNull();
|
|
await waitFor(() => {
|
|
expect(teamSelect.value).toBe('t-platform');
|
|
});
|
|
});
|
|
|
|
it('+ New owner opens the inline modal, calls createOwner, invalidates the cache, and auto-selects the new owner', async () => {
|
|
let ownersData: Array<{
|
|
id: string; name: string; email: string; team_id: string;
|
|
created_at: string; updated_at: string;
|
|
}> = [];
|
|
vi.mocked(client.getOwners).mockImplementation(() =>
|
|
Promise.resolve({
|
|
data: ownersData,
|
|
total: ownersData.length,
|
|
page: 1,
|
|
per_page: 500,
|
|
} as never),
|
|
);
|
|
|
|
vi.mocked(client.createOwner).mockImplementation(async (data) => {
|
|
const owner = {
|
|
id: 'o-alice',
|
|
name: data?.name ?? 'unnamed',
|
|
email: data?.email ?? '',
|
|
team_id: data?.team_id ?? '',
|
|
created_at: '2026-04-19T00:00:00Z',
|
|
updated_at: '2026-04-19T00:00:00Z',
|
|
};
|
|
ownersData = [owner];
|
|
return owner as never;
|
|
});
|
|
|
|
renderWizard();
|
|
await advanceToCertificateStep();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /\+ New owner/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('heading', { name: /Create Owner/i })).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.change(screen.getByPlaceholderText(/Alice Chen/i), {
|
|
target: { value: 'Alice Chen' },
|
|
});
|
|
fireEvent.change(screen.getByPlaceholderText(/alice@example\.com/i), {
|
|
target: { value: 'alice@example.com' },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Create Owner$/ }));
|
|
|
|
// Per OnboardingWizard.tsx:485-489, team_id is coerced to `undefined`
|
|
// when the optional Team select is left at its default empty value —
|
|
// otherwise the server would see `team_id: ""` and 400 on an invalid
|
|
// FK. This assertion pins that coercion.
|
|
await waitFor(() => {
|
|
expect(vi.mocked(client.createOwner)).toHaveBeenCalledWith({
|
|
name: 'Alice Chen',
|
|
email: 'alice@example.com',
|
|
team_id: undefined,
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('heading', { name: /Create Owner/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
// Parent Owner <select> auto-selects the new owner. Option text format
|
|
// from OnboardingWizard.tsx:754-756 is `{name}{email ? ` (${email})` : ''}`
|
|
// — "Alice Chen (alice@example.com)".
|
|
const newOwnerOption = await screen.findByRole('option', { name: /Alice Chen/i });
|
|
const ownerSelect = newOwnerOption.closest('select') as HTMLSelectElement;
|
|
expect(ownerSelect).not.toBeNull();
|
|
await waitFor(() => {
|
|
expect(ownerSelect.value).toBe('o-alice');
|
|
});
|
|
});
|
|
|
|
it('cancel on the team modal closes it without firing createTeam', async () => {
|
|
renderWizard();
|
|
await advanceToCertificateStep();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /\+ New team/i }));
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('heading', { name: /Create Team/i })).toBeInTheDocument();
|
|
});
|
|
|
|
// Cancel button is the modal's second footer button — anchored regex to
|
|
// avoid matching any stray "Cancel" on the page.
|
|
fireEvent.click(screen.getByRole('button', { name: /^Cancel$/ }));
|
|
|
|
// Modal tears down and the mutation never fires — abort is clean.
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('heading', { name: /Create Team/i })).not.toBeInTheDocument();
|
|
});
|
|
expect(vi.mocked(client.createTeam)).not.toHaveBeenCalled();
|
|
});
|
|
});
|