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
117 changes: 117 additions & 0 deletions internal/testutil/egress_posture_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package testutil

import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
)

const egressPoisonProxy = "http://127.0.0.1:9"

func TestEgressPosture(t *testing.T) {
t.Run("poison_sentinel_denies_HTTP_egress", testPoisonSentinel)
t.Run("loopback_bypasses_lane_poison", testLoopbackBypass)
t.Run("raw_TCP_remains_open", testRawTCPResidual)
t.Run("effects_nil_proxy_remains_open", testEffectsProxyResidual)
}

func testPoisonSentinel(t *testing.T) {
proxyURL, err := url.Parse(egressPoisonProxy)
if err != nil {
t.Fatalf("parse poison proxy: %v", err)
}
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
t.Cleanup(transport.CloseIdleConnections)
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}

_, err = client.Get("https://example.com")
assertPoisonProxyError(t, err)
}

func testLoopbackBypass(t *testing.T) {
if !laneIsPoisoned() {
t.Skip("TestEgressPosture/loopback_bypasses_lane_poison requires HTTP_PROXY or HTTPS_PROXY=http://127.0.0.1:9")
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

transport := &http.Transport{Proxy: http.ProxyFromEnvironment}
t.Cleanup(transport.CloseIdleConnections)
response, err := (&http.Client{Transport: transport, Timeout: 5 * time.Second}).Get(server.URL)
if err != nil {
t.Fatalf("loopback GET under poisoned proxy: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
t.Fatalf("loopback status = %d, want %d", response.StatusCode, http.StatusNoContent)
}
}

func testRawTCPResidual(t *testing.T) {
requireLiveEgressPosture(t, "raw_TCP_remains_open")
connection, err := net.DialTimeout("tcp", "example.com:443", 10*time.Second)
if err != nil {
t.Fatalf("raw TCP residual unexpectedly closed: %v", err)
}
connection.Close()
}

func testEffectsProxyResidual(t *testing.T) {
requireLiveEgressPosture(t, "effects_nil_proxy_remains_open")
t.Setenv("HTTP_PROXY", egressPoisonProxy)
t.Setenv("HTTPS_PROXY", egressPoisonProxy)

// This first request intentionally trips red when Option B adds
// ProxyFromEnvironment to internal/effects. That red means the residual has
// closed; retire this tripwire and its Non-Goals text instead of "fixing" it.
effectsTransport := &http.Transport{}
t.Cleanup(effectsTransport.CloseIdleConnections)
response, err := (&http.Client{Transport: effectsTransport, Timeout: 10 * time.Second}).Get("https://example.com")
if err != nil {
t.Fatalf("nil-Proxy transport should bypass poison while D5 Option A remains: %v", err)
}
response.Body.Close()

controlTransport := &http.Transport{Proxy: http.ProxyFromEnvironment}
t.Cleanup(controlTransport.CloseIdleConnections)
_, err = (&http.Client{Transport: controlTransport, Timeout: 5 * time.Second}).Get("https://example.com")
assertPoisonProxyError(t, err)
}

func requireLiveEgressPosture(t *testing.T, leg string) {
t.Helper()
if os.Getenv("AILANG_LIVE_NET") != "1" {
t.Skipf("TestEgressPosture/%s requires AILANG_LIVE_NET=1", leg)
}
}

func laneIsPoisoned() bool {
return os.Getenv("HTTP_PROXY") == egressPoisonProxy || os.Getenv("HTTPS_PROXY") == egressPoisonProxy
}

func assertPoisonProxyError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("request through poison proxy unexpectedly succeeded")
}
message := err.Error()
// "refused", not "connection refused": the refusal wording is platform-specific.
// Unix reports `connect: connection refused`; Windows reports `connectex: No
// connection could be made because the target machine actively refused it.`
// All three tokens must match, so this stays discriminating — a direct hit or a
// DNS failure carries no `proxyconnect`/`127.0.0.1:9`, and a proxy that hangs
// rather than refusing carries no `refused`.
for _, required := range []string{"proxyconnect", "127.0.0.1:9", "refused"} {
if !strings.Contains(strings.ToLower(message), required) {
t.Fatalf("poison error %q does not contain %q", message, required)
}
}
t.Logf("observed poison sentinel error: %v", err)
}
30 changes: 30 additions & 0 deletions internal/testutil/gatelint/allowlist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package gatelint

