Skip to content

Commit f38c39f

Browse files
test: make network-dependent unit tests hermetic
Two unit tests made live outbound HTTP calls and failed on any runner without egress (air-gapped CI, outages, rate limits). Make them not need the network at all, reusing loopback patterns already present in the repo. - rego: replace the two "allowed network requests" subtests (which dialed www.chainloop.dev and github.com) with TestRego_AllowedHostnames, which asserts allow-list membership, and TestRego_AllowedHostIsReachable, which reaches an allowed host on a loopback httptest server. Drop the now-unused restricted_mode_networking_allowed_host.rego testfile. The forbidden functions/network subtests are unchanged. - materials: replace the "non-existent path" OCI case (which reached Docker Hub for its UNAUTHORIZED response) with TestOCIImageCraft_UnresolvableRemoteReference against an in-memory loopback registry, asserting the deterministic NAME_UNKNOWN error. No production behavior changes. Signed-off-by: Matías Insaurralde <matias@chainloop.dev>
1 parent c1834ac commit f38c39f

4 files changed

Lines changed: 67 additions & 85 deletions

File tree

‎pkg/attestation/crafter/materials/oci_image_test.go‎

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2025 The Chainloop Authors.
2+
// Copyright 2025-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -74,7 +74,6 @@ func TestOCIImageCraft_Layout(t *testing.T) {
7474
testCases := []struct {
7575
name string
7676
layoutPath string
77-
wantErr string
7877
wantDigest string
7978
wantName string
8079
wantTag string
@@ -97,11 +96,6 @@ func TestOCIImageCraft_Layout(t *testing.T) {
9796
wantName: "oci-layout:v1.51.0",
9897
wantDigest: "sha256:a5303ef28a4bd9b6e06aa92c07831dd151ac64172695971226bdba4a11fc1b88",
9998
},
100-
{
101-
name: "non-existent path",
102-
layoutPath: "/non/existent/path",
103-
wantErr: "UNAUTHORIZED",
104-
},
10599
}
106100

107101
for _, tc := range testCases {
@@ -115,11 +109,6 @@ func TestOCIImageCraft_Layout(t *testing.T) {
115109
require.NoError(t, err)
116110

117111
got, err := craftedMaterial(crafter.Craft(context.TODO(), tc.layoutPath))
118-
if tc.wantErr != "" {
119-
assert.ErrorContains(t, err, tc.wantErr)
120-
return
121-
}
122-
123112
require.NoError(t, err)
124113
assert.Equal(t, contractAPI.CraftingSchema_Material_CONTAINER_IMAGE.String(), got.MaterialType.String())
125114

@@ -139,6 +128,33 @@ func TestOCIImageCraft_Layout(t *testing.T) {
139128
}
140129
}
141130

131+
// TestOCIImageCraft_UnresolvableRemoteReference covers the path where the input
132+
// is neither an OCI layout directory nor a pullable image, so Craft resolves it
133+
// as a remote reference. It runs against an in-memory registry on loopback
134+
// instead of Docker Hub, so the failure is deterministic and needs no external
135+
// network.
136+
func TestOCIImageCraft_UnresolvableRemoteReference(t *testing.T) {
137+
server := httptest.NewServer(registry.New(registry.Logger(log.New(io.Discard, "", 0))))
138+
defer server.Close()
139+
140+
u, err := url.Parse(server.URL)
141+
require.NoError(t, err)
142+
143+
schema := &contractAPI.CraftingSchema_Material{
144+
Name: "test",
145+
Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE,
146+
}
147+
l := zerolog.Nop()
148+
crafter, err := materials.NewOCIImageCrafter(schema, authn.DefaultKeychain, &l)
149+
require.NoError(t, err)
150+
151+
// The repository was never pushed, so the registry answers with a not-found
152+
// error instead of serving a manifest.
153+
_, err = craftedMaterial(crafter.Craft(context.TODO(), fmt.Sprintf("%s/does-not-exist:latest", u.Host)))
154+
require.Error(t, err)
155+
assert.ErrorContains(t, err, "NAME_UNKNOWN")
156+
}
157+
142158
func TestOCIImageCraft_LayoutWithDigestSelector(t *testing.T) {
143159
testCases := []struct {
144160
name string

‎pkg/policies/engine/rego/httpsend_test.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,33 @@ violations contains resp.error.message if {
160160
assert.NotContains(t, result.Violations[0].Violation, "is not allowed")
161161
}
162162

163+
// TestRego_AllowedHostIsReachable proves that a host on the allow-list is
164+
// actually reached by http.send, using a loopback httptest server instead of a
165+
// live external host. PublicTargetsOnly is not set, so netguard does not block
166+
// the loopback target (see evalContext).
167+
func TestRego_AllowedHostIsReachable(t *testing.T) {
168+
var hits int
169+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
170+
hits++
171+
w.WriteHeader(http.StatusOK)
172+
}))
173+
defer srv.Close()
174+
175+
serverURL, err := url.Parse(srv.URL)
176+
require.NoError(t, err)
177+
178+
policy := &engine.Policy{
179+
Name: "allowed-host",
180+
Source: fmt.Appendf(nil, httpSendPolicyTemplate, fmt.Sprintf(`{"method": "GET", "url": %q}`, srv.URL)),
181+
}
182+
183+
// OPA matches the allowed host without its port.
184+
r := NewEngine(engine.WithAllowedHostnames(serverURL.Hostname()))
185+
_, err = r.Verify(context.TODO(), policy, []byte(`{}`), nil)
186+
require.NoError(t, err)
187+
assert.Equal(t, 1, hits)
188+
}
189+
163190
func TestRego_HTTPSendPublicTargetsOnly(t *testing.T) {
164191
// httptest listens on the loopback interface, so it stands in for any
165192
// destination that is not publicly routable.

‎pkg/policies/engine/rego/rego_test.go‎

Lines changed: 12 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -325,44 +325,19 @@ func TestRego_WithRestrictiveMode(t *testing.T) {
325325
assert.Error(t, err)
326326
assert.Contains(t, err.Error(), "eval_builtin_error: http.send: disallowed host: github.com")
327327
})
328+
}
328329

