diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 32b39573f..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,3 +0,0 @@ -# CLAUDE.md - -Project rules and instructions live in [AGENTS.md](AGENTS.md). Read that file now — it is the single source of truth for all agent-facing guidance in this repo. diff --git a/internal/scaffold/fullsend-repo/scripts/reconcile-repos-test.sh b/internal/scaffold/fullsend-repo/scripts/reconcile-repos-test.sh index d4d3f5325..eedc46cbb 100644 --- a/internal/scaffold/fullsend-repo/scripts/reconcile-repos-test.sh +++ b/internal/scaffold/fullsend-repo/scripts/reconcile-repos-test.sh @@ -743,3 +743,108 @@ if ! grep -q "::warning::test-repo: non-comment content above sentinel was rejec fi echo "PASS: non-comment YAML above sentinel rejected by content-injection guard" + +# =========================== +# Test 5: identical content with different trailing newlines is not flagged as stale +# =========================== + +# Regression test for issue #2247: the old managed_content_b64 comparison +# produced false-positive drift detection when the remote and expected +# content were logically identical but encoded with different trailing +# newlines (e.g. one trailing \n vs two from the GitHub content API). +# The fix compares decoded text instead of re-encoded base64. + +rm -f "${GH_LOG}" "${TMPDIR}/blob-input-test-repo.json" + +# Generate the expected content (template with sentinel) — the "truth". +IDENTICAL_MANAGED=$(cat "${CONFIG_DIR}/templates/shim-workflow-call.yaml") +# The remote has the same text but an extra trailing newline, producing +# different base64 from shim_content_b64. This simulates encoding +# differences that can arise from GitHub's content API. +IDENTICAL_REMOTE=$(printf '%s\n\n' "$IDENTICAL_MANAGED") +IDENTICAL_B64=$(printf '%s' "$IDENTICAL_REMOTE" | /usr/bin/base64 | tr -d '\r\n') + +cat > "${MOCK_BIN}/gh" <> "${GH_LOG}" +for arg in "\$@"; do + printf ' %q' "\$arg" >> "${GH_LOG}" +done +printf '\n' >> "${GH_LOG}" + +if [[ "\$1" == "pr" ]]; then + exit 0 +fi + +if [[ "\$1" != "api" ]]; then + exit 0 +fi + +jq_filter="" +has_input=false +shift +endpoint="\$1"; shift +while [[ \$# -gt 0 ]]; do + case "\$1" in + --jq) jq_filter="\$2"; shift 2 ;; + --input) has_input=true; shift 2 ;; + --method|--field) shift 2 ;; + --silent) shift ;; + *) shift ;; + esac +done + +if [[ "\$has_input" == "true" && "\$endpoint" == *"/git/blobs" ]]; then + cat > "${TMPDIR}/blob-input-test-repo.json" +fi + +json="" +rc=0 +case "\$endpoint" in + repos/test-org/test-repo/actions/variables/*) + json='{"status":"404","message":"Not Found"}' + rc=1 + ;; + repos/test-org/test-repo/contents/.github/workflows/fullsend.yaml) + json='{"content":"${IDENTICAL_B64}","sha":"file-sha"}' + ;; + repos/test-org/test-repo) + json='{"default_branch":"main","private":false}' + ;; + *) + rc=0 + ;; +esac + +if [[ -n "\$json" ]]; then + if [[ -n "\$jq_filter" ]]; then + printf '%s' "\$json" | jq -r "\$jq_filter" + else + printf '%s\n' "\$json" + fi +fi +exit "\$rc" +EOF5 +chmod +x "${MOCK_BIN}/gh" + +bash "${RECONCILE_SCRIPT}" "${CONFIG_DIR}" > "${TMPDIR}/stdout5.log" 2>&1 || true + +if grep -q "shim is stale" "${TMPDIR}/stdout5.log"; then + echo "FAIL: identical content with different trailing newline was flagged as stale" + cat "${TMPDIR}/stdout5.log" + exit 1 +fi + +if ! grep -q "already enrolled (shim up to date)" "${TMPDIR}/stdout5.log"; then + echo "FAIL: identical content with different trailing newline was not recognized as current" + cat "${TMPDIR}/stdout5.log" + exit 1 +fi + +if [ -f "${TMPDIR}/blob-input-test-repo.json" ]; then + echo "FAIL: blob was created for identical content (false positive drift)" + exit 1 +fi + +echo "PASS: identical content with different trailing newlines not flagged as stale" diff --git a/internal/scaffold/fullsend-repo/scripts/reconcile-repos.sh b/internal/scaffold/fullsend-repo/scripts/reconcile-repos.sh index 280c9ef2e..a3e9c924d 100755 --- a/internal/scaffold/fullsend-repo/scripts/reconcile-repos.sh +++ b/internal/scaffold/fullsend-repo/scripts/reconcile-repos.sh @@ -404,8 +404,16 @@ if [ -n "$ENABLED_REPOS" ]; then EXPECTED_B64=$(shim_content_b64) # GitHub returns base64 with newlines; strip them for comparison. REMOTE_B64=$(printf '%s' "$REMOTE_CONTENT" | tr -d '\r\n') - REMOTE_MANAGED=$(managed_content_b64 "$REMOTE_B64") - EXPECTED_MANAGED=$(managed_content_b64 "$EXPECTED_B64") + # Compare decoded text instead of re-encoded base64 to avoid + # false-positive drift detection from encoding differences + # (trailing newlines, line wrapping in command substitution). + EXPECTED_DECODED=$(printf '%s' "$EXPECTED_B64" | base64 -d | tr -d '\r') + REMOTE_DECODED=$(printf '%s' "$REMOTE_B64" | base64 -d | tr -d '\r') + EXPECTED_MANAGED=$(printf '%s\n' "$EXPECTED_DECODED" | extract_managed_content) + REMOTE_MANAGED=$(printf '%s\n' "$REMOTE_DECODED" | extract_managed_content) + # When no sentinel is found (pre-sentinel shim), compare full decoded content. + [ -z "$EXPECTED_MANAGED" ] && EXPECTED_MANAGED="$EXPECTED_DECODED" + [ -z "$REMOTE_MANAGED" ] && REMOTE_MANAGED="$REMOTE_DECODED" if [ "$REMOTE_MANAGED" = "$EXPECTED_MANAGED" ]; then echo "✓ $REPO already enrolled (shim up to date)" SKIPPED=$((SKIPPED + 1)) diff --git a/internal/scaffold/qf_content_injection_guard_test.go b/internal/scaffold/qf_content_injection_guard_test.go new file mode 100644 index 000000000..d9ff904a8 --- /dev/null +++ b/internal/scaffold/qf_content_injection_guard_test.go @@ -0,0 +1,115 @@ +package scaffold + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +/* +Content-Injection Guard Tests — YAML Injection Prevention + +STP Reference: outputs/stp/GH-77/GH-77_test_plan.md +Jira: GH-77 + +These tests verify that the content-injection guard in shim_with_header_b64() +correctly rejects non-comment YAML above the sentinel line while preserving +legitimate comment-only headers (e.g., license headers). +*/ + +func TestContentInjectionGuard(t *testing.T) { + t.Run("[test_id:TS-GH77-014] should reject non-comment YAML above sentinel", func(t *testing.T) { + h := newReconcileHarness(t) + + // Remote shim has non-comment YAML key injected above sentinel. + remoteContent := "name: injected-workflow\n# --- fullsend managed below - do not edit ---\nstale shim template\n" + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: ` +case "$2" in + list) + for arg in "$@"; do + if [[ "$arg" == "fullsend/onboard" ]]; then + echo "https://github.com/test-org/test-repo/pull/99" + fi + done + exit 0 ;; + create) echo "https://github.com/test-org/test-repo/pull/99"; exit 0 ;; + close) exit 0 ;; +esac +exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, _ := h.run() + + // Verify blob was created (content was stale). + assert.True(t, h.blobExists("test-repo"), + "blob should be created for injection-guarded shim update") + + blobDecoded := h.blobContent("test-repo") + + // Verify injected YAML was stripped. + assert.NotContains(t, blobDecoded, "injected-workflow", + "injected YAML key should be stripped from blob content") + + // Verify warning was emitted. + assert.Contains(t, output, "non-comment content above sentinel was rejected", + "warning log should be emitted for rejected content") + + // Verify blob still contains valid template. + assert.Contains(t, blobDecoded, "# --- fullsend managed below - do not edit ---", + "sentinel line should be present in the updated blob") + assert.Contains(t, blobDecoded, "fresh shim template", + "fresh template content should be present after guard") + }) + + t.Run("[test_id:TS-GH77-015] should preserve comment-only header during update", func(t *testing.T) { + h := newReconcileHarness(t) + + // Remote shim has comment-only header (license) + sentinel + stale managed content. + remoteContent := "# Copyright 2026 Conforma\n# SPDX-License-Identifier: Apache-2.0\n# --- fullsend managed below - do not edit ---\nstale shim template\n" + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: ` +case "$2" in + list) exit 0 ;; + create) echo "https://github.com/test-org/test-repo/pull/99"; exit 0 ;; + close) exit 0 ;; +esac +exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, _ := h.run() + + // Verify stale detection. + assert.Contains(t, output, "shim is stale", + "stale managed content should be detected") + + // Verify blob was created with preserved header. + assert.True(t, h.blobExists("test-repo"), + "blob should be created for stale shim update") + blobDecoded := h.blobContent("test-repo") + + assert.Contains(t, blobDecoded, "# Copyright 2026 Conforma", + "comment header should be preserved in updated blob") + assert.Contains(t, blobDecoded, "# SPDX-License-Identifier: Apache-2.0", + "SPDX identifier should be preserved") + assert.Contains(t, blobDecoded, "# --- fullsend managed below - do not edit ---", + "sentinel line should be present") + assert.Contains(t, blobDecoded, "fresh shim template", + "managed section should be updated with fresh template") + assert.NotContains(t, blobDecoded, "stale shim template", + "old managed content should be replaced") + }) +} diff --git a/internal/scaffold/qf_crlf_normalization_test.go b/internal/scaffold/qf_crlf_normalization_test.go new file mode 100644 index 000000000..46ea10bdf --- /dev/null +++ b/internal/scaffold/qf_crlf_normalization_test.go @@ -0,0 +1,69 @@ +package scaffold + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +/* +CR/LF Normalization Tests — Cross-Platform Drift Prevention + +STP Reference: outputs/stp/GH-77/GH-77_test_plan.md +Jira: GH-77 + +These tests verify that the tr -d '\r' normalization in reconcile-repos.sh +correctly handles Windows-style line endings, preventing false-positive +drift detection from CR/LF differences. +*/ + +func TestCRLFNormalization(t *testing.T) { + t.Run("[test_id:TS-GH77-012] should normalize CRLF content before comparison", func(t *testing.T) { + h := newReconcileHarness(t) + + // Remote content has CRLF line endings throughout. + remoteContent := "# --- fullsend managed below - do not edit ---\r\nfresh shim template\r\n" + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: `exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, exitCode := h.run() + + assert.Equal(t, 0, exitCode, "script should exit successfully") + assert.Contains(t, output, "already enrolled (shim up to date)", + "CRLF content should be recognized as up-to-date after normalization") + assert.NotContains(t, output, "shim is stale", + "CRLF differences should not cause false drift") + }) + + t.Run("[test_id:TS-GH77-013] should handle mixed line endings correctly", func(t *testing.T) { + h := newReconcileHarness(t) + + // Remote content has mixed line endings: first line CRLF, second line LF. + remoteContent := "# --- fullsend managed below - do not edit ---\r\nfresh shim template\n" + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: `exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, exitCode := h.run() + + assert.Equal(t, 0, exitCode, "script should exit successfully") + assert.NotContains(t, output, "shim is stale", + "mixed line endings should not cause false drift") + assert.Contains(t, output, "already enrolled (shim up to date)", + "mixed-ending content should be recognized as up-to-date") + }) +} diff --git a/internal/scaffold/qf_drift_detection_test.go b/internal/scaffold/qf_drift_detection_test.go new file mode 100644 index 000000000..9dab3400e --- /dev/null +++ b/internal/scaffold/qf_drift_detection_test.go @@ -0,0 +1,115 @@ +package scaffold + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +/* +Shim Drift Detection Tests — Encoding-Insensitive Comparison + +STP Reference: outputs/stp/GH-77/GH-77_test_plan.md +Jira: GH-77 + +These tests verify the fix for issue #2247: the old managed_content_b64() +comparison re-encoded content to base64, amplifying trivial trailing newline +differences into mismatched base64 strings. The fix compares decoded text +instead of re-encoded base64. +*/ + +func TestShimDriftDetection(t *testing.T) { + t.Run("[test_id:TS-GH77-001] should not flag identical content with different trailing newlines as stale", func(t *testing.T) { + h := newReconcileHarness(t) + + // The remote has the same template content but with an extra trailing newline, + // which produces different base64 from shim_content_b64(). This simulates + // encoding differences from the GitHub Content API. + templateContent := "# --- fullsend managed below - do not edit ---\nfresh shim template\n" + remoteContent := templateContent + "\n" // extra trailing newline + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: `exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, exitCode := h.run() + + assert.Equal(t, 0, exitCode, "script should exit successfully") + assert.Contains(t, output, "already enrolled (shim up to date)", + "identical content with trailing newline difference should be recognized as up-to-date") + assert.NotContains(t, output, "shim is stale", + "identical content should NOT be flagged as stale") + assert.False(t, h.blobExists("test-repo"), + "no blob should be created for encoding-only differences") + }) + + t.Run("[test_id:TS-GH77-002] should produce already enrolled status for up-to-date shim", func(t *testing.T) { + h := newReconcileHarness(t) + + // Remote shim includes user header + sentinel + matching managed portion. + remoteContent := "# Copyright 2026 Conforma\n# SPDX-License-Identifier: Apache-2.0\n# --- fullsend managed below - do not edit ---\nfresh shim template\n" + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: `exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, exitCode := h.run() + + assert.Equal(t, 0, exitCode, "script should exit successfully") + assert.Contains(t, output, "already enrolled (shim up to date)", + "up-to-date shim should be recognized as current") + assert.Contains(t, output, "Skipped (already reconciled): 1", + "SKIPPED counter should be incremented") + assert.False(t, h.blobExists("test-repo"), + "no blob or PR should be created for up-to-date shim") + }) + + t.Run("[test_id:TS-GH77-003] should not create blob or PR for encoding-only differences", func(t *testing.T) { + h := newReconcileHarness(t) + + // Remote has identical text but with trailing newline variation, + // causing different base64 encoding. + templateContent := "# --- fullsend managed below - do not edit ---\nfresh shim template\n" + remoteContent := templateContent + "\n" // trailing newline diff + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: `exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, exitCode := h.run() + + assert.Equal(t, 0, exitCode, "script should exit successfully") + + // Verify no blob creation. + assert.False(t, h.blobExists("test-repo"), + "no blob-input JSON should exist for encoding-only differences") + + // Verify no git/blobs endpoint called. + ghLog := h.ghCallsLog() + assert.NotContains(t, ghLog, "git/blobs", + "no git/blobs API call should be made") + + // Verify no PR creation. + assert.NotContains(t, ghLog, "pr create", + "no PR creation should occur for encoding-only differences") + + // Verify the repo was recognized as up-to-date. + assert.Contains(t, output, "already enrolled (shim up to date)") + }) +} diff --git a/internal/scaffold/qf_pre_sentinel_fallback_test.go b/internal/scaffold/qf_pre_sentinel_fallback_test.go new file mode 100644 index 000000000..714808a40 --- /dev/null +++ b/internal/scaffold/qf_pre_sentinel_fallback_test.go @@ -0,0 +1,131 @@ +package scaffold + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +/* +Pre-Sentinel Shim Fallback Tests — Full Decoded Content Comparison + +STP Reference: outputs/stp/GH-77/GH-77_test_plan.md +Jira: GH-77 + +These tests verify behavior when the remote shim has no sentinel line +(pre-sentinel shim from before the header-preservation feature). The +script falls back to comparing full decoded content. +*/ + +func TestPreSentinelShimFallback(t *testing.T) { + t.Run("[test_id:TS-GH77-007] should compare full decoded content for pre-sentinel shim", func(t *testing.T) { + h := newReconcileHarness(t) + + // Pre-sentinel shim: no sentinel line, stale content. + remoteContent := "stale shim template\n" + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: ` +case "$2" in + list) + # Check --head flag for existing PR. + for arg in "$@"; do + if [[ "$arg" == "fullsend/onboard" ]]; then + echo "https://github.com/test-org/test-repo/pull/42" + fi + done + exit 0 ;; + create) echo "https://github.com/test-org/test-repo/pull/99"; exit 0 ;; + close) exit 0 ;; +esac +exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, _ := h.run() + + assert.Contains(t, output, "shim is stale", + "pre-sentinel shim with different content should be flagged as stale") + + // Verify blob is created with sentinel + fresh template (migration). + assert.True(t, h.blobExists("test-repo"), + "blob should be created for pre-sentinel shim update") + blobDecoded := h.blobContent("test-repo") + assert.Contains(t, blobDecoded, "# --- fullsend managed below - do not edit ---", + "updated blob should include sentinel line (migration to new format)") + assert.Contains(t, blobDecoded, "fresh shim template", + "updated blob should contain fresh template content") + assert.NotContains(t, blobDecoded, "stale shim template", + "old content should NOT be duplicated in the blob") + }) + + t.Run("[test_id:TS-GH77-008] should not flag pre-sentinel shim with identical content as stale", func(t *testing.T) { + h := newReconcileHarness(t) + + // Pre-sentinel shim whose content matches the full template + // (sentinel + fresh template). No user header. + remoteContent := "# --- fullsend managed below - do not edit ---\nfresh shim template\n" + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: `exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, exitCode := h.run() + + assert.Equal(t, 0, exitCode, "script should exit successfully") + assert.Contains(t, output, "already enrolled (shim up to date)", + "matching pre-sentinel shim should be recognized as current") + assert.False(t, h.blobExists("test-repo"), + "no blob should be created for matching pre-sentinel shim") + }) + + t.Run("[test_id:TS-GH77-009] should flag pre-sentinel shim with different content as stale", func(t *testing.T) { + h := newReconcileHarness(t) + + // Pre-sentinel shim with completely different body. + remoteContent := "old workflow template v0\n" + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: ` +case "$2" in + list) + for arg in "$@"; do + if [[ "$arg" == "fullsend/onboard" ]]; then + echo "https://github.com/test-org/test-repo/pull/42" + fi + done + exit 0 ;; + create) echo "https://github.com/test-org/test-repo/pull/99"; exit 0 ;; + close) exit 0 ;; +esac +exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, _ := h.run() + + assert.Contains(t, output, "shim is stale", + "diverged pre-sentinel shim should be flagged as stale") + + // Verify blob has fresh template. + assert.True(t, h.blobExists("test-repo"), + "blob should be created for diverged pre-sentinel shim") + blobDecoded := h.blobContent("test-repo") + assert.Contains(t, blobDecoded, "fresh shim template", + "blob should contain fresh template content") + }) +} diff --git a/internal/scaffold/qf_reconcile_test_helpers_test.go b/internal/scaffold/qf_reconcile_test_helpers_test.go new file mode 100644 index 000000000..443808441 --- /dev/null +++ b/internal/scaffold/qf_reconcile_test_helpers_test.go @@ -0,0 +1,321 @@ +package scaffold + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// reconcileHarness encapsulates common test infrastructure for reconcile-repos.sh tests. +// Each test creates a harness, customizes the mock gh binary, runs the script, +// and asserts on stdout/stderr/artifacts. +type reconcileHarness struct { + t *testing.T + tmpDir string + configDir string + mockBin string + ghLog string + scriptPath string +} + +// newReconcileHarness creates a temporary directory with config.yaml, shim template, +// and mock base64/yq binaries. The caller must provide a mock gh binary via writeGHMock. +func newReconcileHarness(t *testing.T) *reconcileHarness { + t.Helper() + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "config") + mockBin := filepath.Join(tmpDir, "bin") + ghLog := filepath.Join(tmpDir, "gh-calls.log") + + require.NoError(t, os.MkdirAll(filepath.Join(configDir, "templates"), 0o755)) + require.NoError(t, os.MkdirAll(mockBin, 0o755)) + + // Resolve the absolute path to reconcile-repos.sh from the test's working directory. + scriptPath, err := filepath.Abs("fullsend-repo/scripts/reconcile-repos.sh") + require.NoError(t, err) + require.FileExists(t, scriptPath) + + h := &reconcileHarness{ + t: t, + tmpDir: tmpDir, + configDir: configDir, + mockBin: mockBin, + ghLog: ghLog, + scriptPath: scriptPath, + } + + h.writeDefaultConfig() + h.writeDefaultTemplate() + h.writeMockBase64() + h.writeMockYQ([]string{"test-repo"}, nil) + + return h +} + +// writeDefaultConfig writes a config.yaml with a single enabled repo. +func (h *reconcileHarness) writeDefaultConfig() { + h.t.Helper() + config := `version: 1 +repos: + test-repo: + enabled: true +` + require.NoError(h.t, os.WriteFile(filepath.Join(h.configDir, "config.yaml"), []byte(config), 0o644)) +} + +// writeConfig writes a custom config.yaml. +func (h *reconcileHarness) writeConfig(content string) { + h.t.Helper() + require.NoError(h.t, os.WriteFile(filepath.Join(h.configDir, "config.yaml"), []byte(content), 0o644)) +} + +// writeDefaultTemplate writes the shim template with sentinel + "fresh shim template". +func (h *reconcileHarness) writeDefaultTemplate() { + h.t.Helper() + template := "# --- fullsend managed below - do not edit ---\nfresh shim template\n" + require.NoError(h.t, os.WriteFile( + filepath.Join(h.configDir, "templates", "shim-workflow-call.yaml"), + []byte(template), 0o644)) +} + +// writeMockBase64 creates a mock base64 that delegates to /usr/bin/base64 +// but strips newlines when called with -w0. +func (h *reconcileHarness) writeMockBase64() { + h.t.Helper() + script := `#!/usr/bin/env bash +if [[ "${1:-}" == "-w0" ]]; then + shift + /usr/bin/base64 "$@" | tr -d '\r\n' +else + /usr/bin/base64 "$@" +fi +` + path := filepath.Join(h.mockBin, "base64") + require.NoError(h.t, os.WriteFile(path, []byte(script), 0o755)) +} + +// writeMockYQ creates a mock yq that returns the given enabled and disabled repos. +func (h *reconcileHarness) writeMockYQ(enabled, disabled []string) { + h.t.Helper() + enabledStr := strings.Join(enabled, "\n") + disabledStr := strings.Join(disabled, "\n") + script := fmt.Sprintf(`#!/usr/bin/env bash +query="${1:-}" +if [[ "$query" == *"enabled == true"* ]]; then + printf '%%s\n' %s +elif [[ "$query" == *"enabled == false"* ]]; then + printf '%%s\n' %s +else + echo "unexpected yq query: $*" >&2 + exit 1 +fi +`, shellescape(enabledStr), shellescape(disabledStr)) + path := filepath.Join(h.mockBin, "yq") + require.NoError(h.t, os.WriteFile(path, []byte(script), 0o755)) +} + +// shellescape wraps a string in single quotes for safe shell embedding. +func shellescape(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + +// writeGHMock writes a mock gh binary. The caseBlock is inserted into a +// case statement that matches on the API endpoint. The prBlock handles +// "gh pr" subcommands. Blob input is automatically captured. +func (h *reconcileHarness) writeGHMock(opts ghMockOpts) { + h.t.Helper() + + script := fmt.Sprintf(`#!/usr/bin/env bash +set -euo pipefail +printf 'gh' >> %s +for arg in "$@"; do + printf ' %%q' "$arg" >> %s +done +printf '\n' >> %s + +# Handle pr subcommands. +if [[ "$1" == "pr" ]]; then + %s + exit 0 +fi + +if [[ "$1" != "api" ]]; then + exit 0 +fi + +jq_filter="" +has_input=false +method="GET" +shift # consume "api" +endpoint="$1"; shift +while [[ $# -gt 0 ]]; do + case "$1" in + --jq) jq_filter="$2"; shift 2 ;; + --input) has_input=true; shift 2 ;; + --method) method="$2"; shift 2 ;; + --field) shift 2 ;; + --silent) shift ;; + *) shift ;; + esac +done + +# Capture blob input. +input_data="" +if [[ "$has_input" == "true" ]]; then + input_data=$(cat) + if [[ "$endpoint" == *"/git/blobs" ]]; then + blob_repo=$(printf '%%s' "$endpoint" | sed 's|repos/[^/]*/||;s|/git/blobs||') + printf '%%s' "$input_data" > %s/blob-input-${blob_repo}.json + fi +fi + +json="" +rc=0 +case "$endpoint" in + repos/test-org/*/actions/variables/*) + json='{"status":"404","message":"Not Found"}' + rc=1 + ;; + %s + repos/test-org/*/git/ref/heads/*) + json='{"object":{"sha":"base-sha"}}' + ;; + repos/test-org/*/git/commits/base-sha) + json='{"tree":{"sha":"base-tree-sha"}}' + ;; + repos/test-org/*/git/blobs) + json='{"sha":"blob-sha"}' + ;; + repos/test-org/*/git/trees) + json='{"sha":"tree-sha"}' + ;; + repos/test-org/*/git/commits) + json='{"sha":"desired-commit-sha"}' + ;; + repos/test-org/*/git/refs) + rc=1 + ;; + repos/test-org/*/git/refs/heads/*) + rc=0 + ;; + repos/test-org/*) + json='{"default_branch":"main","private":false}' + ;; + *) + rc=0 + ;; +esac + +if [[ -n "$json" ]]; then + if [[ -n "$jq_filter" ]]; then + printf '%%s' "$json" | jq -r "$jq_filter" + else + printf '%%s\n' "$json" + fi +fi +exit "$rc" +`, + shellescape(h.ghLog), + shellescape(h.ghLog), + shellescape(h.ghLog), + opts.prBlock, + shellescape(h.tmpDir), + opts.apiCases, + ) + path := filepath.Join(h.mockBin, "gh") + require.NoError(h.t, os.WriteFile(path, []byte(script), 0o755)) +} + +// ghMockOpts configures the mock gh binary behavior. +type ghMockOpts struct { + // prBlock is shell code handling "gh pr" subcommands (runs inside if [[ "$1" == "pr" ]]). + prBlock string + // apiCases are additional case clauses for the API endpoint case statement. + // They must end with ;; and should be placed before the wildcard repos/test-org/* case. + apiCases string +} + +// run executes reconcile-repos.sh and returns stdout+stderr combined output. +func (h *reconcileHarness) run() (string, int) { + h.t.Helper() + cmd := exec.Command("bash", h.scriptPath, h.configDir) + cmd.Env = []string{ + "PATH=" + h.mockBin + string(os.PathListSeparator) + os.Getenv("PATH"), + "GITHUB_REPOSITORY_OWNER=test-org", + "GITHUB_SHA=test-sha", + "GH_TOKEN=fake-token", + "HOME=" + os.Getenv("HOME"), + } + output, err := cmd.CombinedOutput() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + h.t.Fatalf("failed to run reconcile-repos.sh: %v", err) + } + } + return string(output), exitCode +} + +// blobContent reads and base64-decodes the blob input captured for a given repo. +// Returns empty string if no blob was captured. +func (h *reconcileHarness) blobContent(repo string) string { + h.t.Helper() + path := filepath.Join(h.tmpDir, fmt.Sprintf("blob-input-%s.json", repo)) + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "" + } + require.NoError(h.t, err) + + var blob struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + } + if err := json.Unmarshal(data, &blob); err != nil { + h.t.Logf("blob JSON parse error: %v, raw: %s", err, string(data)) + return "" + } + if blob.Content == "" { + return "" + } + decoded, err := base64.StdEncoding.DecodeString(blob.Content) + if err != nil { + // Try with padding adjustment. + decoded, err = base64.RawStdEncoding.DecodeString(blob.Content) + require.NoError(h.t, err, "failed to decode blob content: %s", blob.Content) + } + return string(decoded) +} + +// blobExists checks whether a blob input file was captured for the given repo. +func (h *reconcileHarness) blobExists(repo string) bool { + h.t.Helper() + path := filepath.Join(h.tmpDir, fmt.Sprintf("blob-input-%s.json", repo)) + _, err := os.Stat(path) + return err == nil +} + +// ghCallsLog returns the content of the gh-calls.log file. +func (h *reconcileHarness) ghCallsLog() string { + h.t.Helper() + data, err := os.ReadFile(h.ghLog) + if os.IsNotExist(err) { + return "" + } + require.NoError(h.t, err) + return string(data) +} + +// b64encode base64-encodes a string (no line wrapping). +func b64encode(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} diff --git a/internal/scaffold/qf_skip_behavior_test.go b/internal/scaffold/qf_skip_behavior_test.go new file mode 100644 index 000000000..be6895a28 --- /dev/null +++ b/internal/scaffold/qf_skip_behavior_test.go @@ -0,0 +1,96 @@ +package scaffold + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +/* +Up-to-Date Shim Skip Behavior Tests + +STP Reference: outputs/stp/GH-77/GH-77_test_plan.md +Jira: GH-77 + +These tests verify that up-to-date shims are correctly skipped: no blob +creation, no API writes, and the SKIPPED counter is incremented. +*/ + +func TestUpToDateShimSkipBehavior(t *testing.T) { + t.Run("[test_id:TS-GH77-010] should not create blob for up-to-date shim", func(t *testing.T) { + h := newReconcileHarness(t) + + // Remote content exactly matches the template. + remoteContent := "# --- fullsend managed below - do not edit ---\nfresh shim template\n" + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: `exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, exitCode := h.run() + + assert.Equal(t, 0, exitCode) + assert.Contains(t, output, "already enrolled (shim up to date)") + + // Verify no blob creation. + assert.False(t, h.blobExists("test-repo"), + "no blob-input JSON should exist for up-to-date shim") + + // Verify no git/blobs endpoint called. + ghLog := h.ghCallsLog() + assert.NotContains(t, ghLog, "git/blobs", + "no git/blobs API call should be made for up-to-date shim") + }) + + t.Run("[test_id:TS-GH77-011] should increment skip counter for current shim", func(t *testing.T) { + h := newReconcileHarness(t) + + // Configure two repos: one up-to-date, one stale. + h.writeConfig(`version: 1 +repos: + uptodate-repo: + enabled: true + stale-repo: + enabled: true +`) + h.writeMockYQ([]string{"uptodate-repo", "stale-repo"}, nil) + + uptodateContent := "# --- fullsend managed below - do not edit ---\nfresh shim template\n" + uptodateB64 := b64encode(uptodateContent) + staleContent := "# --- fullsend managed below - do not edit ---\nstale shim template\n" + staleB64 := b64encode(staleContent) + + h.writeGHMock(ghMockOpts{ + prBlock: ` +case "$2" in + list) exit 0 ;; + create) echo "https://github.com/test-org/mock/pull/99"; exit 0 ;; + close) exit 0 ;; +esac +exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/uptodate-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; + repos/test-org/stale-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, uptodateB64, staleB64), + }) + + output, _ := h.run() + + // Verify the summary shows at least 1 skipped repo. + assert.Contains(t, output, "Skipped (already reconciled): 1", + "SKIPPED counter should reflect the up-to-date repo") + + // Verify updated counter for the stale repo. + assert.Contains(t, output, "Updated (stale shim): 1", + "UPDATED counter should reflect the stale repo") + }) +} diff --git a/internal/scaffold/qf_stale_detection_test.go b/internal/scaffold/qf_stale_detection_test.go new file mode 100644 index 000000000..7694e9a39 --- /dev/null +++ b/internal/scaffold/qf_stale_detection_test.go @@ -0,0 +1,124 @@ +package scaffold + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +/* +Stale Shim Detection Tests — Genuine Drift Triggers Update PR + +STP Reference: outputs/stp/GH-77/GH-77_test_plan.md +Jira: GH-77 + +These tests verify that genuinely stale shims (where the managed content +has actually changed) are correctly detected and trigger update PRs. +*/ + +func TestStaleShimDetection(t *testing.T) { + t.Run("[test_id:TS-GH77-004] should trigger update PR for genuinely stale shim", func(t *testing.T) { + h := newReconcileHarness(t) + + // Remote shim has user header + sentinel + stale managed content. + remoteContent := "# Copyright 2026 Conforma\n# SPDX-License-Identifier: Apache-2.0\n# --- fullsend managed below - do not edit ---\nstale shim template\n" + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: ` +case "$2" in + list) exit 0 ;; + create) echo "https://github.com/test-org/test-repo/pull/99"; exit 0 ;; + close) exit 0 ;; +esac +exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, _ := h.run() + + assert.Contains(t, output, "shim is stale", + "genuinely stale shim should be detected") + + // Verify blob is created with fresh template content. + assert.True(t, h.blobExists("test-repo"), + "blob should be created for stale shim update") + blobDecoded := h.blobContent("test-repo") + assert.Contains(t, blobDecoded, "fresh shim template", + "blob should contain the updated template content") + + // Verify user header is preserved. + assert.Contains(t, blobDecoded, "# Copyright 2026 Conforma", + "user license header should be preserved in the updated blob") + + // Verify PR was created. + assert.Contains(t, output, "Created shim update PR") + + // Verify UPDATED counter. + assert.Contains(t, output, "Updated (stale shim): 1") + }) + + t.Run("[test_id:TS-GH77-005] should detect stale shim after template content change", func(t *testing.T) { + h := newReconcileHarness(t) + + // Remote has correct sentinel but different managed body (old version). + remoteContent := "# --- fullsend managed below - do not edit ---\nold workflow version v1\n" + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: ` +case "$2" in + list) exit 0 ;; + create) echo "https://github.com/test-org/test-repo/pull/99"; exit 0 ;; + close) exit 0 ;; +esac +exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, _ := h.run() + + assert.Contains(t, output, "shim is stale", + "template body change should be detected as drift") + }) + + t.Run("[test_id:TS-GH77-006] should handle error when update PR creation fails", func(t *testing.T) { + h := newReconcileHarness(t) + + // Remote has stale content to trigger update path. + remoteContent := "# --- fullsend managed below - do not edit ---\nstale shim template\n" + remoteB64 := b64encode(remoteContent) + + h.writeGHMock(ghMockOpts{ + prBlock: ` +case "$2" in + list) exit 0 ;; + create) + echo "Permission denied" >&2 + exit 1 ;; + close) exit 0 ;; +esac +exit 0`, + apiCases: fmt.Sprintf(`repos/test-org/test-repo/contents/*) + json='{"content":"%s","sha":"file-sha"}' + ;; +`, remoteB64), + }) + + output, exitCode := h.run() + + assert.NotEqual(t, 0, exitCode, + "script should exit with non-zero code when PR creation fails") + assert.Contains(t, output, "::error::Failed to create", + "error annotation should be emitted for failed PR creation") + assert.Contains(t, output, "Failed: 1", + "FAILED counter should be incremented") + }) +}