type allowReason struct {
text string
}

func mustReason(text string) allowReason {
if text == "" {
panic("gatelint allowlist reasons must not be empty")
}
return allowReason{text: text}
}

var ruleAllowlist = map[Rule]map[string]allowReason{
RuleR2: {
"internal/coordinator/provider_script_test.go": mustReason("this is a Unix shell/grandchild signal semantics test, not a live-network test, so the network opt-in helper would misstate its requirement. CI runners skip the known flaky shell behavior while local Unix runs retain coverage."),
},
RuleR3: {
"internal/coordinator/agent_registry_test.go": mustReason("ailang-packages appears only in workspace-path fixtures; this test performs no live network call"),
"internal/parser/cli_integration_test.go": mustReason("httpbin.org appears only in expected CLI diagnostic fixture text; this test performs no live network call"),
"internal/parser/suggestion_errors_test.go": mustReason("httpbin.org appears only in parser suggestion/error fixtures; this test performs no live network call"),
"internal/messaging/config_test.go": mustReason("ailang-packages appears only in registry-mapping configuration fixtures; this test performs no live network call"),
"internal/pkg/manifest_test.go": mustReason("ailang-packages appears only in manifest parsing fixture URLs; this test performs no live network call"),
},
}

func isAllowlisted(rule Rule, path string) bool {
_, ok := ruleAllowlist[rule][path]
return ok
}
55 changes: 55 additions & 0 deletions internal/testutil/gatelint/gatelint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package gatelint

import (
"fmt"
"path/filepath"
"sort"
"strings"
"testing"
)