329-
t.Run("allowed network requests from defaults", func(t *testing.T) {
330-
regoContent, err := os.ReadFile("testfiles/restricted_mode_networking_allowed_host.rego")
331-
require.NoError(t, err)
332-
333-
r := NewEngine()
334-
policy := &engine.Policy{
335-
Name: "policy",
336-
Source: regoContent,
337-
}
338-
339-
_, err = r.Verify(context.TODO(), policy, []byte(`{}`), nil)
340-
assert.NoError(t, err)
341-
})
342-
343-
t.Run("allowed network requests from defaults plus custom domains", func(t *testing.T) {
344-
defaultHosts, err := os.ReadFile("testfiles/restricted_mode_networking_allowed_host.rego")
345-
require.NoError(t, err)
346-
customHosts, err := os.ReadFile("testfiles/restrictive_mode_networking.rego")
347-
require.NoError(t, err)
348-
349-
r := NewEngine(engine.WithAllowedHostnames("github.com"))
350-
policy := &engine.Policy{
351-
Name: "policy",
352-
Source: defaultHosts,
353-
}
354-
355-
_, err = r.Verify(context.TODO(), policy, []byte(`{}`), nil)
356-
assert.NoError(t, err)
357-
358-
policy = &engine.Policy{
359-
Name: "policy",
360-
Source: customHosts,
361-
}
362-
363-
_, err = r.Verify(context.TODO(), policy, []byte(`{}`), nil)
364-
assert.NoError(t, err)
365-
})
330+
// TestRego_AllowedHostnames asserts the host allow-list membership without any
331+
// network: that the defaults are present and that WithAllowedHostnames appends
332+
// to them rather than replacing them. Whether an allowed host is actually
333+
// reachable is covered hermetically by TestRego_AllowedHostIsReachable.
334+
func TestRego_AllowedHostnames(t *testing.T) {
335+
base := NewEngine().Capabilities().AllowNet
336+
assert.Contains(t, base, "www.chainloop.dev")
337+
assert.Contains(t, base, "www.cisa.gov")
338+
339+
withCustom := NewEngine(engine.WithAllowedHostnames("github.com")).Capabilities().AllowNet
340+
assert.Subset(t, withCustom, []string{"www.chainloop.dev", "www.cisa.gov", "github.com"})
366341
}
367342

368343
func TestRego_WithPermissiveMode(t *testing.T) {

‎pkg/policies/engine/rego/testfiles/restricted_mode_networking_allowed_host.rego‎

Lines changed: 0 additions & 36 deletions
This file was deleted.

0 commit comments

Comments
 (0)