mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:51:30 +00:00
0e06f6c4fc
Closes findings P3-1 and P3-2 from the 2026-05-05 CLI/API/MCP↔GUI parity
audit (cowork/cli-gui-parity-audit-2026-05-05/RESULTS.md). Both findings
flagged hidden defaults that the CLI was sending without exposing them
to operators: `force=false` baked into every renew payload, and a silent
fallback to `reason="unspecified"` whenever --reason was omitted.
P3-1 — promote --force on `certs renew` (full end-to-end plumbing)
The pre-2026-05-05 CLI sent `{"force": false}` in the renew body. The
API handler never decoded it — a textbook "lying field" per the
operator's CLAUDE.md "complete path, not the easy path" rule: the body
field stored a value, claimed to do something, and silently did nothing
because the wire never reached the consumer. Adding a --force flag that
also went unread would have created another lying field.
This commit takes the complete path:
service.CertificateService.TriggerRenewal grew a `force bool` parameter
(internal/service/certificate.go). When force=true, the
RenewalInProgress block is overridden so operators can recover stuck
in-flight renewals where a previous job hung without releasing the
status flag. Archived and Expired remain terminal blockers regardless
of force — those are semantic dead-ends that --force should not paper
over (archived = decommissioned, expired = issue a new cert instead of
renewing a dead one).
handler.CertificateHandler.TriggerRenewal parses force from
?force=true (or ?force=1) query param, OR {"force": true} JSON body,
whichever the client picks. Defaults to false. Passes through to the
service.
internal/cli/client.go::RenewCertificate(id, force bool) sends
?force=true on the URL when --force is set. The historical hardcoded
`{"force": false}` body is gone — no more lying field.
cmd/cli/main.go dispatches `certs renew <id> [--force]` (ID-first
flag-second convention matches the existing `agents retire <id>
[--force]`).
P3-2 — require --reason on `certs revoke` (Option A: strict refusal)
The pre-2026-05-05 CLI dropped to `--reason unspecified` whenever the
operator omitted the flag. Compliance reporting (RFC 5280 §5.3.1, PCI-
DSS §3.6, HIPAA §164.312) relies on the reason code being meaningful;
silent fallback defeats the audit trail because every revocation looks
identical.
cmd/cli/main.go dispatch refuses to send when --reason is empty,
prints the canonical RFC 5280 §5.3.1 reason-code menu, and exits
non-zero.
internal/cli/client.go exposes ValidRevokeReasons() returning the
canonical camelCase list (unspecified, keyCompromise, caCompromise,
affiliationChanged, superseded, cessationOfOperation, certificateHold,
removeFromCRL, privilegeWithdrawn, aaCompromise) and
NormalizeRevokeReason() that accepts both camelCase and snake_case
inputs and normalises to the canonical wire form. Off-list reasons
are rejected at dispatch with the menu re-printed.
Test pins:
internal/cli/client_test.go::TestClient_RenewCertificate_ForceFlag —
--force=true sends ?force=true with empty body; --force=false sends
no query and no body.
internal/cli/client_test.go::TestNormalizeRevokeReason +
TestValidRevokeReasons — canonical-camelCase + snake_case + reject-
off-enum behaviour.
cmd/cli/dispatch_test.go::TestHandleCerts_Revoke_RequiresReason +
TestHandleCerts_Revoke_RejectsUnknownReason +
TestHandleCerts_Renew_ForceFlag — dispatch-layer pins for the same
contracts.
internal/api/handler/certificate_handler_test.go::TestTriggerRenewal_
ForceQueryParam — query-param passthrough (no-flag, force=true,
force=1, force=false) flows through to the service-layer parameter.
internal/service/certificate_test.go::TestTriggerRenewal_
ForceOverridesInProgress — force=false preserves the
RenewalInProgress block; force=true clears it.
Existing TestTriggerRenewal_Archived extended to assert force=true
still blocks Archived (terminal-state guarantee).
Docs: docs/reference/cli.md updated with the --force example for renew
and the strict --reason semantics for revoke (including snake_case
input acceptance).
Acceptance gate (verified):
- go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/...
./cmd/mcp-server/... clean.
- go vet ./... clean.
- go test -short -count=1 ./... pass repo-wide.
- bash scripts/ci-guards/openapi-handler-parity.sh clean
(router 178, OpenAPI 144, exceptions 36 — unchanged; we add
parameter parsing, not routes).
- gofmt -l clean.
508 lines
19 KiB
Go
508 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/certctl-io/certctl/internal/cli"
|
|
)
|
|
|
|
// Bundle Q (L-001 closure): per-subcommand dispatch tests for cmd/cli/main.go.
|
|
//
|
|
// The existing `main_test.go` only covered `validateHTTPSScheme`. This file
|
|
// pins every dispatch arm in `handleCerts`, `handleAgents`, `handleJobs`,
|
|
// `handleImport`, `handleStatus` — both the "missing arg" usage prints and
|
|
// the happy-path delegation to `*cli.Client`.
|
|
//
|
|
// Strategy: spin up an `httptest.Server` mocking the relevant API routes so
|
|
// the client can exercise its end-to-end code path without a live server.
|
|
// For arms that print usage and return without calling the client, we pass
|
|
// a freshly-constructed client (still no network call — the client method
|
|
// is never invoked).
|
|
|
|
// newDispatchTestClient returns a `*cli.Client` pointed at the given test
|
|
// server. Calls `t.Fatal` on construction error.
|
|
func newDispatchTestClient(t *testing.T, server *httptest.Server) *cli.Client {
|
|
t.Helper()
|
|
// Configure the client with `insecure=true` because httptest.Server's
|
|
// self-signed TLS cert won't chain to a system root.
|
|
c, err := cli.NewClient(server.URL, "test-key", "json", "", true)
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// stubServer returns an httptest.Server (TLS) that responds with the given
|
|
// JSON body and status code for any request. Tests that want to assert on
|
|
// the request shape can wrap it in a more specific handler.
|
|
func stubServer(t *testing.T, status int, body string) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// handleCerts dispatch arms
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestHandleCerts_NoArgs_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{"data":[],"total":0}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleCerts(c, []string{}); err != nil {
|
|
t.Errorf("handleCerts({}): unexpected err=%v (should print usage and return nil)", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleCerts_UnknownSubcommand_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{"data":[],"total":0}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleCerts(c, []string{"frobnicate"}); err != nil {
|
|
t.Errorf("handleCerts({frobnicate}): unexpected err=%v (should print usage and return nil)", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleCerts_GetWithoutID_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleCerts(c, []string{"get"}); err != nil {
|
|
t.Errorf("handleCerts({get}): unexpected err=%v (should print usage and return nil)", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleCerts_RenewWithoutID_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleCerts(c, []string{"renew"}); err != nil {
|
|
t.Errorf("handleCerts({renew}): unexpected err=%v (should print usage and return nil)", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleCerts_RevokeWithoutID_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleCerts(c, []string{"revoke"}); err != nil {
|
|
t.Errorf("handleCerts({revoke}): unexpected err=%v (should print usage and return nil)", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleCerts_List_HitsClientPath(t *testing.T) {
|
|
// Asserts dispatch-path: handleCerts → c.ListCertificates → GET /api/v1/certificates.
|
|
var hits int
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits++
|
|
if r.Method != "GET" || !strings.HasPrefix(r.URL.Path, "/api/v1/certificates") {
|
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
}
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleCerts(c, []string{"list"}); err != nil {
|
|
t.Errorf("handleCerts({list}): err=%v", err)
|
|
}
|
|
if hits != 1 {
|
|
t.Errorf("expected 1 server hit, got %d", hits)
|
|
}
|
|
}
|
|
|
|
func TestHandleCerts_Get_HitsClientPath(t *testing.T) {
|
|
var lastPath string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastPath = r.URL.Path
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"id":"mc-x","name":"x"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleCerts(c, []string{"get", "mc-x"}); err != nil {
|
|
t.Errorf("handleCerts({get, mc-x}): err=%v", err)
|
|
}
|
|
if !strings.Contains(lastPath, "/api/v1/certificates/mc-x") {
|
|
t.Errorf("expected GET on /api/v1/certificates/mc-x, got %q", lastPath)
|
|
}
|
|
}
|
|
|
|
func TestHandleCerts_Renew_HitsClientPath(t *testing.T) {
|
|
var lastPath, lastMethod string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastPath = r.URL.Path
|
|
lastMethod = r.Method
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"job_id":"job-1","status":"ok"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleCerts(c, []string{"renew", "mc-x"}); err != nil {
|
|
t.Errorf("handleCerts({renew, mc-x}): err=%v", err)
|
|
}
|
|
if lastMethod != "POST" || !strings.Contains(lastPath, "/renew") {
|
|
t.Errorf("expected POST .../renew, got %s %s", lastMethod, lastPath)
|
|
}
|
|
}
|
|
|
|
func TestHandleCerts_Revoke_HitsClientPath(t *testing.T) {
|
|
var lastPath, lastMethod, lastBody string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastPath = r.URL.Path
|
|
lastMethod = r.Method
|
|
buf := make([]byte, 1024)
|
|
n, _ := r.Body.Read(buf)
|
|
lastBody = string(buf[:n])
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"status":"revoked"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
// 2026-05-05 parity-defaults-cleanup (P3-2): reason must be a canonical
|
|
// RFC 5280 §5.3.1 code (camelCase or snake_case both accepted; this
|
|
// test asserts the snake_case path normalises to the camelCase wire
|
|
// format that the local issuer + ACME server expect).
|
|
if err := handleCerts(c, []string{"revoke", "mc-x", "--reason", "key_compromise"}); err != nil {
|
|
t.Errorf("handleCerts({revoke ...}): err=%v", err)
|
|
}
|
|
if lastMethod != "POST" || !strings.Contains(lastPath, "/revoke") {
|
|
t.Errorf("expected POST .../revoke, got %s %s", lastMethod, lastPath)
|
|
}
|
|
if !strings.Contains(lastBody, "keyCompromise") {
|
|
t.Errorf("expected normalised reason 'keyCompromise' in body, got %q", lastBody)
|
|
}
|
|
}
|
|
|
|
// TestHandleCerts_Revoke_RequiresReason pins the 2026-05-05 parity-defaults-
|
|
// cleanup (P3-2, Option A) strict-reason contract: empty --reason is a
|
|
// fatal error, not a silent fallback to "unspecified".
|
|
func TestHandleCerts_Revoke_RequiresReason(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
err := handleCerts(c, []string{"revoke", "mc-x"})
|
|
if err == nil {
|
|
t.Fatal("expected error when --reason is omitted; got nil (regression on P3-2 strict path)")
|
|
}
|
|
if !strings.Contains(err.Error(), "reason") {
|
|
t.Errorf("expected error to mention 'reason', got %q", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestHandleCerts_Revoke_RejectsUnknownReason pins that off-RFC reason
|
|
// codes are rejected at the CLI dispatch layer (P3-2 anti-typo guard).
|
|
func TestHandleCerts_Revoke_RejectsUnknownReason(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
err := handleCerts(c, []string{"revoke", "mc-x", "--reason", "compromise"})
|
|
if err == nil {
|
|
t.Fatal("expected error for non-canonical reason; got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "compromise") {
|
|
t.Errorf("expected error to echo bad reason 'compromise', got %q", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestHandleCerts_Renew_ForceFlag pins the 2026-05-05 parity-defaults-
|
|
// cleanup (P3-1) wire: --force on the renew dispatch sends ?force=true.
|
|
// CLI convention: ID is positional and precedes the flags (matches
|
|
// `agents retire <id> [--force]`), so the flag MUST come after the ID.
|
|
func TestHandleCerts_Renew_ForceFlag(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
args []string
|
|
wantQuery string
|
|
}{
|
|
{"no-force", []string{"renew", "mc-x"}, ""},
|
|
{"force-after-id", []string{"renew", "mc-x", "--force"}, "force=true"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var lastQuery string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastQuery = r.URL.RawQuery
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleCerts(c, tc.args); err != nil {
|
|
t.Fatalf("handleCerts: %v", err)
|
|
}
|
|
if lastQuery != tc.wantQuery {
|
|
t.Errorf("query: got %q want %q", lastQuery, tc.wantQuery)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleCerts_BulkRevoke_HitsClientPath(t *testing.T) {
|
|
var lastPath string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastPath = r.URL.Path
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"total_matched":0,"total_revoked":0,"total_skipped":0,"total_failed":0}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleCerts(c, []string{"bulk-revoke", "--reason", "test"}); err != nil {
|
|
t.Errorf("handleCerts({bulk-revoke ...}): err=%v", err)
|
|
}
|
|
if !strings.Contains(lastPath, "/bulk-revoke") {
|
|
t.Errorf("expected /bulk-revoke path, got %q", lastPath)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// handleAgents dispatch arms
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestHandleAgents_NoArgs_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleAgents(c, []string{}); err != nil {
|
|
t.Errorf("handleAgents({}): unexpected err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgents_UnknownSubcommand_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleAgents(c, []string{"frobnicate"}); err != nil {
|
|
t.Errorf("handleAgents({frobnicate}): unexpected err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgents_GetWithoutID_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleAgents(c, []string{"get"}); err != nil {
|
|
t.Errorf("handleAgents({get}): unexpected err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgents_RetireWithoutID_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleAgents(c, []string{"retire"}); err != nil {
|
|
t.Errorf("handleAgents({retire}): unexpected err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgents_List_HitsClientPath(t *testing.T) {
|
|
var lastPath string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastPath = r.URL.Path
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleAgents(c, []string{"list"}); err != nil {
|
|
t.Errorf("handleAgents({list}): err=%v", err)
|
|
}
|
|
if !strings.Contains(lastPath, "/api/v1/agents") {
|
|
t.Errorf("expected /api/v1/agents path, got %q", lastPath)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgents_ListRetired_HitsRetiredEndpoint(t *testing.T) {
|
|
// I-004: --retired flag splits to a separate /agents/retired endpoint.
|
|
var lastPath string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastPath = r.URL.Path
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleAgents(c, []string{"list", "--retired"}); err != nil {
|
|
t.Errorf("handleAgents({list --retired}): err=%v", err)
|
|
}
|
|
if !strings.Contains(lastPath, "/agents/retired") {
|
|
t.Errorf("expected --retired to hit /agents/retired, got %q", lastPath)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgents_Get_HitsClientPath(t *testing.T) {
|
|
var lastPath string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastPath = r.URL.Path
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"id":"ag-x","status":"online"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleAgents(c, []string{"get", "ag-x"}); err != nil {
|
|
t.Errorf("handleAgents({get, ag-x}): err=%v", err)
|
|
}
|
|
if !strings.Contains(lastPath, "/agents/ag-x") {
|
|
t.Errorf("expected /agents/ag-x, got %q", lastPath)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// handleJobs dispatch arms
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestHandleJobs_NoArgs_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleJobs(c, []string{}); err != nil {
|
|
t.Errorf("handleJobs({}): unexpected err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleJobs_UnknownSubcommand_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleJobs(c, []string{"frobnicate"}); err != nil {
|
|
t.Errorf("handleJobs({frobnicate}): unexpected err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleJobs_GetWithoutID_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleJobs(c, []string{"get"}); err != nil {
|
|
t.Errorf("handleJobs({get}): unexpected err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleJobs_CancelWithoutID_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleJobs(c, []string{"cancel"}); err != nil {
|
|
t.Errorf("handleJobs({cancel}): unexpected err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleJobs_List_HitsClientPath(t *testing.T) {
|
|
var lastPath string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastPath = r.URL.Path
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleJobs(c, []string{"list"}); err != nil {
|
|
t.Errorf("handleJobs({list}): err=%v", err)
|
|
}
|
|
if !strings.Contains(lastPath, "/api/v1/jobs") {
|
|
t.Errorf("expected /api/v1/jobs path, got %q", lastPath)
|
|
}
|
|
}
|
|
|
|
func TestHandleJobs_Get_HitsClientPath(t *testing.T) {
|
|
var lastPath string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastPath = r.URL.Path
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"id":"job-x"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleJobs(c, []string{"get", "job-x"}); err != nil {
|
|
t.Errorf("handleJobs({get, job-x}): err=%v", err)
|
|
}
|
|
if !strings.Contains(lastPath, "/jobs/job-x") {
|
|
t.Errorf("expected /jobs/job-x, got %q", lastPath)
|
|
}
|
|
}
|
|
|
|
func TestHandleJobs_Cancel_HitsClientPath(t *testing.T) {
|
|
var lastPath, lastMethod string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastPath = r.URL.Path
|
|
lastMethod = r.Method
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"status":"cancelled"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleJobs(c, []string{"cancel", "job-x"}); err != nil {
|
|
t.Errorf("handleJobs({cancel, job-x}): err=%v", err)
|
|
}
|
|
if lastMethod != "POST" || !strings.Contains(lastPath, "/cancel") {
|
|
t.Errorf("expected POST .../cancel, got %s %s", lastMethod, lastPath)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// handleImport / handleStatus dispatch arms
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestHandleImport_NoArgs_PrintsUsage(t *testing.T) {
|
|
srv := stubServer(t, 200, `{}`)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleImport(c, []string{}); err != nil {
|
|
t.Errorf("handleImport({}): unexpected err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleStatus_HitsClientPath(t *testing.T) {
|
|
var lastPath string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastPath = r.URL.Path
|
|
w.WriteHeader(200)
|
|
// GetStatus expects {"status":..., "stats":...} or similar.
|
|
// Provide a minimal valid JSON object.
|
|
_, _ = w.Write([]byte(`{"status":"healthy","version":"v2.X","db":"connected"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newDispatchTestClient(t, srv)
|
|
if err := handleStatus(c); err != nil {
|
|
// GetStatus's table output may complain about missing fields; we only
|
|
// care that the dispatch arm fired and the request reached the server.
|
|
_ = err
|
|
}
|
|
if lastPath == "" {
|
|
t.Errorf("expected handleStatus to make at least one request")
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// CLI client TLS sanity (Q.1: confirms NewClient configures TLS correctly).
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestCliClient_RejectsUntrustedCert_WhenNotInsecure(t *testing.T) {
|
|
// Without insecure=true, the self-signed httptest cert must fail TLS
|
|
// verification. This pins the security default.
|
|
srv := stubServer(t, 200, `{}`)
|
|
c, err := cli.NewClient(srv.URL, "k", "json", "", false)
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
// Try a status call — should error out with a TLS verification failure,
|
|
// not silently succeed.
|
|
if err := c.GetStatus(); err == nil {
|
|
t.Errorf("expected TLS verification error against self-signed cert; got nil")
|
|
}
|
|
}
|
|
|
|
// TestCliClient_ParsesJSONResponse asserts the do() path's JSON unmarshalling
|
|
// succeeds end-to-end (one of the more error-prone paths in the client).
|
|
func TestCliClient_ParsesJSONResponse(t *testing.T) {
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
body := map[string]interface{}{
|
|
"data": []map[string]interface{}{{"id": "mc-1", "name": "site-1"}},
|
|
"total": 1,
|
|
}
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c, err := cli.NewClient(srv.URL, "k", "json", "", true)
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
if err := c.ListCertificates(nil); err != nil {
|
|
t.Errorf("ListCertificates: err=%v", err)
|
|
}
|
|
}
|