Files
certctl/internal/api/handler/admin_scep_intune_test.go
T
shankar0123 8b75e0311b chore: rename Go module path to github.com/certctl-io/certctl
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.

Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.

Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).

Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.

Diff shape:
  361 *.go files  — import path replacement only
    2 go.mod     — module declaration replacement only
    1 binary     — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
                   so embedded build-info reflects the new path (8618965 vs
                   8618933 bytes; 32-byte diff is the build-info change)

  Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
  mechanical substitution.

Verification:
  gofmt: 17 files needed re-alignment after sed (the new path is one char
    shorter than the old, so column-aligned import groups drifted). Applied
    `gofmt -w` to fix.
  go mod tidy: clean exit on both modules.
  go vet ./...: clean exit.
  go build ./...: clean exit.
  go test -short -count=1 on representative packages: all green
    (internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
    cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
    confirming the module path resolves correctly.
  binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
    nothing; `strings | grep certctl-io/certctl` shows the new module path
    embedded in build-info.

Files intentionally NOT touched in this commit:
  README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
    URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
    purely the Go-tooling layer.
  Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
    namespace, not a Go import or GitHub repo URL. Stays.

This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
2026-05-04 00:30:29 +00:00

496 lines
17 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/service"
)
// fakeAdminSCEPIntuneService is the test stub for AdminSCEPIntuneService.
// Records call observations so the M-008 admin-gate triplet can pin
// "service was never invoked" when the gate rejects the caller.
type fakeAdminSCEPIntuneService struct {
statsCalled bool
profilesCalled bool
reloadCalled bool
rows []service.IntuneStatsSnapshot
profileRows []service.SCEPProfileStatsSnapshot
statsErr error
profilesErr error
reloadPathID string
reloadErr error
}
func (f *fakeAdminSCEPIntuneService) Stats(_ context.Context, _ time.Time) ([]service.IntuneStatsSnapshot, error) {
f.statsCalled = true
return f.rows, f.statsErr
}
func (f *fakeAdminSCEPIntuneService) Profiles(_ context.Context, _ time.Time) ([]service.SCEPProfileStatsSnapshot, error) {
f.profilesCalled = true
return f.profileRows, f.profilesErr
}
func (f *fakeAdminSCEPIntuneService) ReloadTrust(_ context.Context, pathID string) error {
f.reloadCalled = true
f.reloadPathID = pathID
return f.reloadErr
}
// =============================================================================
// M-008 admin-gate triplet for Stats (GET).
// =============================================================================
func TestAdminSCEPIntune_NonAdmin_Returns403(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
w := httptest.NewRecorder()
h.Stats(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for non-admin, got %d (body=%q)", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
msg, _ := resp["message"].(string)
if !strings.Contains(strings.ToLower(msg), "admin") {
t.Errorf("expected message to mention admin requirement, got %q", msg)
}
if svc.statsCalled {
t.Errorf("service was invoked despite non-admin caller — gate failed open")
}
}
func TestAdminSCEPIntune_AdminExplicitFalse_Returns403(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Stats(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for admin=false, got %d", w.Code)
}
if svc.statsCalled {
t.Error("service called despite admin=false gate")
}
}
func TestAdminSCEPIntune_AdminPermitted_ForwardsActor(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{
rows: []service.IntuneStatsSnapshot{
{PathID: "corp", IssuerID: "iss-corp", Enabled: true},
{PathID: "iot", IssuerID: "iss-iot", Enabled: false},
},
}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Stats(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for admin caller, got %d (body=%q)", w.Code, w.Body.String())
}
if !svc.statsCalled {
t.Fatal("service was not invoked for admin caller")
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if pc, ok := resp["profile_count"].(float64); !ok || pc != 2 {
t.Errorf("profile_count = %v, want 2", resp["profile_count"])
}
if _, ok := resp["profiles"].([]any); !ok {
t.Errorf("profiles missing or wrong shape: %v", resp["profiles"])
}
}
// =============================================================================
// M-008 triplet for ReloadTrust (POST).
// =============================================================================
func TestAdminSCEPIntuneReload_NonAdmin_Returns403(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"corp"}`))
req.ContentLength = int64(len(`{"path_id":"corp"}`))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 non-admin, got %d", w.Code)
}
if svc.reloadCalled {
t.Error("service called despite non-admin gate")
}
}
func TestAdminSCEPIntuneReload_AdminExplicitFalse_Returns403(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"corp"}`))
req.ContentLength = int64(len(`{"path_id":"corp"}`))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 admin=false, got %d", w.Code)
}
if svc.reloadCalled {
t.Error("service called despite admin=false gate")
}
}
func TestAdminSCEPIntuneReload_AdminPermitted_ForwardsActor(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
body := `{"path_id":"corp"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body=%q)", w.Code, w.Body.String())
}
if !svc.reloadCalled {
t.Fatal("reload was not invoked")
}
if svc.reloadPathID != "corp" {
t.Errorf("path_id forwarded = %q, want corp", svc.reloadPathID)
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
if reloaded, _ := resp["reloaded"].(bool); !reloaded {
t.Errorf("response.reloaded = %v, want true", resp["reloaded"])
}
}
// =============================================================================
// Endpoint behavior — method gates, error mapping, body parsing.
// =============================================================================
func TestAdminSCEPIntuneStats_RejectsNonGetMethod(t *testing.T) {
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/stats", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Stats(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405 for POST, got %d", w.Code)
}
}
func TestAdminSCEPIntuneReload_RejectsNonPostMethod(t *testing.T) {
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/reload-trust", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405 for GET, got %d", w.Code)
}
}
func TestAdminSCEPIntuneStats_PropagatesServiceError(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{statsErr: errors.New("registry walk failed")}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Stats(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on service error, got %d", w.Code)
}
}
func TestAdminSCEPIntuneReload_ProfileNotFound_Returns404(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{reloadErr: ErrAdminSCEPProfileNotFound}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"nonexistent"}`))
req.ContentLength = int64(len(`{"path_id":"nonexistent"}`))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for unknown profile, got %d", w.Code)
}
}
func TestAdminSCEPIntuneReload_IntuneDisabled_Returns409(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{reloadErr: service.ErrSCEPProfileIntuneDisabled}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"iot"}`))
req.ContentLength = int64(len(`{"path_id":"iot"}`))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusConflict {
t.Errorf("expected 409 for Intune-disabled profile, got %d", w.Code)
}
}
func TestAdminSCEPIntuneReload_BadReloadPropagates500(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{reloadErr: errors.New("trust anchor cert expired")}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"corp"}`))
req.ContentLength = int64(len(`{"path_id":"corp"}`))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on bad reload, got %d", w.Code)
}
}
func TestAdminSCEPIntuneReload_EmptyBodyTargetsLegacyRoot(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 with empty body (legacy root path), got %d", w.Code)
}
if svc.reloadPathID != "" {
t.Errorf("empty body should target empty PathID; got %q", svc.reloadPathID)
}
}
func TestAdminSCEPIntuneReload_RejectsMalformedJSON(t *testing.T) {
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
bad := `{not valid json`
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(bad))
req.ContentLength = int64(len(bad))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 on malformed JSON, got %d", w.Code)
}
}
// =============================================================================
// AdminSCEPIntuneServiceImpl — narrow integration with the per-profile map.
// =============================================================================
func TestAdminSCEPIntuneServiceImpl_NilMapReturnsEmpty(t *testing.T) {
impl := NewAdminSCEPIntuneServiceImpl(nil)
rows, err := impl.Stats(context.Background(), time.Now())
if err != nil {
t.Fatalf("nil-map Stats: %v", err)
}
if len(rows) != 0 {
t.Errorf("nil-map Stats len=%d, want 0", len(rows))
}
}
func TestAdminSCEPIntuneServiceImpl_ReloadUnknownPathReturnsNotFound(t *testing.T) {
impl := NewAdminSCEPIntuneServiceImpl(map[string]*service.SCEPService{})
if err := impl.ReloadTrust(context.Background(), "nope"); !errors.Is(err, ErrAdminSCEPProfileNotFound) {
t.Errorf("ReloadTrust unknown = %v, want ErrAdminSCEPProfileNotFound", err)
}
}
// =============================================================================
// M-008 admin-gate triplet for Profiles (GET) — Phase 9 follow-up endpoint.
// =============================================================================
func TestAdminSCEPProfiles_NonAdmin_Returns403(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
w := httptest.NewRecorder()
h.Profiles(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for non-admin, got %d (body=%q)", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
msg, _ := resp["message"].(string)
if !strings.Contains(strings.ToLower(msg), "admin") {
t.Errorf("expected message to mention admin requirement, got %q", msg)
}
if svc.profilesCalled {
t.Errorf("service was invoked despite non-admin caller — gate failed open")
}
}
func TestAdminSCEPProfiles_AdminExplicitFalse_Returns403(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for admin=false, got %d", w.Code)
}
if svc.profilesCalled {
t.Error("service called despite admin=false gate")
}
}
func TestAdminSCEPProfiles_AdminPermitted_ForwardsActor(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{
profileRows: []service.SCEPProfileStatsSnapshot{
{
PathID: "corp",
IssuerID: "iss-corp",
ChallengePasswordSet: true,
MTLSEnabled: true,
Intune: &service.IntuneSection{
Audience: "https://certctl.example.com/scep/corp",
},
},
{
PathID: "iot",
IssuerID: "iss-iot",
ChallengePasswordSet: true,
// Intune nil — disabled
},
},
}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for admin caller, got %d (body=%q)", w.Code, w.Body.String())
}
if !svc.profilesCalled {
t.Fatal("service was not invoked for admin caller")
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if pc, ok := resp["profile_count"].(float64); !ok || pc != 2 {
t.Errorf("profile_count = %v, want 2", resp["profile_count"])
}
rows, ok := resp["profiles"].([]any)
if !ok || len(rows) != 2 {
t.Fatalf("profiles missing or wrong shape: %v", resp["profiles"])
}
// Find the Intune-enabled vs Intune-disabled row by path_id and
// assert the Intune sub-block is present/absent accordingly.
for _, raw := range rows {
row := raw.(map[string]any)
switch row["path_id"] {
case "corp":
if _, has := row["intune"]; !has {
t.Errorf("expected corp profile to carry an intune sub-block")
}
case "iot":
if _, has := row["intune"]; has {
t.Errorf("expected iot profile to OMIT the intune sub-block (Intune disabled)")
}
}
}
}
func TestAdminSCEPProfiles_RejectsNonGetMethod(t *testing.T) {
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405 for POST, got %d", w.Code)
}
}
func TestAdminSCEPProfiles_PropagatesServiceError(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{profilesErr: errors.New("registry walk failed")}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on service error, got %d", w.Code)
}
}
func TestAdminSCEPProfilesServiceImpl_NilMapReturnsEmpty(t *testing.T) {
impl := NewAdminSCEPIntuneServiceImpl(nil)
rows, err := impl.Profiles(context.Background(), time.Now())
if err != nil {
t.Fatalf("nil-map Profiles: %v", err)
}
if len(rows) != 0 {
t.Errorf("nil-map Profiles len=%d, want 0", len(rows))
}
}