diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 74e147c..3b68a27 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -35,6 +35,8 @@ Commands: jobs cancel ID Cancel a pending job import FILE Bulk import certificates from PEM file(s) + Required: --owner-id, --team-id, --renewal-policy-id, --issuer-id + Optional: --name-template (default {cn}), --environment (default imported) status Show server health + summary stats version Show CLI version diff --git a/deploy/test/qa_test.go b/deploy/test/qa_test.go index a6166e6..6e0394e 100644 --- a/deploy/test/qa_test.go +++ b/deploy/test/qa_test.go @@ -435,10 +435,19 @@ func TestQA(t *testing.T) { // =================================================================== t.Run("Part03_CertCRUD", func(t *testing.T) { t.Run("Create_Minimal", func(t *testing.T) { + // C-001 scope-expansion: the handler's ValidateRequired + // contract now gates common_name, owner_id, team_id, + // issuer_id, name, and renewal_policy_id. A 3-field + // payload would 400 regardless of the id hint, so the + // "minimal" variant carries every required field. code, body := c.bodyStr(t, "POST", "/api/v1/certificates", `{ "id": "mc-qa-minimal", + "name": "qa-minimal", "common_name": "qa-minimal.example.com", - "issuer_id": "iss-local" + "issuer_id": "iss-local", + "owner_id": "o-alice", + "team_id": "t-platform", + "renewal_policy_id": "rp-standard" }`) if code != 201 && code != 200 { t.Fatalf("create cert: status %d, body: %s", code, body) @@ -448,11 +457,14 @@ func TestQA(t *testing.T) { t.Run("Create_Full", func(t *testing.T) { code, body := c.bodyStr(t, "POST", "/api/v1/certificates", `{ "id": "mc-qa-full", + "name": "qa-full", "common_name": "qa-full.example.com", "sans": ["qa-full-alt.example.com"], "issuer_id": "iss-local", "environment": "staging", - "owner_id": "o-alice" + "owner_id": "o-alice", + "team_id": "t-platform", + "renewal_policy_id": "rp-standard" }`) if code != 201 && code != 200 { t.Fatalf("create cert: status %d, body: %s", code, body) diff --git a/internal/cli/client.go b/internal/cli/client.go index 337da5a..e6120ff 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -12,6 +12,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "text/tabwriter" "time" ) @@ -430,7 +431,54 @@ func (c *Client) GetStatus() error { } // ImportCertificates bulk imports certificates from PEM files. -func (c *Client) ImportCertificates(files []string) error { +// +// C-001 scope-expansion closure: the create-certificate handler's +// six-field required contract (name, common_name, renewal_policy_id, +// issuer_id, owner_id, team_id) is enforced server-side via +// ValidateRequired. The bulk importer must therefore be told which +// owner / team / renewal-policy / issuer to assign to every imported +// cert — otherwise every POST comes back 400. All four IDs are +// required flags; missing flags error out with a user-legible message +// before any files are read. +func (c *Client) ImportCertificates(args []string) error { + fs := flag.NewFlagSet("import", flag.ContinueOnError) + ownerID := fs.String("owner-id", "", "Owner ID to assign to each imported certificate (required)") + teamID := fs.String("team-id", "", "Team ID to assign to each imported certificate (required)") + renewalPolicyID := fs.String("renewal-policy-id", "", "Renewal policy ID to assign to each imported certificate (required)") + issuerID := fs.String("issuer-id", "", "Issuer ID to assign to each imported certificate (required)") + nameTemplate := fs.String("name-template", "{cn}", "Template for the certificate name; {cn} is substituted with the cert's common name") + environment := fs.String("environment", "imported", "Environment tag for each imported certificate") + if err := fs.Parse(args); err != nil { + return err + } + + // Validate required flags up front — a clear error here beats six + // parallel 400s from the server. + missing := []string{} + if *ownerID == "" { + missing = append(missing, "--owner-id") + } + if *teamID == "" { + missing = append(missing, "--team-id") + } + if *renewalPolicyID == "" { + missing = append(missing, "--renewal-policy-id") + } + if *issuerID == "" { + missing = append(missing, "--issuer-id") + } + if len(missing) > 0 { + return fmt.Errorf("missing required flag(s): %s", strings.Join(missing, ", ")) + } + if *nameTemplate == "" { + return fmt.Errorf("--name-template must be non-empty") + } + + files := fs.Args() + if len(files) == 0 { + return fmt.Errorf("at least one PEM file path is required") + } + var imported, failed int for _, filePath := range files { @@ -452,12 +500,18 @@ func (c *Client) ImportCertificates(files []string) error { total := len(certs) fmt.Printf("Importing %d/%d certificates from %s...\r", i+1, total, filepath.Base(filePath)) + name := strings.ReplaceAll(*nameTemplate, "{cn}", cert.Subject.CommonName) + req := map[string]interface{}{ - "common_name": cert.Subject.CommonName, - "sans": cert.DNSNames, - "issuer_id": "iss-local", - "environment": "imported", - "status": "Active", + "name": name, + "common_name": cert.Subject.CommonName, + "sans": cert.DNSNames, + "issuer_id": *issuerID, + "owner_id": *ownerID, + "team_id": *teamID, + "renewal_policy_id": *renewalPolicyID, + "environment": *environment, + "status": "Active", } if cert.SerialNumber != nil { diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index de2b68c..fe6f718 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -387,6 +387,178 @@ func TestClient_AuthHeader(t *testing.T) { } } +// TestClient_ImportCertificates_MissingRequiredFlags verifies the CLI +// import command rejects invocations missing any of the four required +// flags (--owner-id, --team-id, --renewal-policy-id, --issuer-id) +// before any network call is attempted. This is the C-001 scope-expansion +// closure for the CLI layer: the handler now requires all six cert +// fields, so the importer must collect ownership / team / policy / +// issuer up front rather than hard-coding iss-local and letting the +// server 400 on every POST. +func TestClient_ImportCertificates_MissingRequiredFlags(t *testing.T) { + var requestCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + cases := []struct { + name string + args []string + missing string + }{ + { + name: "missing owner-id", + args: []string{"--team-id", "t-platform", "--renewal-policy-id", "rp-default", "--issuer-id", "iss-local", "certs.pem"}, + missing: "--owner-id", + }, + { + name: "missing team-id", + args: []string{"--owner-id", "o-alice", "--renewal-policy-id", "rp-default", "--issuer-id", "iss-local", "certs.pem"}, + missing: "--team-id", + }, + { + name: "missing renewal-policy-id", + args: []string{"--owner-id", "o-alice", "--team-id", "t-platform", "--issuer-id", "iss-local", "certs.pem"}, + missing: "--renewal-policy-id", + }, + { + name: "missing issuer-id", + args: []string{"--owner-id", "o-alice", "--team-id", "t-platform", "--renewal-policy-id", "rp-default", "certs.pem"}, + missing: "--issuer-id", + }, + { + name: "no flags at all", + args: []string{"certs.pem"}, + missing: "--owner-id", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client := NewClient(server.URL, "", "table") + err := client.ImportCertificates(tc.args) + if err == nil { + t.Fatalf("expected error for %s, got nil", tc.name) + } + msg := err.Error() + if !containsStr(msg, tc.missing) { + t.Fatalf("expected error to name %q, got: %v", tc.missing, err) + } + if !containsStr(msg, "required") { + t.Fatalf("expected error message to mention 'required', got: %v", err) + } + }) + } + + if requestCount != 0 { + t.Fatalf("expected zero HTTP requests before flag validation, got %d", requestCount) + } +} + +// TestClient_ImportCertificates_MissingPositionalArgs verifies the +// import command errors out when flags are present but no PEM file +// paths follow them. +func TestClient_ImportCertificates_MissingPositionalArgs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + client := NewClient(server.URL, "", "table") + err := client.ImportCertificates([]string{ + "--owner-id", "o-alice", + "--team-id", "t-platform", + "--renewal-policy-id", "rp-default", + "--issuer-id", "iss-local", + }) + if err == nil { + t.Fatal("expected error when no PEM file paths are supplied") + } + if !containsStr(err.Error(), "PEM file") { + t.Fatalf("expected error to mention 'PEM file', got: %v", err) + } +} + +// TestClient_ImportCertificates_SixFieldPayload verifies the happy +// path: given all four required flags plus a PEM file, the importer +// POSTs a request containing all six required fields plus the +// name-template–resolved name. The httptest handler decodes the +// request body and asserts every required field is populated with +// the values supplied via flags. +func TestClient_ImportCertificates_SixFieldPayload(t *testing.T) { + // Generate a test cert and write it to a temp PEM file. + cert := generateTestCert() + pemBlock := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw} + pemPath := filepath.Join(t.TempDir(), "test.pem") + if err := os.WriteFile(pemPath, pem.EncodeToMemory(pemBlock), 0o600); err != nil { + t.Fatalf("write temp PEM: %v", err) + } + + var gotBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/api/v1/certificates" { + w.WriteHeader(http.StatusNotFound) + return + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode request body: %v", err) + } + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"mc-imported"}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "", "table") + err := client.ImportCertificates([]string{ + "--owner-id", "o-alice", + "--team-id", "t-platform", + "--renewal-policy-id", "rp-default", + "--issuer-id", "iss-local", + "--name-template", "imported-{cn}", + pemPath, + }) + if err != nil { + t.Fatalf("ImportCertificates failed: %v", err) + } + + // Verify every required field from the six-field contract is present. + required := []struct { + field string + want interface{} + }{ + {"name", "imported-test.example.com"}, + {"common_name", "test.example.com"}, + {"issuer_id", "iss-local"}, + {"owner_id", "o-alice"}, + {"team_id", "t-platform"}, + {"renewal_policy_id", "rp-default"}, + } + for _, r := range required { + got, ok := gotBody[r.field] + if !ok { + t.Errorf("payload missing required field %q (body: %+v)", r.field, gotBody) + continue + } + if got != r.want { + t.Errorf("field %q = %v, want %v", r.field, got, r.want) + } + } +} + +// containsStr is a tiny substring helper so the test file doesn't +// need a `strings` import dependency aside from what's already there. +func containsStr(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} + // Helper function to generate a test certificate func generateTestCert() *x509.Certificate { now := time.Now() diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 45a85d9..4204846 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -288,6 +288,39 @@ describe('API Client', () => { expect(JSON.parse(init.body)).toEqual(certData); }); + // C-001 scope-expansion regression: the OnboardingWizard CertificateStep + // and the CertificatesPage CreateCertificateModal must both ship the full + // six-field required payload (name, common_name, renewal_policy_id, + // issuer_id, owner_id, team_id) — the handler's ValidateRequired contract + // rejects anything less with HTTP 400. This test pins the wire shape so + // that accidentally dropping a field from either UI surface fails CI + // rather than only surfacing as a 400 at runtime. + it('createCertificate accepts and transmits all six required fields', async () => { + const wizardPayload = { + name: 'API Production Cert', + common_name: 'api.example.com', + sans: ['www.example.com'], + issuer_id: 'iss-local', + owner_id: 'o-alice', + team_id: 't-platform', + renewal_policy_id: 'rp-standard', + environment: 'production', + }; + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-new', ...wizardPayload })); + await createCertificate(wizardPayload); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/certificates'); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body); + // Assert every required field is present and intact + expect(body.name).toBe('API Production Cert'); + expect(body.common_name).toBe('api.example.com'); + expect(body.issuer_id).toBe('iss-local'); + expect(body.owner_id).toBe('o-alice'); + expect(body.team_id).toBe('t-platform'); + expect(body.renewal_policy_id).toBe('rp-standard'); + }); + it('updateCertificate sends PUT', async () => { mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-test', status: 'Active' })); await updateCertificate('mc-test', { status: 'Active' }); diff --git a/web/src/pages/OnboardingWizard.tsx b/web/src/pages/OnboardingWizard.tsx index 077fab6..fa2545d 100644 --- a/web/src/pages/OnboardingWizard.tsx +++ b/web/src/pages/OnboardingWizard.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate, Link } from 'react-router-dom'; import { - getIssuers, getAgents, getProfiles, getOwners, + getIssuers, getAgents, getProfiles, getOwners, getTeams, getPolicies, createIssuer, testIssuerConnection, createCertificate, triggerRenewal, getApiKey, @@ -400,18 +400,28 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: { createdIssuerId: string | null; }) { const queryClient = useQueryClient(); + const [name, setName] = useState(''); const [commonName, setCommonName] = useState(''); const [sans, setSans] = useState(''); const [issuerId, setIssuerId] = useState(createdIssuerId || ''); const [profileId, setProfileId] = useState(''); const [ownerId, setOwnerId] = useState(''); + const [teamId, setTeamId] = useState(''); + const [renewalPolicyId, setRenewalPolicyId] = useState(''); const [error, setError] = useState(''); const [created, setCreated] = useState(false); + // C-001: the server requires name, common_name, issuer_id, owner_id, + // team_id, and renewal_policy_id (handler in + // internal/api/handler/certificates.go + ManagedCertificate.required in + // api/openapi.yaml). The wizard must collect the same six fields so that + // "Issue Certificate" doesn't 400 at the API boundary. const { data: issuers } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() }); const { data: profiles } = useQuery({ queryKey: ['profiles'], queryFn: () => getProfiles() }); const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents() }); - const { data: owners } = useQuery({ queryKey: ['owners'], queryFn: () => getOwners() }); + const { data: owners } = useQuery({ queryKey: ['owners'], queryFn: () => getOwners({ per_page: '500' }) }); + const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: () => getTeams({ per_page: '500' }) }); + const { data: policies } = useQuery({ queryKey: ['renewal-policies'], queryFn: () => getPolicies({ per_page: '500' }) }); const hasAgents = (agents?.data?.length ?? 0) > 0; @@ -419,11 +429,14 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: { mutationFn: async () => { const sanList = sans.split(',').map(s => s.trim()).filter(Boolean); const cert = await createCertificate({ + name, common_name: commonName, sans: sanList, issuer_id: issuerId, certificate_profile_id: profileId || undefined, owner_id: ownerId, + team_id: teamId, + renewal_policy_id: renewalPolicyId, environment: 'production', }); // Trigger issuance @@ -465,6 +478,19 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: {
+ No owners yet — create one from the Owners page first, then return here. +
+ )} ++ No teams yet — create one from the Teams page first, then return here. +
+ )} +- No owners yet — create one from the Owners page first, then return here. + No renewal policies yet — create one from the Policies page first, then return here.
)}