package aastro import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "fmt" "math/big" "net" "net/http" "os" "testing" "time" "github.com/go-chi/chi/v5" "github.com/goccy/go-json" "github.com/stretchr/testify/require" "go.uber.org/zap" "github.com/voidrunner3074/aastro/internal/circuitbreaker" "github.com/voidrunner3074/aastro/internal/metric" "github.com/voidrunner3074/aastro/sdk" ) var testMetrics *metric.Metrics func TestMain(m *testing.M) { tm, err := metric.New() if err != nil { _, _ = fmt.Fprintf(os.Stderr, "init test metrics: %v\n", err) os.Exit(1) } testMetrics = tm os.Exit(m.Run()) } // ── Upstream stubs ──────────────────────────────────────────────────────────── type mockUpstream struct { upstreamName string } func (s *mockUpstream) name() string { return s.upstreamName } func (s *mockUpstream) call(_ context.Context, _ *http.Request, _ []byte) *upstreamResponse { return &upstreamResponse{} } type mockProxyUpstream struct { upstreamName string proxyFn func(w http.ResponseWriter, req *http.Request) error } func (m *mockProxyUpstream) name() string { return m.upstreamName } func (m *mockProxyUpstream) call(_ context.Context, _ *http.Request, _ []byte) *upstreamResponse { return &upstreamResponse{} } func (m *mockProxyUpstream) proxy(_ context.Context, w http.ResponseWriter, req *http.Request) error { return m.proxyFn(w, req) } func mockUpstreams(names ...string) []upstream { upstreams := make([]upstream, len(names)) for i, name := range names { upstreams[i] = &mockUpstream{upstreamName: name} } return upstreams } // ── Plugin and middleware mocks ─────────────────────────────────────────────── type mockPlugin struct { name string typ sdk.PluginType fn func(sdk.Context) } func (m *mockPlugin) Init(_ map[string]interface{}) error { return nil } func (m *mockPlugin) Info() sdk.PluginInfo { return sdk.PluginInfo{ Name: m.name, Description: "Mock plugin", Version: "v1", Author: "test", } } func (m *mockPlugin) Type() sdk.PluginType { return m.typ } func (m *mockPlugin) Execute(ctx sdk.Context) error { m.fn(ctx) return nil } type mockMiddleware struct{} func (m *mockMiddleware) Init(_ map[string]interface{}) error { return nil } func (m *mockMiddleware) Name() string { return "mockmw" } func (m *mockMiddleware) Handler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("X-Middleware", "ok") next.ServeHTTP(w, r) }) } // ── Scatter mock ────────────────────────────────────────────────────────────── type mockScatter struct { results []upstreamResponse tooLarge bool } func (m *mockScatter) scatter(_ *flow, _ *http.Request) []upstreamResponse { if m.tooLarge { return nil } return m.results } func (m *mockScatter) call(_ *flow, _ *http.Request) (upstreamResponse, bool) { if m.tooLarge { return upstreamResponse{}, false } return m.results[0], true } // ── Builders ────────────────────────────────────────────────────────────────── func newTestUpstreamConfig(port string) UpstreamConfig { return UpstreamConfig{ Name: "test_service_" + port, Hosts: AddrList{"test-service:" + port}, Path: "/builder/test", Method: http.MethodGet, Timeout: 5 * time.Second, } } func newTestRouter(flows []flow, d scatter, a aggregator) *Router { r := &Router{ chiRouter: chi.NewMux(), scatter: d, aggregator: a, flows: flows, log: zap.NewNop(), metrics: testMetrics, } r.registerFlows() return r } func newTestUpstream(host string, opts ...func(*httpUpstream)) *httpUpstream { u := &httpUpstream{ cfg: upstreamConfig{ hosts: []string{host}, method: http.MethodGet, timeout: 500 * time.Millisecond, }, state: upstreamState{}, metrics: testMetrics, log: zap.NewNop(), client: http.DefaultClient, } for _, opt := range opts { opt(u) } return u } func withMethod(method string) func(*httpUpstream) { return func(u *httpUpstream) { u.cfg.method = method } } func withTimeout(d time.Duration) func(*httpUpstream) { return func(u *httpUpstream) { u.cfg.timeout = d } } func withPolicy(p upstreamPolicy) func(*httpUpstream) { return func(u *httpUpstream) { u.cfg.policy = p } } func withForwardQueries(queries ...string) func(*httpUpstream) { return func(u *httpUpstream) { u.cfg.forwardQueries = queries } } func withForwardHeaders(headers ...string) func(*httpUpstream) { return func(u *httpUpstream) { u.cfg.forwardHeaders = headers } } func withForwardParams(params ...string) func(*httpUpstream) { return func(u *httpUpstream) { u.cfg.forwardParams = params } } func withPath(path string) func(*httpUpstream) { return func(u *httpUpstream) { u.cfg.path = path } } func withHosts(hosts ...string) func(*httpUpstream) { return func(u *httpUpstream) { u.cfg.hosts = hosts } } func withLBMode(mode lbMode, hostCount int) func(*httpUpstream) { return func(u *httpUpstream) { u.cfg.lbMode = mode u.state.activeConnections = make([]int64, hostCount) } } func withCircuitBreaker(cb *circuitbreaker.CircuitBreaker) func(*httpUpstream) { return func(u *httpUpstream) { u.circuitBreaker = cb } } func withTrustedProxies(proxies ...*net.IPNet) func(*httpUpstream) { return func(u *httpUpstream) { u.cfg.trustedProxies = proxies } } func withTLS(tlsConfig *tls.Config) func(*httpUpstream) { return func(u *httpUpstream) { u.client = &http.Client{ Transport: &http.Transport{TLSClientConfig: tlsConfig}, } } } // ── TLS fixture ─────────────────────────────────────────────────────────────── type tlsFixture struct { caCert *x509.Certificate caKey *ecdsa.PrivateKey } func newTLSFixture(t *testing.T) *tlsFixture { t.Helper() caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) template := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "aastro-test-ca"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, BasicConstraintsValid: true, } der, err := x509.CreateCertificate(rand.Reader, template, template, &caKey.PublicKey, caKey) require.NoError(t, err) caCert, err := x509.ParseCertificate(der) require.NoError(t, err) return &tlsFixture{caCert: caCert, caKey: caKey} } func (f *tlsFixture) issueServerCert(t *testing.T) tls.Certificate { t.Helper() return f.issueCert(t, certParams{ cn: "test-server", eku: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, dnsNames: []string{"localhost"}, ipAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, }) } func (f *tlsFixture) issueClientCert(t *testing.T, cn string) tls.Certificate { t.Helper() return f.issueCert(t, certParams{ cn: cn, eku: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, }) } func (f *tlsFixture) certPool() *x509.CertPool { pool := x509.NewCertPool() pool.AddCert(f.caCert) return pool } type certParams struct { cn string eku []x509.ExtKeyUsage dnsNames []string ipAddresses []net.IP } func (f *tlsFixture) issueCert(t *testing.T, p certParams) tls.Certificate { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) template := &x509.Certificate{ SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: p.cn}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: p.eku, DNSNames: p.dnsNames, IPAddresses: p.ipAddresses, } der, err := x509.CreateCertificate(rand.Reader, template, f.caCert, &key.PublicKey, f.caKey) require.NoError(t, err) return tls.Certificate{ Certificate: [][]byte{der}, PrivateKey: key, } } // ── Flow / scatter builders ─────────────────────────────────────────────────── func newTestFlow(upstreams []upstream) *flow { return &flow{upstreams: upstreams} } func newTestScatter() *defaultScatter { return &defaultScatter{ log: zap.NewNop(), metrics: testMetrics, } } func streamingFlow(path string, u upstream, plugins ...sdk.Plugin) flow { return flow{ path: path, method: http.MethodGet, streaming: true, upstreams: []upstream{u}, plugins: plugins, } } // ── Response builders ───────────────────────────────────────────────────────── func okResponse(body string) upstreamResponse { return upstreamResponse{body: []byte(body)} } func errResponse(kind upstreamErrorKind) upstreamResponse { return upstreamResponse{ err: &upstreamError{ kind: kind, err: fmt.Errorf("upstream error: %s", kind), }, } } // ── Request builders ────────────────────────────────────────────────────────── func requestWithClientIP(method, url, remoteAddr, clientIP string) *http.Request { req, _ := http.NewRequest(method, url, nil) req.RemoteAddr = remoteAddr req = req.WithContext(withClientIP(req.Context(), clientIP)) return req } func mustParseCIDR(cidr string) *net.IPNet { _, ipnet, err := net.ParseCIDR(cidr) if err != nil { panic(err) } return ipnet } // ── Assertions ──────────────────────────────────────────────────────────────── func jsonEqual(t *testing.T, expected string, actual []byte) { t.Helper() var e, a any require.NoError(t, json.Unmarshal([]byte(expected), &e)) require.NoError(t, json.Unmarshal(actual, &a)) require.Equal(t, e, a) } func decodeProblem(t *testing.T, body []byte) ProblemDetails { t.Helper() var problem ProblemDetails require.NoError(t, json.Unmarshal(body, &problem)) return problem } func decodeJSONInto(data []byte, dst any) error { return json.Unmarshal(data, dst) }