package handler import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/rsa" "encoding/pem" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "github.com/certctl-io/certctl/internal/domain" "github.com/certctl-io/certctl/internal/pkcs7" "github.com/certctl-io/certctl/internal/scep/intune" "github.com/certctl-io/certctl/internal/service" ) // SCEP RFC 8894 + Intune master prompt §13 line 1851 acceptance — // "Per-profile dispatch test must prove per-profile counters in // metrics." Closed in the 2026-04-29 audit-closure bundle (Phase E). // // Why this test exists separately from the existing router-level // /scep/ dispatch test (TestRouter_RegisterSCEPHandlers_ // MultipleProfilesNoCrossBleed): that test proves the route table // doesn't bleed; this one proves the in-memory observability state // (intuneCounterTab) is per-SCEPService, not shared. The bug class // it guards against is a future cmd/server/main.go refactor that // constructs a single shared *intuneCounterTab and injects it into // every per-profile service — that would compile cleanly, pass the // existing route-table test, and silently inflate one profile's // counters with another's traffic. // TestSCEPHandler_PerProfileIntuneCountersIsolated wires two real // SCEPService instances, each with its OWN trust anchor + audience. // A success on profile "corp" MUST NOT tick "iot"'s success counter, // and vice versa for the failure path. The test constructs the // fixtures hermetically (no shared state between the two profiles // except the test's t.TempDir + selfSignedRSACert helpers). func TestSCEPHandler_PerProfileIntuneCountersIsolated(t *testing.T) { corpFix := buildPerProfileIntuneFixture(t, "corp", "https://certctl.example.com/scep/corp") iotFix := buildPerProfileIntuneFixture(t, "iot", "https://certctl.example.com/scep/iot") now := time.Now() // --- Drive a SUCCESS through CORP --- corpChallenge := signIntuneChallengeES256(t, corpFix.connectorKey, map[string]any{ "iss": "intune-connector-corp-fixture", "sub": "device-guid-corp-001", "aud": "https://certctl.example.com/scep/corp", "iat": now.Add(-1 * time.Minute).Unix(), "exp": now.Add(59 * time.Minute).Unix(), "nonce": "iso-corp-nonce-001", "device_name": "device-corp-001.example.com", }) corpMsg := buildIntuneE2EPKIMessage(t, corpFix, "txn-iso-corp", corpChallenge, "device-corp-001.example.com") w, body := postPKIOperation(t, corpFix.handler, corpMsg) if w.Code != http.StatusOK { t.Fatalf("corp success: HTTP %d (body=%q)", w.Code, body) } // --- Drive an EXPIRED challenge through IOT --- iotChallenge := signIntuneChallengeES256(t, iotFix.connectorKey, map[string]any{ "iss": "intune-connector-iot-fixture", "sub": "device-guid-iot-001", "aud": "https://certctl.example.com/scep/iot", "iat": now.Add(-2 * time.Hour).Unix(), "exp": now.Add(-1 * time.Hour).Unix(), // expired "nonce": "iso-iot-nonce-001", "device_name": "device-iot-001.example.com", }) iotMsg := buildIntuneE2EPKIMessage(t, iotFix, "txn-iso-iot", iotChallenge, "device-iot-001.example.com") w, body = postPKIOperation(t, iotFix.handler, iotMsg) if w.Code != http.StatusOK { t.Fatalf("iot expired: HTTP %d — RFC 8894 §3.3 mandates a CertRep on every PKIOperation including failures; body=%q", w.Code, body) } certRep, err := pkcs7.ParseSignedData(body) if err != nil { t.Fatalf("iot expired: ParseSignedData: %v", err) } statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()]) if statusStr != string(domain.SCEPStatusFailure) { t.Errorf("iot expired pkiStatus = %q, want FAILURE", statusStr) } // --- Assert per-service counter isolation --- corpStats := corpFix.scepService.IntuneStats(time.Now()) iotStats := iotFix.scepService.IntuneStats(time.Now()) if got, want := corpStats.PathID, "corp"; got != want { t.Errorf("corp PathID = %q, want %q", got, want) } if got, want := iotStats.PathID, "iot"; got != want { t.Errorf("iot PathID = %q, want %q", got, want) } // CORP should have exactly one success and zero of every other label. if got := corpStats.Counters["success"]; got != 1 { t.Errorf("corp.Counters[success] = %d, want 1", got) } if got := corpStats.Counters["expired"]; got != 0 { t.Errorf("corp.Counters[expired] = %d, want 0 (iot's expired traffic must NOT bleed into corp)", got) } // IOT should have exactly one expired and zero successes. if got := iotStats.Counters["expired"]; got != 1 { t.Errorf("iot.Counters[expired] = %d, want 1", got) } if got := iotStats.Counters["success"]; got != 0 { t.Errorf("iot.Counters[success] = %d, want 0 (corp's success traffic must NOT bleed into iot)", got) } // And the issuer-side state — corp's mock issuer saw the issuance, // iot's did not. This pins that the per-profile dispatch reaches // the per-profile issuer connector too (not just the counter tab). if got, want := len(corpFix.issuer.issued), 1; got != want { t.Errorf("corp issuances = %d, want %d", got, want) } if got, want := len(iotFix.issuer.issued), 0; got != want { t.Errorf("iot issuances = %d, want %d (iot's expired challenge must NOT have produced issuance)", got, want) } } // buildPerProfileIntuneFixture builds an Intune-enabled SCEPService for // the given pathID + audience, with its own freshly-generated trust // anchor + RA pair + issuer mock. Mirrors newIntuneE2EFixture but // parameterized so the per-profile-isolation test can stand up two // independent stacks side-by-side. func buildPerProfileIntuneFixture(t *testing.T, pathID, audience string) *intuneE2EFixture { t.Helper() connectorKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatalf("connector key (%s): %v", pathID, err) } connectorCert := selfSignedECCertForIntuneE2E(t, connectorKey, "intune-connector-"+pathID) dir := t.TempDir() trustPath := filepath.Join(dir, "intune-trust-"+pathID+".pem") if err := os.WriteFile(trustPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: connectorCert.Raw}), 0o600); err != nil { t.Fatalf("write trust anchor (%s): %v", pathID, err) } trustHolder, err := intune.NewTrustAnchorHolder(trustPath, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))) if err != nil { t.Fatalf("NewTrustAnchorHolder (%s): %v", pathID, err) } raKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("ra key (%s): %v", pathID, err) } raCert := selfSignedRSACert(t, raKey, "ra-iso-"+pathID) caKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("ca key (%s): %v", pathID, err) } caCert := selfSignedRSACert(t, caKey, "test-fixture-ca-"+pathID) caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}) issuer := &intuneE2EIssuerConnector{ caPEM: string(caPEM), signKey: caKey, caCert: caCert, } auditRepo := &intuneE2EAuditRepo{} auditSvc := service.NewAuditService(auditRepo) logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10})) scepSvc := service.NewSCEPService("iss-"+pathID, issuer, auditSvc, logger, "static-fallback-"+pathID) scepSvc.SetPathID(pathID) scepSvc.SetIntuneIntegration( trustHolder, audience, 60*time.Minute, 0, // ClockSkewTolerance — strict intune.NewReplayCache(60*time.Minute, 100), intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100), ) deviceKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("device key (%s): %v", pathID, err) } deviceCert := selfSignedRSACert(t, deviceKey, "device-iso-"+pathID) handler := NewSCEPHandler(scepSvc) handler.SetRAPair(raCert, raKey) return &intuneE2EFixture{ connectorKey: connectorKey, connectorDir: dir, trustPath: trustPath, trustHolder: trustHolder, raKey: raKey, raCert: raCert, deviceKey: deviceKey, deviceCert: deviceCert, issuer: issuer, auditRepo: auditRepo, scepService: scepSvc, handler: handler, } } // silence unused-import for httptest (only needed if a future test in // this file constructs requests directly — kept here to avoid a // goimports-driven churn the next time the file gains a test). var _ = httptest.NewRecorder