diff --git a/cmd/mock-oci-registry/main.go b/cmd/mock-oci-registry/main.go index b3d30c62..ed1f8641 100644 --- a/cmd/mock-oci-registry/main.go +++ b/cmd/mock-oci-registry/main.go @@ -35,6 +35,9 @@ var seedData embed.FS //go:embed testdata/opa-complypack/* var opaComplypackData embed.FS +//go:embed testdata/ampel-complypack/* +var ampelComplypackData embed.FS + const defaultPort = "8765" const ( @@ -490,10 +493,11 @@ func (s *contentStore) seedDefaults() { []string{"v1.0.0", "latest"}) // complypacks/ampel-bp — ComplyPack artifact for AMPEL branch protection evaluator + // Contains granular policy JSON from testdata/ampel-complypack/ s.addComplypackArtifact("complypacks/ampel-bp", []string{"v1.0.0", "latest"}, complypackDef{ evaluatorID: "ampel", version: "1.0.0", - content: buildDummyTarGz("policy.json", []byte(`{"name":"ampel-branch-protection","version":"1.0.0"}`)), + content: buildTarGzFromFS(ampelComplypackData, "testdata/ampel-complypack"), }) // policies/test-opa-policy — OPA container security controls diff --git a/cmd/mock-oci-registry/main_test.go b/cmd/mock-oci-registry/main_test.go index c8e29e61..92013501 100644 --- a/cmd/mock-oci-registry/main_test.go +++ b/cmd/mock-oci-registry/main_test.go @@ -6,6 +6,7 @@ import ( "archive/tar" "bytes" "compress/gzip" + "encoding/json" "io" "os" "path/filepath" @@ -65,6 +66,43 @@ func TestBuildTarGzFromFS_ContentReadable(t *testing.T) { } } +func TestBuildTarGzFromFS_AmpelFS(t *testing.T) { + data := buildTarGzFromFS(ampelComplypackData, "testdata/ampel-complypack") + + gr, err := gzip.NewReader(bytes.NewReader(data)) + require.NoError(t, err) + defer gr.Close() + + tr := tar.NewReader(gr) + files := make(map[string]bool) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + files[hdr.Name] = true + assert.Equal(t, int64(0o644), hdr.Mode) + assert.Greater(t, hdr.Size, int64(0)) + + // Verify each file is valid JSON with a non-empty "id" field. + content := make([]byte, hdr.Size) + _, readErr := io.ReadFull(tr, content) + require.NoError(t, readErr) + + var policy struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(content, &policy), + "file %s should be valid JSON", hdr.Name) + assert.NotEmpty(t, policy.ID, + "file %s should have a non-empty id field", hdr.Name) + } + + assert.Contains(t, files, "block-force-push.json") + assert.Len(t, files, 1, "expected exactly 1 file in tar archive") +} + func TestBuildDummyTarGz_SingleFile(t *testing.T) { content := []byte(`{"test": true}`) data := buildDummyTarGz("test.json", content) @@ -115,6 +153,54 @@ func TestSeedDefaults_AllReposSeeded(t *testing.T) { require.NotNil(t, complypackRepo) _, hasLatest = complypackRepo.tags["latest"] assert.True(t, hasLatest, "OPA complypack should have 'latest' tag") + + // Verify ampel complypack has expected tags and valid content + ampelRepo := store.repos["complypacks/ampel-bp"] + require.NotNil(t, ampelRepo) + _, hasLatest = ampelRepo.tags["latest"] + assert.True(t, hasLatest, "ampel complypack should have 'latest' tag") + _, hasV1 = ampelRepo.tags["v1.0.0"] + assert.True(t, hasV1, "ampel complypack should have 'v1.0.0' tag") + + // Verify the ampel complypack content blob contains valid granular policy JSON. + ampelArt := ampelRepo.tags["latest"] + require.NotNil(t, ampelArt) + var ampelManifest ociManifest + require.NoError(t, json.Unmarshal(ampelArt.manifestBytes, &elManifest)) + require.NotEmpty(t, ampelManifest.Layers, "ampel complypack should have at least one layer") + + contentDigest := ampelManifest.Layers[0].Digest + contentBlob, ok := ampelRepo.blobs[contentDigest] + require.True(t, ok, "content blob should exist for digest %s", contentDigest) + + gr, err := gzip.NewReader(bytes.NewReader(contentBlob.data)) + require.NoError(t, err) + defer gr.Close() + + tr := tar.NewReader(gr) + fileCount := 0 + for { + hdr, tarErr := tr.Next() + if tarErr == io.EOF { + break + } + require.NoError(t, tarErr) + fileCount++ + + data := make([]byte, hdr.Size) + _, readErr := io.ReadFull(tr, data) + require.NoError(t, readErr) + + var policy struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(data, &policy), + "ampel complypack file %s should be valid JSON", hdr.Name) + assert.NotEmpty(t, policy.ID, + "ampel complypack file %s should have a non-empty id field", hdr.Name) + } + assert.GreaterOrEqual(t, fileCount, 1, + "ampel complypack should contain at least one file") } func TestResolveContentDir_Default(t *testing.T) { diff --git a/cmd/mock-oci-registry/testdata/ampel-complypack/block-force-push.json b/cmd/mock-oci-registry/testdata/ampel-complypack/block-force-push.json new file mode 100644 index 00000000..fe521a09 --- /dev/null +++ b/cmd/mock-oci-registry/testdata/ampel-complypack/block-force-push.json @@ -0,0 +1,27 @@ +{ + "id": "block-force-push", + "meta": { + "description": "Validate force push blocking is enabled on protected branches via the GitHub API", + "controls": [ + { "framework": "test-branch-protection", "class": "source-code", "id": "force-push-protection" } + ] + }, + "tenets": [ + { + "id": "01", + "code": "(has(predicates[0].data.values) && type(predicates[0].data.values) == list && predicates[0].data.values.exists(rule, rule.type == \"non_fast_forward\")) || (has(predicates[0].data.values) && type(predicates[0].data.values) != list && has(predicates[0].data.values.allow_force_push) && predicates[0].data.values.allow_force_push == false)", + "predicates": { + "types": [ + "http://github.com/carabiner-dev/snappy/specs/github/branch-rules.yaml" + ] + }, + "assessment": { + "message": "Force pushing is disabled in protected branch." + }, + "error": { + "message": "Force pushes can be sent to protected branch", + "guidance": "Create a branch ruleset and enable 'Block force pushes' in the GitHub repository settings." + } + } + ] +} diff --git a/openspec/changes/ampel-complypack-content/.openspec.yaml b/openspec/changes/ampel-complypack-content/.openspec.yaml new file mode 100644 index 00000000..f617bd18 --- /dev/null +++ b/openspec/changes/ampel-complypack-content/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-04 diff --git a/openspec/changes/ampel-complypack-content/design.md b/openspec/changes/ampel-complypack-content/design.md new file mode 100644 index 00000000..7d0d54fc --- /dev/null +++ b/openspec/changes/ampel-complypack-content/design.md @@ -0,0 +1,93 @@ +## Context + +The mock OCI registry (`cmd/mock-oci-registry/main.go`) seeds test +content for integration testing. It currently seeds the ampel complypack +(`complypacks/ampel-bp`) with a dummy payload via `buildDummyTarGz()`: + +```go +content: buildDummyTarGz("policy.json", + []byte(`{"name":"ampel-branch-protection","version":"1.0.0"}`)) +``` + +This dummy JSON lacks the `id` field that `LoadGranularPolicies()` +requires. The OPA complypack was already migrated to the +`buildTarGzFromFS()` pattern in commit `74fbae8`, where real policy +files are embedded from `testdata/opa-complypack/` via `//go:embed`. + +The cross-repo integration test script (`cross_repo_integration_test.sh`) +currently works around the dummy content by manually copying +`block-force-push.json` into the workspace default directory. With the +ampel provider now consuming `ComplypackContentPath`, this workaround +is insufficient — the complypack content takes precedence. + +## Goals / Non-Goals + +**Goals:** +- Seed the ampel complypack with valid granular policy content that the + provider accepts +- Follow the established OPA complypack pattern (embed + `buildTarGzFromFS`) +- Remove the manual granular policy pre-staging from the integration test +- Maintain backward compatibility with the pre-PR#52 ampel provider + +**Non-Goals:** +- Adding new ampel policies or expanding test coverage beyond what exists +- Modifying the ampel provider code (that is `complytime-providers` PR #52) +- Changing the complypack OCI artifact format or media types +- Adding OPA-side changes (already handled by `opa-devcontainer-content`) + +## Decisions + +### D1: Reuse existing `block-force-push.json` content + +**Decision**: Copy the content from +`tests/cross-repo/testdata/granular-policies/block-force-push.json` +into `cmd/mock-oci-registry/testdata/ampel-complypack/block-force-push.json`. + +**Rationale**: This is the same fixture the integration test already +validates against. Using identical content ensures the test assertions +(e.g., checking for `block-force-push` policy ID in results) continue +to pass without modification. + +**Alternatives considered**: +- Create minimal stub content: Rejected — would require updating test + assertions and diverges from the real policy format the test validates. + +### D2: Remove pre-staged granular policies from integration test + +**Decision**: Remove the `mkdir -p` and `cp` lines that pre-stage +`block-force-push.json` into `.complytime/ampel/granular-policies/` in +`cross_repo_integration_test.sh`. + +**Rationale**: With `ComplypackContentPath` taking precedence in PR #52's +provider, the pre-staged directory is never consulted. Keeping it creates +a false safety net — the test would pass even if complypack delivery +broke, defeating the purpose of the integration test. + +**Alternatives considered**: +- Keep pre-staged content as fallback: Rejected — masks complypack + delivery failures and makes the test less meaningful. + +### D3: Add `//go:embed` directive alongside the existing OPA one + +**Decision**: Add a new `//go:embed testdata/ampel-complypack/*` var +declaration directly below the existing OPA embed directive. + +**Rationale**: Follows the established pattern. Each complypack provider +gets its own embedded filesystem variable and testdata subdirectory. + +## Risks / Trade-offs + +- **[Risk] Merge ordering**: If this PR merges before + `complytime-providers` PR #52, the cross-repo CI will build the old + ampel provider from `main`, which ignores `ComplypackContentPath` + and falls back to the default directory. Since we're removing the + pre-staged content, the test would fail. + → **Mitigation**: Keep the pre-staged content removal as a separate, + final task. If needed, split the change: merge the complypack content + seeding first (backward-compatible), then remove pre-staging after + PR #52 lands on `complytime-providers` main. + +- **[Risk] Content drift**: The embedded `block-force-push.json` could + diverge from the provider's expected format over time. + → **Mitigation**: This is test content for integration validation — + if the format changes, the integration test will catch it (by design). diff --git a/openspec/changes/ampel-complypack-content/proposal.md b/openspec/changes/ampel-complypack-content/proposal.md new file mode 100644 index 00000000..e6cd875d --- /dev/null +++ b/openspec/changes/ampel-complypack-content/proposal.md @@ -0,0 +1,54 @@ +## Why + +The mock OCI registry seeds the `complypacks/ampel-bp` artifact with a +dummy `policy.json` containing `{"name":"...","version":"..."}` — a +placeholder that lacks the `id` field required by the ampel provider's +`LoadGranularPolicies()`. Before `complytime-providers` PR #52, the ampel +provider ignored `ComplypackContentPath` and fell back to pre-staged +granular policies, so the dummy content was never parsed. Now that the +provider consumes complypack content, the cross-repo integration test +fails because the dummy payload is rejected. This change replaces the +dummy content with a valid ampel granular policy, mirroring the pattern +already established for the OPA complypack (commit `74fbae8`). + +## What Changes + +- Replace the dummy `buildDummyTarGz("policy.json", ...)` call in + `seedDefaults()` with `buildTarGzFromFS()` using embedded ampel + complypack test content +- Add `testdata/ampel-complypack/block-force-push.json` containing a + valid `AmpelPolicy` structure (matching the existing cross-repo test + fixture at `tests/cross-repo/testdata/granular-policies/`) +- Add `//go:embed testdata/ampel-complypack/*` directive to embed the + ampel complypack content +- Update `TestSeedDefaults_AllReposSeeded` to verify the ampel + complypack contains the expected file count and content structure +- Remove the now-unused `block-force-push.json` from + `tests/cross-repo/testdata/granular-policies/` and the corresponding + `cp` / `mkdir` lines from `cross_repo_integration_test.sh`, since + the provider will consume complypack content directly instead of the + pre-staged fallback directory + +## Capabilities + +### New Capabilities +- `ampel-complypack-seed`: Embedded ampel complypack test content in the + mock OCI registry, producing a valid tar.gz payload that the ampel + provider's `LoadGranularPolicies()` accepts + +### Modified Capabilities + +## Impact + +- `cmd/mock-oci-registry/main.go`: New embed directive, updated + `seedDefaults()` call for `complypacks/ampel-bp` +- `cmd/mock-oci-registry/testdata/ampel-complypack/`: New directory with + valid granular policy JSON +- `cmd/mock-oci-registry/main_test.go`: Updated test assertions for + ampel complypack content +- `tests/cross-repo/testdata/granular-policies/`: Removed (content + migrated to embedded complypack) +- `tests/cross-repo/cross_repo_integration_test.sh`: Simplified setup + (no manual granular policy staging) +- Backward-compatible: the old ampel provider ignores + `ComplypackContentPath` and is unaffected by serving valid content diff --git a/openspec/changes/ampel-complypack-content/specs/ampel-complypack-seed/spec.md b/openspec/changes/ampel-complypack-content/specs/ampel-complypack-seed/spec.md new file mode 100644 index 00000000..b86a7b71 --- /dev/null +++ b/openspec/changes/ampel-complypack-content/specs/ampel-complypack-seed/spec.md @@ -0,0 +1,62 @@ +## ADDED Requirements + +### Requirement: Valid ampel complypack content in mock registry +The mock OCI registry SHALL seed the `complypacks/ampel-bp` artifact +with a tar.gz payload containing valid ampel granular policy JSON that +the ampel provider's `LoadGranularPolicies()` accepts without error. +Each embedded JSON file SHALL contain an `id` field, a `meta` object +with `description` and `controls`, and a `tenets` array with at least +one entry. + +#### Scenario: Mock registry serves valid ampel complypack +- **WHEN** the mock registry starts with default seed data +- **THEN** the `complypacks/ampel-bp` artifact SHALL contain a tar.gz + layer with at least one `.json` file that conforms to the `AmpelPolicy` + schema (non-empty `id`, `meta.controls`, and `tenets` fields) + +#### Scenario: Ampel provider parses complypack content without error +- **WHEN** the ampel provider's `Generate()` receives a + `ComplypackContentPath` pointing to the extracted complypack content +- **THEN** `LoadGranularPolicies()` SHALL parse all JSON files + successfully and return a non-empty policy map + +### Requirement: Ampel complypack embedded via filesystem +The ampel complypack content SHALL be embedded using `//go:embed` and +`buildTarGzFromFS()`, matching the pattern established by the OPA +complypack. The content SHALL NOT use `buildDummyTarGz()` with inline +string literals. + +#### Scenario: Embedded testdata directory structure +- **WHEN** the mock registry binary is compiled +- **THEN** the `testdata/ampel-complypack/` directory SHALL be embedded + via `//go:embed` and its contents packaged by `buildTarGzFromFS()` + +### Requirement: Cross-repo integration test uses complypack flow +The cross-repo integration test SHALL exercise the complypack content +path for the ampel provider. Manual pre-staging of granular policy files +into the workspace default directory SHALL be removed so the test +validates the complypack-delivered content path. + +#### Scenario: Integration test passes without pre-staged granular policies +- **WHEN** the cross-repo integration test runs `complyctl generate` +- **THEN** the ampel provider SHALL load granular policies from the + complypack content path (delivered by `complyctl get`) instead of from + a manually pre-staged `.complytime/ampel/granular-policies/` directory + +#### Scenario: Backward compatibility with old provider +- **WHEN** the mock registry serves valid ampel complypack content +- **AND** the ampel provider binary does NOT support `ComplypackContentPath` + (pre-PR#52 version) +- **THEN** the provider SHALL ignore the complypack content and fall back + to the pre-staged granular policies directory without error + +### Requirement: Unit test coverage for ampel complypack seeding +The mock registry's unit tests SHALL verify that the ampel complypack +artifact is seeded with valid, readable content containing the expected +files. + +#### Scenario: TestSeedDefaults verifies ampel complypack content +- **WHEN** `TestSeedDefaults_AllReposSeeded` runs +- **THEN** it SHALL verify that `complypacks/ampel-bp` contains a content + blob that decompresses to at least one `.json` file with a non-empty + `id` field diff --git a/openspec/changes/ampel-complypack-content/tasks.md b/openspec/changes/ampel-complypack-content/tasks.md new file mode 100644 index 00000000..b52149bd --- /dev/null +++ b/openspec/changes/ampel-complypack-content/tasks.md @@ -0,0 +1,21 @@ +## 1. Ampel Complypack Test Content + +- [x] 1.1 [P] Create `cmd/mock-oci-registry/testdata/ampel-complypack/block-force-push.json` with valid `AmpelPolicy` content (copy from `tests/cross-repo/testdata/granular-policies/block-force-push.json`) +- [x] 1.2 Add `//go:embed testdata/ampel-complypack/*` directive and `ampelComplypackData` variable to `cmd/mock-oci-registry/main.go`, below the existing OPA embed directive +- [x] 1.3 Update `seedDefaults()` in `cmd/mock-oci-registry/main.go` to replace `buildDummyTarGz("policy.json", ...)` with `buildTarGzFromFS(ampelComplypackData, "testdata/ampel-complypack")` for the `complypacks/ampel-bp` artifact + +## 2. Unit Tests + +- [x] 2.1 Add `TestBuildTarGzFromFS_AmpelFS` test in `cmd/mock-oci-registry/main_test.go` to verify the ampel complypack archive contains `block-force-push.json` with valid content +- [x] 2.2 Update `TestSeedDefaults_AllReposSeeded` to verify the ampel complypack content blob decompresses to at least one `.json` file with a non-empty `id` field + +## 3. Integration Test Cleanup + +- [ ] 3.1 DEFERRED (merge ordering) Remove the `mkdir -p "${WORK_DIR}/.complytime/ampel/granular-policies"` and `cp` lines from `tests/cross-repo/cross_repo_integration_test.sh` (lines 176-179) +- [ ] 3.2 DEFERRED (merge ordering) Remove `tests/cross-repo/testdata/granular-policies/block-force-push.json` and its parent directory (content now embedded in mock registry) + +## 4. Validation + +- [x] 4.1 Verify `make build` compiles with the new embedded testdata +- [x] 4.2 Verify `make test-unit` passes (mock registry tests) +- [x] 4.3 Verify `make lint` passes with zero issues