Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ADR-47206: Table-Driven Tests and Internal Coverage for actionpins

**Date**: 2026-07-22
**Status**: Draft
**Deciders**: pelikhan, copilot-swe-agent

---

### Context

The `pkg/actionpins` package is a core library responsible for resolving, mapping, and pinning GitHub Actions references and container images. Its internal test file (`actionpins_internal_test.go`) had grown a set of per-scenario test functions for `applyActionPinMapping` (six separate `TestApplyActionPinMapping_*` functions), each with duplicated `PinContext` setup boilerplate. Additionally, several internal helper functions — `getLatestActionPinReference`, `recordPinResolutionFailure`, `logDynamicResolutionSkipped`, and the container-pin branch of `loadActionPinsData` — had no direct test coverage, leaving edge-case nil-safety and boundary behaviors unverified. The PR #47206 was opened to close these gaps and improve test maintainability.

### Decision

We will consolidate the `applyActionPinMapping` per-scenario tests into a single table-driven `TestApplyActionPinMapping` function, and add four new internal test functions targeting previously uncovered code paths (`TestLoadActionPinsData_LoadsContainerPins`, `TestGetLatestActionPinReference_ReturnsFormattedReferenceOrEmpty`, `TestLogDynamicResolutionSkipped_NoResolverBranch`, `TestRecordPinResolutionFailure_NilSafety`). The table-driven approach is chosen because it is idiomatic Go, reduces boilerplate for each new scenario, and surfaces all cases in a single view, making it easier to audit coverage.

### Alternatives Considered

#### Alternative 1: Continue Adding Per-Scenario Test Functions

The status quo approach — each mapping scenario gets its own top-level `TestApplyActionPinMapping_*` function. Why considered: matches the existing style and requires no refactoring. Why not chosen: the six existing functions already contain significant boilerplate duplication; adding further scenarios would compound maintenance cost and make it harder to see at a glance which cases are covered. The `repeat` field needed for the deduplication scenario is also awkward to express without the table struct.

#### Alternative 2: Move Missing Coverage to Black-Box Spec Tests in spec_test.go

Add coverage for `getLatestActionPinReference`, `recordPinResolutionFailure`, and `logDynamicResolutionSkipped` in the `actionpins_test` (black-box) package rather than the internal test package. Why considered: black-box tests are less brittle because they don't depend on unexported symbols. Why not chosen: the functions under test are unexported (`getLatestActionPinReference`, `recordPinResolutionFailure`, `logDynamicResolutionSkipped`); they cannot be called from `actionpins_test`. Internal tests are the only way to exercise them directly, and the nil-safety behavior is only observable via those unexported entry points.

### Consequences

#### Positive
- Adding new `applyActionPinMapping` scenarios now requires adding a single struct literal to the test table rather than a new top-level function, lowering the per-scenario authoring cost.
- Previously untested nil-safety and no-resolver branches are now explicitly verified, reducing the risk of panics in production edge cases (nil `PinContext`, nil `RecordResolutionFailure` callback).
- The container-pin branch of `loadActionPinsData` is now independently verified without relying on the embedded data fixture.
- A package-level doc comment added to `spec_test.go` clarifies the purpose of the black-box test module for future contributors.

#### Negative
- Table-driven tests with a large number of fields (`name`, `actionRepo`, `version`, `mappings`, `repeat`, `wantRepo`, `wantVersion`, `wantMappingNotification`, `wantMapNotificationKeys`) can be harder to scan when a single case fails — the failure message must be read alongside the struct to reconstruct the intent.
- The `repeat` field is non-obvious: it exists solely for the deduplication scenario; all other cases default to `repeat = 1` via `max(tt.repeat, 1)`. This implicit default must be understood by future editors.

#### Neutral
- No production code was changed; test-only changes do not affect binary artifacts or runtime behavior.
- The consolidation deletes 79 lines and adds 181, for a net increase of 102 lines — the expanded table carries more information than the removed boilerplate.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
260 changes: 181 additions & 79 deletions pkg/actionpins/actionpins_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,32 @@ func TestLoadActionPinsData_PanicsWhenEntrySHAIsEmpty(t *testing.T) {
}, "Expected loadActionPinsData to panic when embedded pin data contains an empty SHA")
}

func TestLoadActionPinsData_LoadsContainerPins(t *testing.T) {
fixture := []byte(`{
"entries": {
"actions/checkout@v5": {
"repo": "actions/checkout",
"version": "v5",
"sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
"containers": {
"node:lts-alpine": {
"image": "node:lts-alpine",
"digest": "sha256:deadbeef",
"pinned_image": "node:lts-alpine@sha256:deadbeef"
}
}
}`)

data := loadActionPinsData(fixture)

require.Contains(t, data.Containers, "node:lts-alpine", "Expected container pin key to be loaded")
assert.Equal(t, "node:lts-alpine", data.Containers["node:lts-alpine"].Image)
assert.Equal(t, "sha256:deadbeef", data.Containers["node:lts-alpine"].Digest)
assert.Equal(t, "node:lts-alpine@sha256:deadbeef", data.Containers["node:lts-alpine"].PinnedImage)
}