func TestGateLint_SelfTest(t *testing.T) {
root := filepath.Join("testdata", "fixtures")
violations, scanned := scan(root)
if scanned != 4 {
t.Fatalf("scanned %d candidate test files, want 4", scanned)
}

got := violationSet(violations)
want := []string{
"R1:internal/r1_test.go.fixture",
"R2:cmd/r2_test.go.fixture",
"R3:tests/r3_test.go.fixture",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("violations mismatch\ngot:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
}

func TestGateLint_Repo(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
if err != nil {
t.Fatalf("resolve repository root: %v", err)
}
violations, scanned := scan(root)
if scanned == 0 {
t.Fatal("gatelint scanned zero *_test.go files; repository-root or walker scope is broken")
}
if len(violations) != 0 {
lines := make([]string, len(violations))
for i, violation := range violations {
lines[i] = fmt.Sprintf("%s:%d: %s: %s", violation.Path, violation.Line, violation.Rule, violation.Message)
}
t.Fatalf("gatelint found %d violation(s) after scanning %d files:\n%s", len(violations), scanned, strings.Join(lines, "\n"))
}
t.Logf("scanned %d first-party test files", scanned)
}

func violationSet(violations []Violation) []string {
set := make([]string, len(violations))
for i, violation := range violations {
set[i] = string(violation.Rule) + ":" + violation.Path
}
sort.Strings(set)
return set
}
137 changes: 137 additions & 0 deletions internal/testutil/gatelint/scan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Package gatelint enforces the repository's explicit test-gating conventions.
package gatelint

import (
"bufio"
"errors"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)

// Rule identifies a gatelint rule.
type Rule string

const (
RuleR1 Rule = "R1"
RuleR2 Rule = "R2"
RuleR3 Rule = "R3"
)

// Violation describes one rule violation in a first-party test file.
type Violation struct {
Rule Rule
Path string
Line int
Message string
}

var scanRoots = []string{"internal", "cmd", "runtime", "std", "tests"}

// Scan checks first-party test files below root and returns violations in stable order.
func Scan(root string) []Violation {
violations, _ := scan(root)
return violations
}

func scan(root string) ([]Violation, int) {

Check failure on line 39 in internal/testutil/gatelint/scan.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 33 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=sunholo-data_ailang&issues=AZ_TI63bFillTclf1vJk&open=AZ_TI63bFillTclf1vJk&pullRequest=597
var violations []Violation
scanned := 0
for _, scanRoot := range scanRoots {
base := filepath.Join(root, scanRoot)
err := filepath.WalkDir(base, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if path != base && (strings.HasPrefix(entry.Name(), ".") || entry.Name() == "testdata") {
return filepath.SkipDir
}
return nil
}

logicalName, fixture := logicalFileName(entry.Name())
if !strings.HasSuffix(logicalName, "_test.go") {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if strings.HasPrefix(rel, "internal/testutil/gatelint/") && !fixture {
return nil
}

contents, err := os.ReadFile(path)
if err != nil {
return err
}
scanned++
violations = append(violations, inspect(rel, logicalName, string(contents))...)
return nil
})
if err != nil && !errors.Is(err, fs.ErrNotExist) {
violations = append(violations, Violation{Rule: "WALK", Path: filepath.ToSlash(scanRoot), Message: err.Error()})
}
}
sort.Slice(violations, func(i, j int) bool {
if violations[i].Path != violations[j].Path {
return violations[i].Path < violations[j].Path
}
if violations[i].Line != violations[j].Line {
return violations[i].Line < violations[j].Line
}
return violations[i].Rule < violations[j].Rule
})
return violations, scanned
}

func logicalFileName(name string) (string, bool) {
const fixtureSuffix = ".fixture"
if strings.HasSuffix(name, fixtureSuffix) {
return strings.TrimSuffix(name, fixtureSuffix), true
}
return name, false
}

func inspect(path, name, contents string) []Violation {
var violations []Violation
if line := tokenLine(contents, "testing.Short("); line != 0 && !isAllowlisted(RuleR1, path) {
violations = append(violations, Violation{Rule: RuleR1, Path: path, Line: line, Message: "testing.Short is inert in CI; delete the gate or use an explicit testutil opt-in helper"})
}
if line := firstTokenLine(contents, `Getenv("CI")`, `Getenv("GITHUB_ACTIONS")`); line != 0 && !isAllowlisted(RuleR2, path) {
violations = append(violations, Violation{Rule: RuleR2, Path: path, Line: line, Message: "CI environment opt-out gate; use the explicit testutil gating convention"})
}
if !strings.HasSuffix(name, "_live_test.go") &&
!strings.Contains(contents, "testutil.RequiresLiveNetwork(") &&
!isAllowlisted(RuleR3, path) {
if line := firstTokenLine(contents, "httpbin.org", "ailang-packages"); line != 0 {
violations = append(violations, Violation{Rule: RuleR3, Path: path, Line: line, Message: "known third-party token outside a live-network gate or documented allowlist"})
}
}
return violations
}

func firstTokenLine(contents string, tokens ...string) int {
first := 0
for _, token := range tokens {
line := tokenLine(contents, token)
if line != 0 && (first == 0 || line < first) {
first = line
}
}
return first
}

func tokenLine(contents, token string) int {
scanner := bufio.NewScanner(strings.NewReader(contents))
for line := 1; scanner.Scan(); line++ {
if strings.Contains(scanner.Text(), token) {
return line
}
}
return 0
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package fixture

func testGate() string { return os.Getenv("CI") }
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package fixture

func hiddenGate() { if testing.Short() {} }
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package fixture

func testGate() {
if testing.Short() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package fixture

const inertRegistryFixture = "ailang-packages"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package fixture

func TestClean(t *testing.T) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package fixture

const endpoint = "https://httpbin.org/post"
Loading