diff --git a/agent/server/snykbroker/acceptfile/accept_file.go b/agent/server/snykbroker/acceptfile/accept_file.go index 3f3752c..0713bf3 100644 --- a/agent/server/snykbroker/acceptfile/accept_file.go +++ b/agent/server/snykbroker/acceptfile/accept_file.go @@ -52,6 +52,12 @@ func NewAcceptFile(content []byte, cfg config.AgentConfig, logger *zap.Logger) ( } af.wrapper = newAcceptFileWrapper(processedContent, af) + // Deliberately no strict validation here. Parsing an accept file is shared + // with the snyk-broker path, where the Node broker honours constructs this + // package does not — refusing them here would break a deployment that + // works today. The Router warns about them when it is built instead, where + // they would otherwise be silently dropped. + warnIgnoredPublicRules(af.wrapper.dict, af.logger) return af, nil } diff --git a/agent/server/snykbroker/acceptfile/origin_contract_test.go b/agent/server/snykbroker/acceptfile/origin_contract_test.go new file mode 100644 index 0000000..ff9a174 --- /dev/null +++ b/agent/server/snykbroker/acceptfile/origin_contract_test.go @@ -0,0 +1,119 @@ +package acceptfile + +import ( + "testing" + + "github.com/cortexapps/axon/config" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// AcceptFileRuleWrapper.Origin() is the one accessor this package shares with +// the snyk-broker reflector: relay_instance_manager.go reads it to decide +// whether a rule needs a wildcard policy, to build the reflector proxy URI, and +// to report a bad origin. Nothing else in here is reachable from that path — +// Path() and MatchRule have no callers outside this package and grpctunnel. +// +// It is pinned here, before the routing work that follows refactors it, so a +// change to the shared accessor cannot quietly alter what the reflector sees. +func TestOriginContract(t *testing.T) { + cases := []struct { + name string + origin string + env map[string]string + want string + }{ + { + name: "absolute origin is returned verbatim", + origin: "https://api.github.com", + want: "https://api.github.com", + }, + { + name: "origin keeps its base path", + origin: "https://git.example/api/v3", + want: "https://git.example/api/v3", + }, + { + name: "origin keeps its port", + origin: "http://localhost:9999", + want: "http://localhost:9999", + }, + { + name: "plaintext scheme is not upgraded", + origin: "http://internal.example", + want: "http://internal.example", + }, + { + name: "a scheme-less origin defaults to https", + origin: "github.com", + want: "https://github.com", + }, + { + name: "a scheme-less default from ${VAR:default} defaults to https", + origin: "${GITHUB:github.com}", + want: "https://github.com", + }, + { + name: "${VAR} expands from the environment", + origin: "${GITHUB_API}", + env: map[string]string{"GITHUB_API": "https://ghe.example/api/v3"}, + want: "https://ghe.example/api/v3", + }, + { + name: "${VAR:default} prefers the environment when set", + origin: "${GITHUB:github.com}", + env: map[string]string{"GITHUB": "https://ghe.example"}, + want: "https://ghe.example", + }, + { + // The reflector greps Origin() for "*" to decide a rule needs a + // wildcard policy, so the star has to survive verbatim. + name: "a wildcard family survives untouched", + origin: "https://*.googleapis.com", + want: "https://*.googleapis.com", + }, + { + name: "a wildcard family keeps its port", + origin: "https://*.api.example.net:8443", + want: "https://*.api.example.net:8443", + }, + { + name: "a wildcard family from a ${VAR:default}", + origin: "${GOOGLE_API:https://*.googleapis.com}", + want: "https://*.googleapis.com", + }, + { + name: "credentials in the origin are preserved", + origin: "http://user:pass@localhost:9000", + want: "http://user:pass@localhost:9000", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + cfg := config.AgentConfig{HttpServerPort: 8080, PluginDirs: []string{}} + content := `{"private":[{"method":"any","path":"/*","origin":"` + tc.origin + `"}]}` + af, err := NewAcceptFile([]byte(content), cfg, zap.NewNop()) + require.NoError(t, err) + + rules := af.Wrapper().PrivateRules() + require.Len(t, rules, 1) + require.Equal(t, tc.want, rules[0].Origin()) + }) + } +} + +// A rule with no origin reads as empty rather than panicking: the reflector +// calls Origin() on every private rule before anything has vetted them. +func TestOriginOfRuleWithoutOneIsEmpty(t *testing.T) { + cfg := config.AgentConfig{HttpServerPort: 8080, PluginDirs: []string{}} + af, err := NewAcceptFile([]byte(`{"private":[{"method":"any","path":"/*"}]}`), cfg, zap.NewNop()) + require.NoError(t, err) + + rules := af.Wrapper().PrivateRules() + require.Len(t, rules, 1) + require.Equal(t, "", rules[0].Origin()) +} diff --git a/agent/server/snykbroker/acceptfile/router.go b/agent/server/snykbroker/acceptfile/router.go index f8c766e..0ccae1c 100644 --- a/agent/server/snykbroker/acceptfile/router.go +++ b/agent/server/snykbroker/acceptfile/router.go @@ -50,11 +50,15 @@ func NewRouter(rules []AcceptFileRuleWrapper, logger *zap.Logger) *Router { if logger == nil { logger = zap.NewNop() } - return &Router{ + rt := &Router{ rules: rules, pools: NewPoolManager(), logger: logger.Named("accept-router"), } + for _, rule := range rules { + warnUnsupportedRule(rule.dict, rt.logger) + } + return rt } // Route resolves a request to a RoutedRequest. rawPath may carry a query @@ -92,7 +96,22 @@ func (rt *Router) Route(method, rawPath string, headers map[string]string) (*Rou // Inject rule headers (overrides incoming). if ruleHeaders := rule.Headers(); ruleHeaders != nil { - for k, v := range ruleHeaders.ToStringMap() { + // A credential provider that fails has to stop the request rather than + // let its placeholder travel upstream as the credential — the refusal + // then comes back as an authorization failure and names the wrong + // culprit, which is the thing #127 set out to stop. The reflector's + // serve() answers 502 here; returning an error lands in the default + // arm of grpctunnel's RouteError mapping, which is also a 502, so both + // transports refuse the same way. + resolved, err := ruleHeaders.ToStringMap() + if err != nil { + rt.logger.Error("Credential provider failed", + zap.String("rulePath", rule.Path()), + zap.Error(err), + ) + return nil, fmt.Errorf("credential provider failed: %w", err) + } + for k, v := range resolved { header.Set(k, v) } } diff --git a/agent/server/snykbroker/acceptfile/router_credential_test.go b/agent/server/snykbroker/acceptfile/router_credential_test.go new file mode 100644 index 0000000..a8b456e --- /dev/null +++ b/agent/server/snykbroker/acceptfile/router_credential_test.go @@ -0,0 +1,56 @@ +package acceptfile + +import ( + "testing" + + "github.com/cortexapps/axon/config" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// A credential provider that fails has to refuse the request, not let its +// placeholder travel upstream as the credential — the upstream then refuses on +// authorization and the log names the wrong culprit. #127 made ResolverMap +// report that failure instead of swallowing it, and taught the reflector's +// serve() to answer 502. This pins the same behaviour on the tunnel path, +// which reads the same ResolverMap through Router.Route. +// +// Route returns a plain error here rather than a classified one, which is what +// puts it in the default arm of grpctunnel's RouteError mapping — a 502, the +// same status the reflector sends. +func TestRouteRefusesWhenACredentialProviderFails(t *testing.T) { + cfg := config.AgentConfig{HttpServerPort: 8080, PluginDirs: []string{"."}} + af, err := NewAcceptFile([]byte(`{"private":[{ + "method": "any", + "path": "/*", + "origin": "https://api.example", + "headers": {"authorization": "${plugin:plugin_fail.sh}"} + }]}`), cfg, zap.NewNop()) + require.NoError(t, err) + + router := NewRouter(af.Wrapper().PrivateRules(), zap.NewNop()) + + _, err = router.Route("GET", "/x", nil) + require.Error(t, err, "a failing credential provider must fail the request") + require.Contains(t, err.Error(), "credential provider failed") + require.NotErrorIs(t, err, ErrNoRoute, "the rule matched; it was the credential that failed") +} + +// The companion case: a provider that succeeds still reaches the upstream, so +// the check above is refusing on the failure rather than on having a plugin. +func TestRouteCarriesAResolvedPluginCredential(t *testing.T) { + cfg := config.AgentConfig{HttpServerPort: 8080, PluginDirs: []string{"."}} + af, err := NewAcceptFile([]byte(`{"private":[{ + "method": "any", + "path": "/*", + "origin": "https://api.example", + "headers": {"x-plugin-output": "${plugin:plugin.sh}"} + }]}`), cfg, zap.NewNop()) + require.NoError(t, err) + + router := NewRouter(af.Wrapper().PrivateRules(), zap.NewNop()) + + req, err := router.Route("GET", "/x", nil) + require.NoError(t, err) + require.NotEmpty(t, req.Header.Get("x-plugin-output")) +} diff --git a/agent/server/snykbroker/acceptfile/supported.go b/agent/server/snykbroker/acceptfile/supported.go new file mode 100644 index 0000000..0735e6f --- /dev/null +++ b/agent/server/snykbroker/acceptfile/supported.go @@ -0,0 +1,96 @@ +package acceptfile + +import ( + "go.uber.org/zap" +) + +// Nothing in an accept file stops the agent. +// +// Enabling the tunnel switches deployments that are running on snyk-broker +// today, and that switch has to be transparent: a file the broker accepts must +// still start. Anything the Router cannot carry is warned about and ignored, so +// an operator relying on it finds out from the log rather than from an agent +// that will not boot, and can stay on snyk-broker until we implement it. +// +// What ends up here are constructs snyk-broker honours that the Router does not +// implement — body and query "valid" filters, requiredCapabilities. Ignoring +// one widens the rule, so the warning says exactly that. +// The one thing still refused is a malformed wildcard origin, and that is not a +// migration risk: the snyk-broker path already refuses it too, at render +// (ErrWildcardOriginRequiresTLSVerification, the invalid-origin error) or by +// panicking when the reflector is disabled. No working deployment has one, and +// treating a bad family as permissive would authorize hosts nobody chose. + +// warnIgnoredPublicRules logs once for an accept file that declares inbound +// rules, and reports how many it found. +// +// A "public" block describes webhook traffic the relay does not carry, so it +// cannot widen what the agent will call outbound — and enough files carry one +// copied from a snyk-broker config that refusing them would break working +// deployments over a section that routes nothing. +// +// An empty block is silent: it is what Render itself emits, and warning about +// it would train everyone to ignore the warning. +func warnIgnoredPublicRules(dict map[string]any, logger *zap.Logger) int { + rules, ok := dict[RULES_PUBLIC].([]any) + if !ok || len(rules) == 0 { + return 0 + } + logger.Warn( + "Ignoring inbound rules in the accept file: the relay carries no inbound traffic, "+ + "so these route nothing. Support for the section will be removed — remove it from your accept file.", + zap.String("section", RULES_PUBLIC), + zap.Int("rules", len(rules)), + ) + return len(rules) +} + +// warnUnsupportedRule logs whatever in a rule the Router will not act on, and +// reports how many warnings it emitted. +func warnUnsupportedRule(rule map[string]any, logger *zap.Logger) int { + path, _ := rule["path"].(string) + log := logger.With(zap.String("rulePath", path)) + warnings := 0 + + if _, present := rule["requiredCapabilities"]; present { + warnings++ + log.Warn( + "Ignoring \"requiredCapabilities\" on an accept file rule: the relay negotiates no " + + "client capabilities, so the rule is allowed through unconditionally. " + + "snyk-broker would reject a request that did not meet them.") + } + + validEntries, ok := rule["valid"].([]any) + if !ok { + return warnings + } + for _, entry := range validEntries { + entryDict, ok := entry.(map[string]any) + if !ok { + continue + } + if key := unsupportedValidKey(entryDict); key != "" { + warnings++ + log.Warn( + "Ignoring a \"valid\" entry on an accept file rule: the relay does not inspect the "+ + "request body or query string, so this narrowing is not applied and the rule "+ + "matches more than it does under snyk-broker.", + zap.String("filter", key)) + } + } + return warnings +} + +// unsupportedValidKey names why a "valid" entry cannot be honoured, or "" when +// it is a header requirement the matcher does apply. +func unsupportedValidKey(entry map[string]any) string { + for _, key := range []string{"path", "regex", "queryParam"} { + if _, present := entry[key]; present { + return key + } + } + if header, _ := entry["header"].(string); header == "" { + return "no recognized key" + } + return "" +} diff --git a/agent/server/snykbroker/acceptfile/unsupported_test.go b/agent/server/snykbroker/acceptfile/unsupported_test.go new file mode 100644 index 0000000..9752185 --- /dev/null +++ b/agent/server/snykbroker/acceptfile/unsupported_test.go @@ -0,0 +1,348 @@ +package acceptfile + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/cortexapps/axon/config" +) + +// Nothing in an accept file stops the agent. +// +// Enabling the tunnel switches deployments running on snyk-broker today, and +// that switch has to be transparent: a file the broker accepts must still +// start. Anything the Router cannot carry is warned about and ignored. +// +// The refusal that used to be here is gone, and the warnings live at Router +// construction rather than at parse: parsing is shared with the snyk-broker +// path, where the Node broker honours these constructs, so warning at parse +// would tell an operator their working rule is being dropped when it is not. +// TestSnykBrokerConstructsStillParse pins that boundary. + +// loadWithLogs parses an accept file with a logger the test can inspect. +func loadWithLogs(t *testing.T, content string) (*AcceptFile, []observer.LoggedEntry, error) { + t.Helper() + core, logs := observer.New(zapcore.WarnLevel) + cfg := config.AgentConfig{HttpServerPort: 8080, PluginDirs: []string{}} + af, err := NewAcceptFile([]byte(content), cfg, zap.New(core)) + return af, logs.All(), err +} + +// routerWithLogs builds the Router, which is where a construct it cannot carry +// gets warned about. +func routerWithLogs(t *testing.T, content string) (*Router, []observer.LoggedEntry) { + t.Helper() + core, logs := observer.New(zapcore.WarnLevel) + cfg := config.AgentConfig{HttpServerPort: 8080, PluginDirs: []string{}} + + af, err := NewAcceptFile([]byte(content), cfg, zap.New(core)) + require.NoError(t, err, "parsing must never refuse an accept file") + rendered, err := af.Render(zap.NewNop()) + require.NoError(t, err) + af2, err := NewAcceptFile(rendered, cfg, zap.New(core)) + require.NoError(t, err) + + var rules []AcceptFileRuleWrapper + for _, r := range af2.Wrapper().PrivateRules() { + if r.Path() != "/__axon/*" { + rules = append(rules, r) + } + } + return NewRouter(rules, zap.New(core)), logs.All() +} + +// requireWarns asserts the file loads, the Router builds, and a warning names +// the construct being ignored. +func requireWarns(t *testing.T, content, mentions string) []observer.LoggedEntry { + t.Helper() + rt, logs := routerWithLogs(t, content) + require.NotNil(t, rt) + require.NotEmpty(t, logs, "an ignored construct has to be warned about") + + for _, entry := range logs { + if containsAny(entry, mentions) { + return logs + } + } + require.Failf(t, "no warning mentioned the construct", + "looking for %q in %v", mentions, messages(logs)) + return logs +} + +func containsAny(entry observer.LoggedEntry, needle string) bool { + if strings.Contains(entry.Message, needle) { + return true + } + for _, f := range entry.Context { + if strings.Contains(f.String, needle) { + return true + } + } + return false +} + +func messages(logs []observer.LoggedEntry) []string { + out := make([]string, 0, len(logs)) + for _, l := range logs { + out = append(out, l.Message) + } + return out +} + +// --------------------------------------------------------------------------- +// valid: only header requirements are applied +// --------------------------------------------------------------------------- + +// A body filter narrows the rule in snyk-broker. The Router does not inspect +// bodies, so the rule matches more here than it did there — say so rather than +// refuse to start. +func TestBodyValidFilterWarnsAndIsIgnored(t *testing.T) { + requireWarns(t, `{"private":[{ + "method":"POST","path":"/*","origin":"https://up.example", + "valid":[{"path":"proxy.*","value":"please"}]}]}`, "path") +} + +func TestBodyRegexValidFilterWarnsAndIsIgnored(t *testing.T) { + requireWarns(t, `{"private":[{ + "method":"POST","path":"/*","origin":"https://up.example", + "valid":[{"path":"commits.*.added.*","regex":"package.json"}]}]}`, "path") +} + +func TestQueryValidFilterWarnsAndIsIgnored(t *testing.T) { + requireWarns(t, `{"private":[{ + "method":"GET","path":"/*","origin":"https://up.example", + "valid":[{"queryParam":"proxyMe","values":["please"]}]}]}`, "queryParam") +} + +// The header entry is still applied; only the query entry is dropped. +func TestMixedValidArrayWarnsOnlyForTheUnsupportedEntry(t *testing.T) { + rt, logs := routerWithLogs(t, `{"private":[{ + "method":"GET","path":"/*","origin":"https://up.example", + "valid":[ + {"header":"x-cortex-service","values":["scaffolder"]}, + {"queryParam":"proxyMe","values":["please"]} + ]}]}`) + require.Len(t, logs, 1) + + // The header requirement still gates the rule. + _, err := rt.Route("GET", "/thing", nil) + require.ErrorIs(t, err, ErrNoRoute, "the header requirement must still apply") + + routed, err := rt.Route("GET", "/thing", map[string]string{"x-cortex-service": "scaffolder"}) + require.NoError(t, err) + require.Equal(t, "https://up.example/thing", routed.URL.String()) +} + +func TestValidEntryWithNoRecognizedKeyWarns(t *testing.T) { + requireWarns(t, `{"private":[{ + "method":"GET","path":"/*","origin":"https://up.example", + "valid":[{"values":["scaffolder"]}]}]}`, "no recognized key") +} + +func TestHeaderValidFilterIsAppliedSilently(t *testing.T) { + _, logs := routerWithLogs(t, `{"private":[{ + "method":"GET","path":"/*","origin":"https://up.example", + "valid":[{"header":"x-cortex-service","values":["scaffolder"]}]}]}`) + require.Empty(t, logs) +} + +// The broker's own comment convention appears inside valid entries too. +func TestCommentKeysInsideValidAreIgnoredSilently(t *testing.T) { + _, logs := routerWithLogs(t, `{"private":[{ + "method":"GET","path":"/*","origin":"https://up.example", + "valid":[{"//":"scaffolder only","header":"x-cortex-service","values":["scaffolder"]}]}]}`) + require.Empty(t, logs) +} + +// --------------------------------------------------------------------------- +// Snyk-specific rule fields +// --------------------------------------------------------------------------- + +// The relay negotiates no client capabilities, so the gate cannot be evaluated +// and the rule is allowed through. snyk-broker would have rejected the request. +func TestRequiredCapabilitiesWarnsAndIsIgnored(t *testing.T) { + requireWarns(t, `{"private":[{ + "method":"GET","path":"/*","origin":"https://up.example", + "requiredCapabilities":["post-streams"]}]}`, "requiredCapabilities") +} + +// The tunnel streams every body, so "stream": true asks for what it already +// does — no warning, nothing to ignore. +func TestStreamFieldIsAcceptedSilently(t *testing.T) { + _, logs := routerWithLogs(t, `{"private":[{ + "method":"GET","path":"/*","origin":"https://up.example","stream":true}]}`) + require.Empty(t, logs) +} + +// --------------------------------------------------------------------------- +// Inbound rules +// --------------------------------------------------------------------------- + +func TestPublicRulesAreIgnoredWithAWarning(t *testing.T) { + af, logs, err := loadWithLogs(t, `{"private":[ + {"method":"any","path":"/*","origin":"https://up.example"}], + "public":[ + {"method":"POST","path":"/webhook/github","origin":"https://up.example"}]}`) + require.NoError(t, err, "an inbound section must not stop the agent") + require.NotNil(t, af) + + require.Len(t, logs, 1, "exactly one warning, not one per rule") + require.Equal(t, zapcore.WarnLevel, logs[0].Level) + require.Contains(t, logs[0].Message, "Ignoring inbound rules") + require.Contains(t, logs[0].Message, "will be removed", + "the warning has to say the section is going away") + + require.Len(t, af.Wrapper().PrivateRules(), 1) +} + +// The section survives a round trip even though nothing reads it, so an +// operator editing the rendered file still sees what they wrote. +func TestPublicRulesArePreservedThroughRender(t *testing.T) { + af, _, err := loadWithLogs(t, `{"private":[],"public":[ + {"method":"POST","path":"/webhook/github"}]}`) + require.NoError(t, err) + rendered, err := af.Render(zap.NewNop()) + require.NoError(t, err) + require.Contains(t, string(rendered), `"/webhook/github"`) +} + +// Render always emits an empty public array, and re-parsing its own output must +// neither fail nor warn — warning on the shape we ourselves produce would train +// everyone to ignore the warning. +func TestEmptyPublicArrayIsAcceptedSilently(t *testing.T) { + for _, content := range []string{ + `{}`, + `{"private":[]}`, + `{"public":[]}`, + `{"private":[],"public":[]}`, + } { + t.Run(content, func(t *testing.T) { + _, logs, err := loadWithLogs(t, content) + require.NoError(t, err) + require.Empty(t, logs, "an empty inbound section must be silent") + }) + } +} + +// --------------------------------------------------------------------------- +// Nothing refuses an accept file +// --------------------------------------------------------------------------- + +// Every construct the Router cannot carry is one snyk-broker implements. A +// deployment switching onto the tunnel has to start on the file it is already +// running, so none of these may stop either the parse or the Router. +func TestNoAcceptFileConstructStopsTheAgent(t *testing.T) { + for name, content := range map[string]string{ + "body filter": `{"private":[{"method":"POST","path":"/*","origin":"https://up.example", + "valid":[{"path":"proxy.*","value":"please"}]}]}`, + "body regex filter": `{"private":[{"method":"POST","path":"/*","origin":"https://up.example", + "valid":[{"path":"commits.*.added.*","regex":"package.json"}]}]}`, + "query filter": `{"private":[{"method":"GET","path":"/*","origin":"https://up.example", + "valid":[{"queryParam":"proxyMe","values":["please"]}]}]}`, + "required capabilities": `{"private":[{"method":"GET","path":"/*","origin":"https://up.example", + "requiredCapabilities":["post-streams"]}]}`, + "inbound rules": `{"private":[{"method":"any","path":"/*","origin":"https://up.example"}], + "public":[{"method":"POST","path":"/webhook/github"}]}`, + "stream": `{"private":[{"method":"GET","path":"/*","origin":"https://up.example","stream":true}]}`, + "unknown rule field": `{"private":[{"method":"GET","path":"/*","origin":"https://up.example","someFutureField":{"a":1}}]}`, + "comment key": `{"private":[{"//":"note","method":"GET","path":"/*","origin":"https://up.example"}]}`, + "everything at once": `{"private":[{"method":"POST","path":"/*","origin":"https://up.example", + "requiredCapabilities":["x"], + "valid":[{"path":"a.b","value":"c"},{"queryParam":"q","values":["v"]}]}], + "public":[{"method":"POST","path":"/hook"}]}`, + } { + t.Run(name, func(t *testing.T) { + _, _, err := loadWithLogs(t, content) + require.NoError(t, err, "parsing must not refuse this") + rt, _ := routerWithLogs(t, content) + require.NotNil(t, rt, "the Router must not refuse this") + }) + } +} + +// The snyk-broker path keeps working. Every construct the Router warns about is +// one the Node broker implements, so parsing has to stay silent about it: an +// operator on snyk-broker must not be told their working rule is being dropped. +func TestSnykBrokerConstructsStillParse(t *testing.T) { + for name, content := range map[string]string{ + "body filter": `{"private":[{"method":"POST","path":"/*","origin":"https://up.example", + "valid":[{"path":"proxy.*","value":"please"}]}]}`, + "query filter": `{"private":[{"method":"GET","path":"/*","origin":"https://up.example", + "valid":[{"queryParam":"proxyMe","values":["please"]}]}]}`, + "required capabilities": `{"private":[{"method":"GET","path":"/*","origin":"https://up.example", + "requiredCapabilities":["post-streams"]}]}`, + } { + t.Run(name, func(t *testing.T) { + _, logs, err := loadWithLogs(t, content) + require.NoError(t, err, "the Node broker honours this; parsing must not refuse it") + require.Empty(t, logs, "nor tell a snyk-broker operator it is being ignored") + }) + } +} + +func TestUnknownRuleKeysAreAcceptedSilently(t *testing.T) { + _, logs := routerWithLogs(t, `{"private":[{ + "//":"the catch-all API rule", + "method":"any","path":"/*","origin":"https://up.example", + "someFutureField":{"a":1}}]}`) + require.Empty(t, logs) +} + +func TestEveryShippedAcceptFileLoads(t *testing.T) { + for _, file := range acceptFilesUnderTest(t) { + t.Run(file, func(t *testing.T) { + for _, v := range fileEnvVars(t, file) { + t.Setenv(v, "https://"+v+".example") + } + _, _, err := loadWithLogs(t, readFile(t, file)) + require.NoError(t, err) + }) + } +} + +// Accept files the repo hands to the agent — the shipped templates and the +// fixtures the docker E2E suites run with. Without the fixtures here, a +// construct this package starts refusing surfaces only as a container that +// will not start, several CI minutes later. +func acceptFilesUnderTest(t *testing.T) []string { + t.Helper() + files := builtinAcceptFiles(t) + for _, fixture := range []string{ + filepath.Join("..", "..", "..", "test", "relay", "accept-client.json"), + filepath.Join("..", "..", "..", "test", "load", "accept-load.json"), + } { + require.FileExists(t, fixture) + files = append(files, fixture) + } + return files +} + +// readFile is a t.Fatal-on-error os.ReadFile. +func readFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + require.NoError(t, err) + return string(content) +} + +// fileEnvVars lists the environment variables an accept file references, so a +// test can give each a deterministic value before loading it. +func fileEnvVars(t *testing.T, path string) []string { + t.Helper() + var names []string + seen := map[string]bool{} + for _, m := range reFileVar.FindAllStringSubmatch(readFile(t, path), -1) { + if !seen[m[1]] { + seen[m[1]] = true + names = append(names, m[1]) + } + } + return names +}