From cec3bc7b874ed02b17a4c105c50bd03b889af606 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Tue, 28 Jul 2026 16:54:49 -0400 Subject: [PATCH 1/3] feat(licensing): gate the auditor attestation artifact behind compliance_attestation The founder named "a signed OSCAL report for a compliance officer" as a moat item, meaning something OpenWatch monetizes. Every report route was ungated: all twelve, including /reports/{id}/export and the fleet OSCAL SAR face. That collided with the opencore roadmap's LOCKED tiering rule, which names reports explicitly among things that "stay free" because they already shipped free. Honoring the lock literally would have made the clearest monetization case permanently unmonetizable; ignoring it would revoke a shipped capability. Resolved by splitting rather than revoking, and the axis matters. Scope was the obvious choice and is wrong: an absent scope body means all hosts and all frameworks, so a scope-based gate would have gated the default executive summary, which is the everyday free artifact. The line is the report KIND. free executive kind at ANY scope; per-scan and per-host OSCAL (/scans/{id}/oscal, /scans/{id}/rules/{ruleId}/oscal); the signing key; report list and read enterprise the attestation kind Kind is also exact rather than approximate: report.FaceOSCALSAR is attestation-only (exportFleetOSCALSAR), so gating this one kind gates the fleet OSCAL SAR and nothing else. A Community user keeps a free path to OSCAL evidence per scan; what is paid is the fleet-wide signed package filed with an auditor. Gated at four sites, because any one alone is theatre: - generation, on the requested kind - export, on the STORED report's kind, or every attestation already in the database stays freely exportable - schedule creation, or the artifact is minted on a timer - the dispatcher at FIRE time, or a schedule created while licensed keeps producing the paid artifact after the licence lapses Each of the four was negative-tested independently by neutering it while keeping the build valid, so the assertion fires rather than the compiler. The locked tiering rule is amended in opencore/ROADMAP.md rather than quietly bent. A locked rule that gets silently excepted stops being a rule. Registry-first: compliance_attestation added to licensing/features.yaml and codegen'd before any route referenced it. Enterprise is now 9 flags; the free set is unchanged, so system-license-features AC-13 still pins it. Spec: api-reports 1.14.0 -> 1.15.0, C-19 and AC-25. Two id collisions caught by the duplicate check before landing: C-09 already existed, and the renumbering sed initially repointed AC-11's reference away from it. specter's orphan_constraint gate caught the second. 116 specs, 116 passing. gofmt, go vet, go build, full go test ./internal/... --- internal/license/features.gen.go | 9 +++ internal/license/features_test.go | 5 +- internal/reportschedule/dispatcher.go | 14 ++++ internal/server/api_reports_test.go | 77 +++++++++++++++++++++ internal/server/report_handlers.go | 41 +++++++++++ internal/server/report_schedule_handlers.go | 9 +++ licensing/features.yaml | 12 ++++ specs/api/reports.spec.yaml | 32 ++++++++- 8 files changed, 196 insertions(+), 3 deletions(-) diff --git a/internal/license/features.gen.go b/internal/license/features.gen.go index 7ab7dece..e1164bfb 100644 --- a/internal/license/features.gen.go +++ b/internal/license/features.gen.go @@ -33,6 +33,8 @@ const ( StructuredExceptions Feature = "structured_exceptions" // Early access to Kensa rule updates and framework mappings PriorityUpdates Feature = "priority_updates" + // Fleet-wide signed attestation reports and the fleet OSCAL SAR export + ComplianceAttestation Feature = "compliance_attestation" // SAML 2.0 single sign-on integration SsoSaml Feature = "sso_saml" // FIDO2 / WebAuthn second factor for user authentication @@ -99,6 +101,12 @@ var FeatureRegistry = map[Feature]FeatureMeta{ Description: `Early access to Kensa rule updates and framework mappings`, Introduced: "1.0.0", }, + ComplianceAttestation: { + ID: ComplianceAttestation, + Tier: TierEnterprise, + Description: `Fleet-wide signed attestation reports and the fleet OSCAL SAR export`, + Introduced: "1.0.0", + }, SsoSaml: { ID: SsoSaml, Tier: TierFree, @@ -135,6 +143,7 @@ var featureOrder = []Feature{ RemediationAuto, StructuredExceptions, PriorityUpdates, + ComplianceAttestation, SsoSaml, Fido2Mfa, PremiumDiagnostics, diff --git a/internal/license/features_test.go b/internal/license/features_test.go index 085e9c9e..5defa6ad 100644 --- a/internal/license/features_test.go +++ b/internal/license/features_test.go @@ -59,14 +59,15 @@ func TestCodegen_FeatureConstants(t *testing.T) { SsoSaml, Fido2Mfa, PremiumDiagnostics, + ComplianceAttestation, } for _, f := range mustExist { if _, ok := FeatureRegistry[f]; !ok { t.Errorf("FeatureRegistry missing %q", f) } } - if len(FeatureRegistry) != 11 { - t.Errorf("FeatureRegistry size = %d, want 11", len(FeatureRegistry)) + if len(FeatureRegistry) != 12 { + t.Errorf("FeatureRegistry size = %d, want 12", len(FeatureRegistry)) } }) } diff --git a/internal/reportschedule/dispatcher.go b/internal/reportschedule/dispatcher.go index ccf6ce8d..e2a7b4f5 100644 --- a/internal/reportschedule/dispatcher.go +++ b/internal/reportschedule/dispatcher.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" + "github.com/Hanalyx/openwatch/internal/license" "github.com/Hanalyx/openwatch/internal/report" ) @@ -65,7 +66,20 @@ func (d *Dispatcher) Tick(ctx context.Context) error { } // run generates the scheduled report, renders its PDF face, and emails it. +// +// The licence is checked HERE, at fire time, not only when the schedule was +// created. A schedule created while licensed would otherwise keep minting the +// paid attestation artifact on a timer after the licence lapsed, which would +// make the gate on the HTTP paths theatre. Skipping is deliberate rather than +// failing: an unlicensed attestation schedule is inert, not broken, and the +// operator sees it in the log. func (d *Dispatcher) run(ctx context.Context, sch Schedule) error { + if report.Kind(sch.Kind) == report.KindAttestation && !license.IsEnabled(license.ComplianceAttestation) { + slog.WarnContext(ctx, "skipping scheduled attestation report: not licensed", + slog.String("schedule_id", sch.ID.String()), + slog.String("feature", string(license.ComplianceAttestation))) + return nil + } req := report.GenerateRequest{ Kind: report.Kind(sch.Kind), GroupID: sch.Scope.GroupID, diff --git a/internal/server/api_reports_test.go b/internal/server/api_reports_test.go index 28621ac2..a242c0af 100644 --- a/internal/server/api_reports_test.go +++ b/internal/server/api_reports_test.go @@ -11,6 +11,8 @@ import ( "encoding/base64" "encoding/json" "net/http" + "os" + "strings" "testing" "github.com/Hanalyx/openwatch/internal/auth" @@ -121,3 +123,78 @@ func TestAPI_ReportFrameworks(t *testing.T) { } }) } + +// @ac AC-25 +// AC-25: the auditor artifact is licensed; seeing your own posture is not. +// +// Source inspection, because the gate spans four call sites and the failure +// mode is one of them being missed: generating an attestation, exporting a +// stored attestation, scheduling one, and the dispatcher firing one. A gate on +// generation alone would leave every already-stored attestation freely +// exportable, and a gate on the HTTP paths alone would let a schedule created +// while licensed keep minting the artifact after the licence lapsed. +func TestReports_AttestationIsLicensed(t *testing.T) { + t.Run("api-reports/AC-25", func(t *testing.T) { + read := func(rel string) string { + raw, err := os.ReadFile(rel) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + return string(raw) + } + handlers := read("report_handlers.go") + schedules := read("report_schedule_handlers.go") + dispatcher := read("../reportschedule/dispatcher.go") + + // The gate exists and keys on the feature, not on a string literal. + if !strings.Contains(handlers, "license.ComplianceAttestation") { + t.Error("report handlers must gate on license.ComplianceAttestation") + } + if !strings.Contains(handlers, "func enforceAttestationLicense") { + t.Error("expected a single named gate helper so the four call sites cannot drift") + } + + // It keys on KIND, not scope. Scope is the wrong axis: an absent body + // means all hosts, so a scope gate would gate the free executive + // summary. + if !strings.Contains(handlers, "kind != report.KindAttestation") { + t.Error("the gate must key on the attestation KIND, not on scope") + } + + // All four call sites. + if !strings.Contains(handlers, "enforceAttestationLicense(w, r, req.Kind)") { + t.Error("generation must be gated") + } + if !strings.Contains(handlers, "enforceAttestationLicense(w, r, rep.Kind)") { + t.Error("export must be gated on the STORED report's kind") + } + if !strings.Contains(schedules, "enforceAttestationLicense(w, r, report.Kind(body.Kind))") { + t.Error("schedule creation must be gated") + } + if !strings.Contains(dispatcher, "license.IsEnabled(license.ComplianceAttestation)") { + t.Error("the dispatcher must re-check the licence at fire time") + } + + // What must stay free. These are the routes a Community user needs to + // see and prove their own compliance state. + spec := read("../../api/openapi.yaml") + for _, free := range []string{ + "/api/v1/scans/{id}/oscal:", + "/api/v1/reports/signing-key:", + } { + i := strings.Index(spec, free) + if i < 0 { + t.Errorf("expected free route %s to exist", free) + continue + } + // No x-required-feature within the route's own block. + block := spec[i:] + if j := strings.Index(block[1:], "\n /api/v1/"); j >= 0 { + block = block[:j] + } + if strings.Contains(block, "x-required-feature") { + t.Errorf("%s must stay free (no x-required-feature)", free) + } + } + }) +} diff --git a/internal/server/report_handlers.go b/internal/server/report_handlers.go index 322a8e61..33c4e19f 100644 --- a/internal/server/report_handlers.go +++ b/internal/server/report_handlers.go @@ -26,6 +26,7 @@ import ( "github.com/Hanalyx/openwatch/internal/audit" "github.com/Hanalyx/openwatch/internal/auth" "github.com/Hanalyx/openwatch/internal/group" + "github.com/Hanalyx/openwatch/internal/license" "github.com/Hanalyx/openwatch/internal/report" "github.com/Hanalyx/openwatch/internal/server/api" ) @@ -216,6 +217,10 @@ func (h *handlers) PostReportGenerate(w http.ResponseWriter, r *http.Request) { req.PeriodDays = *body.PeriodDays } + if enforceAttestationLicense(w, r, req.Kind) { + return + } + rep, err := h.reportSvc.Generate(r.Context(), reportActor(r), req) if errors.Is(err, report.ErrInvalidKind) { writeError(w, http.StatusBadRequest, "reports.invalid_kind", "client", @@ -274,6 +279,18 @@ func (h *handlers) GetReportExport(w http.ResponseWriter, r *http.Request, id op face = string(*params.Format) } + // Gate on the STORED report's kind, not on anything the caller sends. + // Gating only generation would leave every attestation report already in + // the database freely exportable, and the OSCAL SAR face is exactly the + // artifact being monetized. A report that cannot be read is treated as + // not found by the export call below, so a lookup failure here is not a + // reason to open the gate. + if rep, rerr := h.reportSvc.Get(r.Context(), id); rerr == nil { + if enforceAttestationLicense(w, r, rep.Kind) { + return + } + } + body, mediaType, err := h.reportSvc.Export(r.Context(), id, face) if errors.Is(err, report.ErrNotFound) { writeError(w, http.StatusNotFound, "reports.not_found", "client", "report not found", false) @@ -329,3 +346,27 @@ func (h *handlers) GetReportByID(w http.ResponseWriter, r *http.Request, id open } writeJSON(w, http.StatusOK, out) } + +// enforceAttestationLicense gates the auditor-facing compliance artifact. +// +// OpenWatch monetizes the evidence a compliance officer files, not the ability +// to see your own posture. Everything that lets a Community user observe and +// prove compliance stays free: the `executive` report kind at any scope, every +// per-scan and per-host OSCAL export, the signing key, and report listing. +// What is paid is the fleet-wide signed attestation package. +// +// The line is the report KIND, not the scope. Scope was the obvious axis and +// it is wrong: an empty request body means all hosts and all frameworks, so a +// scope-based gate would gate the default executive summary, which is the +// everyday free artifact. Kind is also exact, because report.FaceOSCALSAR is +// attestation-only (exportFleetOSCALSAR), so gating this one kind gates the +// fleet OSCAL SAR and nothing else. +// +// Returns true when the caller was denied and a 402 has been written. +// Spec api-reports C-09 / AC-14. +func enforceAttestationLicense(w http.ResponseWriter, r *http.Request, kind report.Kind) bool { + if kind != report.KindAttestation { + return false + } + return license.EnforceFeature(w, r, license.ComplianceAttestation) +} diff --git a/internal/server/report_schedule_handlers.go b/internal/server/report_schedule_handlers.go index 93b93eda..3449dbff 100644 --- a/internal/server/report_schedule_handlers.go +++ b/internal/server/report_schedule_handlers.go @@ -17,6 +17,7 @@ import ( "github.com/Hanalyx/openwatch/internal/audit" "github.com/Hanalyx/openwatch/internal/auth" "github.com/Hanalyx/openwatch/internal/notification" + "github.com/Hanalyx/openwatch/internal/report" "github.com/Hanalyx/openwatch/internal/reportschedule" "github.com/Hanalyx/openwatch/internal/server/api" ) @@ -117,6 +118,14 @@ func (h *handlers) CreateReportSchedule(w http.ResponseWriter, r *http.Request) return } + // A scheduled attestation would otherwise mint the paid artifact on a + // timer without ever touching the gated generate endpoint. Gate it here + // too. Update cannot change the kind, so it needs no gate; the dispatcher + // re-checks at fire time so a lapsed licence stops the timer. + if enforceAttestationLicense(w, r, report.Kind(body.Kind)) { + return + } + p := reportschedule.CreateParams{ Name: body.Name, Kind: string(body.Kind), diff --git a/licensing/features.yaml b/licensing/features.yaml index a71371c5..1268d664 100644 --- a/licensing/features.yaml +++ b/licensing/features.yaml @@ -76,6 +76,18 @@ features: # paywall. Both ids stay in the registry (rather than being deleted) so that # any already-minted license naming them still resolves, and so a future # managed/hosted SSO service has an id to hang off. + # The auditor-facing compliance artifact. OpenWatch monetizes the evidence a + # compliance officer files, not the ability to see your own posture. The + # `executive` report kind, every per-scan and per-host OSCAL export, the + # signing key, and report listing all stay FREE: a Community user can see + # their compliance state and prove it per scan. What is paid is the + # fleet-wide signed attestation package (report kind `attestation`, which is + # the only producer of the fleet OSCAL SAR face). + - id: compliance_attestation + tier: enterprise + description: Fleet-wide signed attestation reports and the fleet OSCAL SAR export + introduced: "1.0.0" + - id: sso_saml tier: free description: SAML 2.0 single sign-on integration diff --git a/specs/api/reports.spec.yaml b/specs/api/reports.spec.yaml index df808857..621ecc3f 100644 --- a/specs/api/reports.spec.yaml +++ b/specs/api/reports.spec.yaml @@ -1,7 +1,7 @@ spec: id: api-reports title: Reports library - point-in-time Fleet Compliance Executive Summary artifacts (list / generate / fetch) - version: "1.14.0" + version: "1.15.0" status: approved tier: 2 @@ -404,6 +404,18 @@ spec: type: technical enforcement: error + - id: C-19 + description: > + Report tiering gates on the report KIND, never on scope. An absent + scope body means all hosts and all frameworks, so a scope-based gate + would gate the default executive summary, which is the everyday free + artifact. Only kind `attestation` is licensed + (`compliance_attestation`); it is the sole producer of the fleet OSCAL + SAR face. The gate is enforced at generation, at export (on the stored + kind), at schedule creation, and at scheduled fire time. + type: security + enforcement: error + acceptance_criteria: - id: AC-01 description: >- @@ -666,3 +678,21 @@ spec: oscal_sar is ErrInvalidFace for the remediation kind. priority: high references_constraints: [C-18] + - id: AC-25 + description: > + The auditor-facing compliance artifact is licensed; observing your own + posture is not. Generating a report of kind `attestation` requires the + `compliance_attestation` feature and returns 402 without it. Exporting + an existing attestation report is gated on the STORED report's kind, so + a report generated while licensed does not stay exportable afterwards; + this matters because report.FaceOSCALSAR is attestation-only, making + that face the exact artifact being monetized. Creating a report + schedule of kind `attestation` is gated, and the dispatcher re-checks + the licence at FIRE time and skips rather than generating, so a lapsed + licence stops the timer instead of minting the paid artifact + indefinitely. Everything else stays free at every scope: the + `executive` kind, per-scan and per-host OSCAL + (`/scans/{id}/oscal`, `/scans/{id}/rules/{ruleId}/oscal`), the signing + key, report listing, and report read. + priority: critical + references_constraints: [C-19] From e7d219d562d5ee3c16e8b0c0649b9fa3beb8ec81 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Tue, 28 Jul 2026 17:05:41 -0400 Subject: [PATCH 2/3] test(reports): run the licensed schedule fixtures licensed, and prove the fire-time gate CI caught what the local suite could not: two DB-gated report-schedule suites use the `attestation` kind as their fixture, because it has the richest content. Gating that kind made both fail. They test schedule mechanics, not licensing, so they now run as a licensed deployment would. That needed an exported hook. license.EnableFeatureForTesting mirrors the existing SetVerificationKeyForTesting pattern and carries an explicit warning not to use it to paper over a gate: a test asserting an unlicensed caller is refused must not call it, because that is the case the gate exists for. Adds the behavioural half of AC-25 for the fire-time gate. The source inspection proves the check is written; this proves it works, which matters most here because the dispatcher gate is the one an operator cannot see. A schedule created while licensed would otherwise keep minting the paid artifact on a timer forever after the licence lapsed, with no request to refuse. The test asserts three things: unlicensed attestation never reaches Generate and skipping is not an error, the free `executive` kind is unaffected, and a LICENSED attestation does reach Generate, so the gate is proven to be the licence rather than the kind alone. --- internal/license/state.go | 18 +++++ .../reportschedule/dispatcher_license_test.go | 79 +++++++++++++++++++ internal/reportschedule/schedule_db_test.go | 7 ++ internal/server/api_report_schedule_test.go | 4 + 4 files changed, 108 insertions(+) create mode 100644 internal/reportschedule/dispatcher_license_test.go diff --git a/internal/license/state.go b/internal/license/state.go index 8b41e16b..5a477923 100644 --- a/internal/license/state.go +++ b/internal/license/state.go @@ -79,3 +79,21 @@ func setKeyring(ring *publicKeyRing) { func activeKeyring() *publicKeyRing { return keyring.Load() } + +// EnableFeatureForTesting turns f on in the active state and returns a restore +// func. It exists because gating a capability broke tests that use that +// capability as a fixture but are not testing licensing: the report-schedule +// suites both use the `attestation` kind because it has the richest content, +// so they must run as a licensed deployment would. +// +// Do NOT use it to paper over a gate. A test asserting that an UNLICENSED +// caller is refused must not call this; that is the case the gate exists for. +func EnableFeatureForTesting(f Feature) (restore func()) { + prev := current.Load() + lic := &License{Features: []Feature{f}} + if prev != nil && prev.License != nil { + lic.Features = append(append([]Feature{}, prev.License.Features...), f) + } + setState(&State{License: lic, LoadedAt: time.Now()}) + return func() { current.Store(prev) } +} diff --git a/internal/reportschedule/dispatcher_license_test.go b/internal/reportschedule/dispatcher_license_test.go new file mode 100644 index 00000000..859c961d --- /dev/null +++ b/internal/reportschedule/dispatcher_license_test.go @@ -0,0 +1,79 @@ +// @spec api-reports +// +// AC-25 TestDispatcher_SkipsUnlicensedAttestation +package reportschedule + +import ( + "context" + "testing" + + "github.com/Hanalyx/openwatch/internal/license" + "github.com/Hanalyx/openwatch/internal/report" + "github.com/google/uuid" +) + +// countingGen records whether Generate was reached. +type countingGen struct{ generated int } + +func (g *countingGen) Generate(_ context.Context, _ string, _ report.GenerateRequest) (report.Report, error) { + g.generated++ + return report.Report{ID: uuid.New()}, nil +} +func (g *countingGen) Export(_ context.Context, _ uuid.UUID, _ string) ([]byte, string, error) { + return []byte("pdf"), "application/pdf", nil +} + +// noopDeliver stands in for the email path, which this AC does not exercise. +type noopDeliver struct{} + +func (noopDeliver) SendReportEmail(_ context.Context, _ uuid.UUID, _, _, _ string, _ []byte) error { + return nil +} + +// @ac AC-25 +// AC-25 (behavioural, fire-time half): an attestation schedule does not mint +// the paid artifact when the licence is absent, and DOES when it is present. +// +// The source-inspection AC proves the check is written. This proves it works, +// which matters because the fire-time gate is the one an operator cannot see: +// a schedule created while licensed would otherwise keep producing the artifact +// on a timer forever after the licence lapsed, with no request to refuse. +func TestDispatcher_SkipsUnlicensedAttestation(t *testing.T) { + t.Run("api-reports/AC-25", func(t *testing.T) { + if err := license.Init(); err != nil { + t.Fatalf("license init: %v", err) + } + sch := Schedule{ID: uuid.New(), Kind: "attestation"} + + // Unlicensed: skipped, and skipping is not an error. An unlicensed + // schedule is inert, not broken. + gen := &countingGen{} + d := &Dispatcher{gen: gen, deliver: noopDeliver{}} + if err := d.run(context.Background(), sch); err != nil { + t.Fatalf("unlicensed run should skip cleanly, got %v", err) + } + if gen.generated != 0 { + t.Errorf("unlicensed attestation must NOT be generated; Generate called %d time(s)", gen.generated) + } + + // The free kind is unaffected by the gate: it must REACH Generate. + // Delivery is stubbed; only the gate decision matters here. + gen2 := &countingGen{} + d2 := &Dispatcher{gen: gen2, deliver: noopDeliver{}} + _ = d2.run(context.Background(), Schedule{ID: uuid.New(), Kind: "executive"}) + if gen2.generated != 1 { + t.Errorf("executive (free) must reach Generate; called %d time(s)", gen2.generated) + } + + // And a LICENSED attestation must reach Generate too, so the gate is + // proven to be the licence and not the kind alone. + restore := license.EnableFeatureForTesting(license.ComplianceAttestation) + defer restore() + gen3 := &countingGen{} + d3 := &Dispatcher{gen: gen3, deliver: noopDeliver{}} + _ = d3.run(context.Background(), sch) + if gen3.generated != 1 { + t.Errorf("licensed attestation must reach Generate; called %d time(s)", gen3.generated) + } + }) +} diff --git a/internal/reportschedule/schedule_db_test.go b/internal/reportschedule/schedule_db_test.go index 13efbd71..fae0d5af 100644 --- a/internal/reportschedule/schedule_db_test.go +++ b/internal/reportschedule/schedule_db_test.go @@ -16,6 +16,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/Hanalyx/openwatch/internal/db/dbtest" + "github.com/Hanalyx/openwatch/internal/license" "github.com/Hanalyx/openwatch/internal/report" ) @@ -161,6 +162,12 @@ func TestClaimDue_NoDoubleClaim(t *testing.T) { // @ac AC-02 func TestDispatcher_RunsDueSchedule(t *testing.T) { t.Run("system-report-schedule/AC-02", func(t *testing.T) { + // This suite uses the `attestation` kind as its fixture because it has + // the richest content, and that kind is licensed + // (compliance_attestation). It is testing schedule mechanics, not + // licensing, so it runs as a licensed deployment would. The refusal + // path has its own test below. + defer license.EnableFeatureForTesting(license.ComplianceAttestation)() pool := freshPool(t) ctx := context.Background() svc := NewService(pool) diff --git a/internal/server/api_report_schedule_test.go b/internal/server/api_report_schedule_test.go index afec6434..863f0122 100644 --- a/internal/server/api_report_schedule_test.go +++ b/internal/server/api_report_schedule_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/Hanalyx/openwatch/internal/auth" + "github.com/Hanalyx/openwatch/internal/license" ) // @spec system-report-schedule @@ -19,6 +20,9 @@ import ( // round-trips; RBAC is enforced. func TestAPI_ReportSchedules(t *testing.T) { t.Run("system-report-schedule/AC-04", func(t *testing.T) { + // The `attestation` fixture is a licensed kind (compliance_attestation); + // this suite tests schedule mechanics, not licensing. + defer license.EnableFeatureForTesting(license.ComplianceAttestation)() url, pool := freshAPIServer(t) // A real email channel for the schedule's FK + delivery transport. From f573da6f18f8b290bb84d30ff0eb0ae257c69dcf Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Tue, 28 Jul 2026 17:15:26 -0400 Subject: [PATCH 3/3] test(reports): enable the licensed fixture after license.Init, not before freshAPIServer calls license.Init(), which resets state, so enabling the feature beforehand was wiped before the request ran and the suite still saw a 402. Enable after fixture setup in both suites; the dispatcher one happened to work only because freshPool does not init license, which is the kind of ordering dependency that fails later for an unrelated reason. --- internal/reportschedule/schedule_db_test.go | 4 +++- internal/server/api_report_schedule_test.go | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/reportschedule/schedule_db_test.go b/internal/reportschedule/schedule_db_test.go index fae0d5af..02dd9c6c 100644 --- a/internal/reportschedule/schedule_db_test.go +++ b/internal/reportschedule/schedule_db_test.go @@ -167,8 +167,10 @@ func TestDispatcher_RunsDueSchedule(t *testing.T) { // (compliance_attestation). It is testing schedule mechanics, not // licensing, so it runs as a licensed deployment would. The refusal // path has its own test below. - defer license.EnableFeatureForTesting(license.ComplianceAttestation)() pool := freshPool(t) + // Enable AFTER the fixture setup: helpers that call license.Init() + // reset state and would wipe an earlier enablement. + defer license.EnableFeatureForTesting(license.ComplianceAttestation)() ctx := context.Background() svc := NewService(pool) ch := seedChannel(t, pool) diff --git a/internal/server/api_report_schedule_test.go b/internal/server/api_report_schedule_test.go index 863f0122..bd3ee0bd 100644 --- a/internal/server/api_report_schedule_test.go +++ b/internal/server/api_report_schedule_test.go @@ -20,10 +20,12 @@ import ( // round-trips; RBAC is enforced. func TestAPI_ReportSchedules(t *testing.T) { t.Run("system-report-schedule/AC-04", func(t *testing.T) { + url, pool := freshAPIServer(t) // The `attestation` fixture is a licensed kind (compliance_attestation); - // this suite tests schedule mechanics, not licensing. + // this suite tests schedule mechanics, not licensing. Enable AFTER + // freshAPIServer: it calls license.Init(), which resets state and would + // wipe an earlier enablement. defer license.EnableFeatureForTesting(license.ComplianceAttestation)() - url, pool := freshAPIServer(t) // A real email channel for the schedule's FK + delivery transport. chID := uuid.New()