func TestFormatPinnedActionReference_PanicsWhenSHAIsEmpty(t *testing.T) {
assert.Panics(t, func() {
FormatPinnedActionReference("ruby/setup-ruby", "", "v1.319.0")
Expand Down Expand Up @@ -305,6 +331,20 @@ func TestFindVersionBySHA_ReturnsVersionForKnownSHA(t *testing.T) {
})
}

func TestGetLatestActionPinReference_ReturnsFormattedReferenceOrEmpty(t *testing.T) {
t.Run("returns formatted reference for known repo", func(t *testing.T) {
pins := GetActionPinsByRepo("actions/checkout")
require.NotEmpty(t, pins, "prerequisite: embedded pins must exist for actions/checkout")

result := getLatestActionPinReference("actions/checkout")
assert.Equal(t, FormatPinnedActionReference("actions/checkout", pins[0].SHA, pins[0].Version), result)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] pins[0] coupling may assert the wrong value — getLatestActionPinReference implies latest, but the test assumes pins[0] is the latest without verifying sort order.

💡 Suggestion

If GetActionPinsByRepo guarantees a stable order (e.g., newest-first), document that assumption here. Otherwise, assert properties of the result rather than coupling to index 0.

// assert it is a non-empty, well-formed reference string instead of pinning to pins[0]
assert.NotEmpty(t, result)
assert.Contains(t, result, "actions/checkout@")

A future re-ordering of embedded pins would silently produce a wrong assertion.

@copilot please address this.


t.Run("returns empty string for unknown repo", func(t *testing.T) {
assert.Empty(t, getLatestActionPinReference("does-not-exist/unknown"))
})
}

func TestGetContainerPin_ReturnsPinnedImage(t *testing.T) {
pin, ok := GetContainerPin("node:lts-alpine")
require.True(t, ok, "Expected embedded container pin for node:lts-alpine")
Expand Down Expand Up @@ -376,6 +416,45 @@ func TestResolveActionPinDynamically_SkipsForSHAInput(t *testing.T) {
assert.Zero(t, resolver.called, "Expected resolver not to be called for SHA input")
}

func TestLogDynamicResolutionSkipped_NoResolverBranch(t *testing.T) {
assert.NotPanics(t, func() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] TestLogDynamicResolutionSkipped_NoResolverBranch only tests (false, false) — the function has a second branch (the resolver-present path) that is untested here.

💡 Suggestion

Add a sub-test for the resolver-present branch to ensure both code paths are exercised:

t.Run("no resolver — does not panic", func(t *testing.T) {
    assert.NotPanics(t, func() { logDynamicResolutionSkipped(false, false) })
})
t.Run("with resolver — does not panic", func(t *testing.T) {
    assert.NotPanics(t, func() { logDynamicResolutionSkipped(true, false) })
})

Without this, the resolver-present branch remains uncovered by the new test.

@copilot please address this.

logDynamicResolutionSkipped(false, false)
}, "Expected no-resolver branch to be safe")
}

func TestRecordPinResolutionFailure_NilSafety(t *testing.T) {
t.Run("nil context is safe", func(t *testing.T) {
assert.NotPanics(t, func() {
recordPinResolutionFailure(nil, "actions/checkout", "v4", ResolutionErrorTypePinNotFound)
})
})

t.Run("nil callback is safe", func(t *testing.T) {
ctx := &PinContext{}
assert.NotPanics(t, func() {
recordPinResolutionFailure(ctx, "actions/checkout", "v4", ResolutionErrorTypePinNotFound)
})
})

t.Run("records failure when callback is configured", func(t *testing.T) {
var got []ResolutionFailure
ctx := &PinContext{
RecordResolutionFailure: func(f ResolutionFailure) {
got = append(got, f)
},
}

recordPinResolutionFailure(ctx, "actions/checkout", "v4", ResolutionErrorTypeDynamicResolutionFailed)

require.Len(t, got, 1, "Expected one resolution failure record")
assert.Equal(t, ResolutionFailure{
Repo: "actions/checkout",
Ref: "v4",
ErrorType: ResolutionErrorTypeDynamicResolutionFailed,
}, got[0])
})
}

func TestResolveActionPinFromHardcodedPins_StrictModeNoFallback(t *testing.T) {
ctx := &PinContext{StrictMode: true, Warnings: make(map[string]bool)}

Expand Down Expand Up @@ -523,93 +602,116 @@ func TestResolveActionPinFromHardcodedPins_SkipHardcodedFallback(t *testing.T) {
})
}

func TestApplyActionPinMapping_NoMapping(t *testing.T) {
ctx := &PinContext{Warnings: make(map[string]bool)}

repo, version := applyActionPinMapping("actions/checkout", "v4", ctx)

assert.Equal(t, "actions/checkout", repo, "repo should be unchanged when no mapping exists")
assert.Equal(t, "v4", version, "version should be unchanged when no mapping exists")
}

func TestApplyActionPinMapping_AppliesMapping(t *testing.T) {
ctx := &PinContext{
Warnings: make(map[string]bool),
Mappings: map[string]string{
"actions/checkout@v4": "acme-corp/checkout@v4",
func TestApplyActionPinMapping(t *testing.T) {
tests := []struct {
name string
actionRepo string
version string
mappings map[string]string
repeat int
wantRepo string
wantVersion string
wantMappingNotification bool
wantMapNotificationKeys int
}{
{
name: "no mapping",
actionRepo: "actions/checkout",
version: "v4",
wantRepo: "actions/checkout",
wantVersion: "v4",
wantMappingNotification: false,
wantMapNotificationKeys: 0,
},
}

repo, version := applyActionPinMapping("actions/checkout", "v4", ctx)

assert.Equal(t, "acme-corp/checkout", repo, "repo should be replaced by mapping")
assert.Equal(t, "v4", version, "version should be replaced by mapping")
}

func TestApplyActionPinMapping_OnlyMatchesExact(t *testing.T) {
ctx := &PinContext{
Warnings: make(map[string]bool),
Mappings: map[string]string{
"actions/checkout@v4": "acme-corp/checkout@v4",
{
name: "applies exact mapping",
actionRepo: "actions/checkout",
version: "v4",
mappings: map[string]string{
"actions/checkout@v4": "acme-corp/checkout@v4",
},
wantRepo: "acme-corp/checkout",
wantVersion: "v4",
wantMappingNotification: true,
wantMapNotificationKeys: 1,
},
}

// Different version — should not match.
repo, version := applyActionPinMapping("actions/checkout", "v5", ctx)

assert.Equal(t, "actions/checkout", repo, "repo should be unchanged when version does not match")
assert.Equal(t, "v5", version, "version should be unchanged when version does not match")
}

func TestApplyActionPinMapping_DeduplicatesInfoMessage(t *testing.T) {
ctx := &PinContext{
Warnings: make(map[string]bool),
Mappings: map[string]string{
"actions/checkout@v4": "acme-corp/checkout@v4",
{
name: "does not match different version",
actionRepo: "actions/checkout",
version: "v5",
mappings: map[string]string{
"actions/checkout@v4": "acme-corp/checkout@v4",
},
wantRepo: "actions/checkout",
wantVersion: "v5",
wantMappingNotification: false,
wantMapNotificationKeys: 0,
},
}

applyActionPinMapping("actions/checkout", "v4", ctx)
applyActionPinMapping("actions/checkout", "v4", ctx)

notifyKey := "map:actions/checkout@v4"
assert.True(t, ctx.Warnings[notifyKey], "Expected notification key to be marked as seen")
// Count should be exactly one entry (deduplication).
mapNotifications := 0
for k := range ctx.Warnings {
if strings.HasPrefix(k, "map:") {
mapNotifications++
}
}
assert.Equal(t, 1, mapNotifications, "Expected exactly one deduplicated mapping notification")
}

func TestApplyActionPinMapping_InvalidMappingValueSkipped(t *testing.T) {
ctx := &PinContext{
Warnings: make(map[string]bool),
Mappings: map[string]string{
"actions/checkout@v4": "no-at-sign", // invalid: missing at sign and version
{
name: "deduplicates notification for repeated mapping",
actionRepo: "actions/checkout",
version: "v4",
mappings: map[string]string{
"actions/checkout@v4": "acme-corp/checkout@v4",
},
repeat: 2,
wantRepo: "acme-corp/checkout",
wantVersion: "v4",
wantMappingNotification: true,
wantMapNotificationKeys: 1,
},
{
name: "skips invalid mapping value",
actionRepo: "actions/checkout",
version: "v4",
mappings: map[string]string{
"actions/checkout@v4": "no-at-sign",
},
wantRepo: "actions/checkout",
wantVersion: "v4",
wantMappingNotification: false,
wantMapNotificationKeys: 0,
},
{
name: "skips mapping target without ref separator",
actionRepo: "actions/checkout",
version: "v4",
mappings: map[string]string{
"actions/checkout@v4": "acme-corp/checkout",
},
wantRepo: "actions/checkout",
wantVersion: "v4",
wantMappingNotification: false,
wantMapNotificationKeys: 0,
},
}

repo, version := applyActionPinMapping("actions/checkout", "v4", ctx)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &PinContext{
Warnings: make(map[string]bool),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The table-driven TestApplyActionPinMapping uses repeat only for the deduplication case but silently defaults to 1 via max(tt.repeat, 1) for all others — a zero repeat in a struct literal is easy to misread as "run 0 times".

💡 Suggestion

Consider making the intent explicit with a named constant or by changing the zero-value semantics:

// Option A: default clearly in the test harness comment
repeat := tt.repeat
if repeat == 0 {
    repeat = 1 // zero means "run once"
}

// Option B: use a pointer or a named field like runTimes

This is a minor readability issue, but it can confuse contributors adding future cases who might set repeat: 0 expecting no calls.

@copilot please address this.

Mappings: tt.mappings,
}
repeat := max(tt.repeat, 1)

// Invalid value should be silently skipped.
assert.Equal(t, "actions/checkout", repo, "repo should be unchanged for invalid mapping value")
assert.Equal(t, "v4", version, "version should be unchanged for invalid mapping value")
}
var gotRepo, gotVersion string
for range repeat {
gotRepo, gotVersion = applyActionPinMapping(tt.actionRepo, tt.version, ctx)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deduplication test only validates the final iteration's return values: gotRepo and gotVersion are overwritten on every loop pass; only the last call is ever asserted. For the repeat: 2 deduplication case, a bug where the first call returns wrong values (e.g., un-mapped repo/version on the first application) is completely invisible.

💡 Suggested fix

Assert inside the loop, or capture per-iteration results separately:

for i := range repeat {
    repo, version := applyActionPinMapping(tt.actionRepo, tt.version, ctx)
    assert.Equalf(t, tt.wantRepo, repo, "iteration %d: repo mismatch", i)
    assert.Equalf(t, tt.wantVersion, version, "iteration %d: version mismatch", i)
}

This makes each iteration's output observable and will catch regressions where the idempotency of repeated calls breaks.

}

func TestApplyActionPinMapping_TargetWithoutRefSeparatorSkipped(t *testing.T) {
ctx := &PinContext{
Warnings: make(map[string]bool),
Mappings: map[string]string{
"actions/checkout@v4": "acme-corp/checkout", // invalid: missing @ separator
},
}
assert.Equal(t, tt.wantRepo, gotRepo, "repo should match expected mapping outcome")
assert.Equal(t, tt.wantVersion, gotVersion, "version should match expected mapping outcome")

repo, version := applyActionPinMapping("actions/checkout", "v4", ctx)
notifyKey := "map:" + FormatCacheKey(tt.actionRepo, tt.version)
assert.Equal(t, tt.wantMappingNotification, ctx.Warnings[notifyKey], "mapping notification flag should match expected state")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absent key and explicit false are indistinguishable: assert.Equal(t, false, ctx.Warnings[notifyKey]) passes whether the key was never written or was set to false — Go returns the zero value for missing map keys. A wantMappingNotification: false test case will pass even if the production code accidentally wrote the key, hiding the bug silently.

💡 Suggested fix

Replace the single assertion with presence/absence checks:

if tt.wantMappingNotification {
    assert.True(t, ctx.Warnings[notifyKey], "expected mapping notification key to be set")
} else {
    assert.NotContains(t, ctx.Warnings, notifyKey, "expected mapping notification key to be absent")
}

There is a second latent risk here: notifyKey is constructed by mirroring the production internal prefix ("map:"). If that prefix ever changes in applyActionPinMapping, the test will still pass — the checked key is absent, zero-value is false, which matches wantMappingNotification: false — masking the regression instead of catching it.


assert.Equal(t, "actions/checkout", repo, "repo should be unchanged when mapping target has no @ separator")
assert.Equal(t, "v4", version, "version should be unchanged when mapping target has no @ separator")
assert.NotContains(t, ctx.Warnings, "map:actions/checkout@v4", "invalid mapping target should not set mapping warning key")
mapNotifications := 0
for k := range ctx.Warnings {
if strings.HasPrefix(k, "map:") {
mapNotifications++
}
}
assert.Equal(t, tt.wantMapNotificationKeys, mapNotifications, "unexpected number of mapping notification keys")
})
}
}
1 change: 1 addition & 0 deletions pkg/actionpins/spec_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//go:build !integration

// Package actionpins_test contains black-box specification tests for action pin resolution.
package actionpins_test

import (
Expand Down
Loading