//go:build integration // Package integration runs the gateway in-process against stub upstreams and // exercises it over real HTTP. Run with: // // go test -tags integration ./integration/... package integration import ( "bufio" "context" "crypto/tls" "crypto/x509" "encoding/pem" "errors" "fmt" "net" "net/http" "os" "path/filepath" "strconv" "testing" "text/template" "time" "go.uber.org/zap" "github.com/voidrunner3074/aastro" "github.com/voidrunner3074/aastro/internal/server" "github.com/voidrunner3074/aastro/internal/testutil/certgen" ) const ( startupTimeout = 10 * time.Second clientTimeout = 10 * time.Second logTailLines = 200 ) // env is the shared test environment, built once in TestMain. var env *environment type environment struct { // base is the plain-HTTP gateway (testdata/gateway.yaml.tmpl) base string client *http.Client tlsBase string tlsPlainURL string // same port, plain http:// // tlsHTTP1Base is a second instance of the same template, with http2: off. tlsHTTP1Base string mtlsClient *http.Client noCertTLS *http.Client rogueTLS *http.Client upstreams *upstreams gateways []*gateway dir string logPath string } type gatewayParams struct { Port, AdminPort int Profile, Stats, Prefs, Stream string Proto string TLSDir string HTTP2 string } func TestMain(m *testing.M) { os.Exit(runMain(m)) } func runMain(m *testing.M) int { e, err := setup() if e != nil { defer e.teardown() } if err != nil { fmt.Fprintf(os.Stderr, "integration setup: %v\n", err) e.dumpLog() return 1 } env = e code := m.Run() if code != 0 { e.dumpLog() } return code } func setup() (*environment, error) { dir, err := os.MkdirTemp("", "aastro-integration-") if err != nil { return nil, err } e := &environment{ dir: dir, logPath: filepath.Join(dir, "gateway.log"), upstreams: startUpstreams(), client: newClient(nil), } certs := writeTLSFixtures(dir) certgen.WriteAtomic(filepath.Join(dir, "upstream-ca.crt"), pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE", Bytes: e.upstreams.proto.Certificate().Raw, })) e.mtlsClient = newClient(&tls.Config{RootCAs: certs.roots, Certificates: []tls.Certificate{certs.client}}) e.noCertTLS = newClient(&tls.Config{RootCAs: certs.roots}) e.rogueTLS = newClient(&tls.Config{RootCAs: certs.roots, Certificates: []tls.Certificate{certs.rogue}}) log, err := newFileLogger(e.logPath) if err != nil { return e, err } port, err := e.start("gateway", "gateway.yaml.tmpl", gatewayParams{}, log) if err != nil { return e, err } e.base = "http://localhost:" + strconv.Itoa(port) port, err = e.start("gateway-tls", "gateway-tls.yaml.tmpl", gatewayParams{HTTP2: "auto"}, log) if err != nil { return e, err } e.tlsBase = "https://localhost:" + strconv.Itoa(port) e.tlsPlainURL = "http://localhost:" + strconv.Itoa(port) port, err = e.start("gateway-tls-http1", "gateway-tls.yaml.tmpl", gatewayParams{HTTP2: "off"}, log) if err != nil { return e, err } e.tlsHTTP1Base = "https://localhost:" + strconv.Itoa(port) return e, nil } // start renders a testdata config template with free ports and the stub // upstream addresses, then starts a gateway named name from it. p carries // the template's own knobs (e.g. HTTP2), so one template can back several // instances. Returns the data port. func (e *environment) start(name, tmplName string, p gatewayParams, log *zap.Logger) (int, error) { port, err := freePort() if err != nil { return 0, err } adminPort, err := freePort() if err != nil { return 0, err } p.Port, p.AdminPort = port, adminPort p.Profile, p.Stats, p.Prefs = e.upstreams.profile.URL, e.upstreams.stats.URL, e.upstreams.prefs.URL p.Stream, p.Proto = e.upstreams.stream.URL, e.upstreams.proto.URL p.TLSDir = e.dir cfgPath := filepath.Join(e.dir, name+".yaml") if err = renderConfig(tmplName, cfgPath, p); err != nil { return 0, err } g, err := startGateway(cfgPath, port, adminPort, log.Named(name)) if err != nil { return 0, fmt.Errorf("%s: %w", name, err) } e.gateways = append(e.gateways, g) return port, nil } func (e *environment) teardown() { for _, g := range e.gateways { g.stop() } e.upstreams.close() _ = os.RemoveAll(e.dir) } // dumpLog prints the tail of the gateway log, so a CI failure carries the // gateway's side of the story without having to keep artifacts around. func (e *environment) dumpLog() { if e == nil { return } f, err := os.Open(e.logPath) if err != nil { return } defer f.Close() var lines []string sc := bufio.NewScanner(f) for sc.Scan() { lines = append(lines, sc.Text()) if len(lines) > logTailLines { lines = lines[1:] } } fmt.Fprintf(os.Stderr, "\n--- gateway log (last %d lines) ---\n", len(lines)) for _, l := range lines { fmt.Fprintln(os.Stderr, l) } } // gateway is one in-process aastro server, started the same way // cmd/aastro does it: LoadConfig -> server.New -> Start. type gateway struct { srv *server.Server done chan error } func startGateway(cfgPath string, port, adminPort int, log *zap.Logger) (*gateway, error) { cfg, err := aastro.LoadConfig(cfgPath) if err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), startupTimeout) defer cancel() srv, err := server.New(ctx, cfg.Gateway, "integration", log) if err != nil { return nil, err } g := &gateway{srv: srv, done: make(chan error, 1)} go func() { g.done <- srv.Start() }() if err = waitReady(ctx, port, adminPort, g.done); err != nil { g.stop() return nil, err } return g, nil } func (g *gateway) stop() { ctx, cancel := context.WithTimeout(context.Background(), startupTimeout) defer cancel() _ = g.srv.Stop(ctx) select { case <-g.done: case <-ctx.Done(): } } // waitReady blocks until the admin /__ready endpoint answers 200 and the // data port accepts connections, or the server exits early (e.g. the port // grabbed by freePort was taken in the meantime). func waitReady(ctx context.Context, port, adminPort int, done <-chan error) error { readyURL := fmt.Sprintf("http://127.0.0.1:%d/__ready", adminPort) dataAddr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) tick := time.NewTicker(50 * time.Millisecond) defer tick.Stop() for { select { case err := <-done: return fmt.Errorf("gateway exited during startup: %w", err) case <-ctx.Done(): return errors.New("gateway did not become ready in time") case <-tick.C: } req, _ := http.NewRequestWithContext(ctx, http.MethodGet, readyURL, nil) resp, err := http.DefaultClient.Do(req) if err != nil { continue } _ = resp.Body.Close() if resp.StatusCode != http.StatusOK { continue } var d net.Dialer if conn, dialErr := d.DialContext(ctx, "tcp", dataAddr); dialErr == nil { _ = conn.Close() return nil } } } // freePort asks the OS for an unused port. The server binds it again // moments later; the gap is a theoretical race, and waitReady reports it // as a startup error rather than a hang if it ever happens. func freePort() (int, error) { var lc net.ListenConfig ln, err := lc.Listen(context.Background(), "tcp", ":0") if err != nil { return 0, err } defer ln.Close() return ln.Addr().(*net.TCPAddr).Port, nil } func renderConfig(tmplName, outPath string, params gatewayParams) error { tmpl, err := template.ParseFiles(filepath.Join("testdata", tmplName)) if err != nil { return err } f, err := os.Create(outPath) if err != nil { return err } defer f.Close() return tmpl.Execute(f, params) } func newFileLogger(path string) (*zap.Logger, error) { cfg := zap.NewProductionConfig() cfg.OutputPaths = []string{path} cfg.ErrorOutputPaths = []string{path} cfg.Sampling = nil return cfg.Build() } // newClient returns a client that never follows redirects, so redirect // tests see the gateway's own response. tlsCfg is nil for plain HTTP. func newClient(tlsCfg *tls.Config) *http.Client { transport := http.DefaultTransport.(*http.Transport).Clone() transport.TLSClientConfig = tlsCfg return &http.Client{ Timeout: clientTimeout, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } } type tlsFixtures struct { roots *x509.CertPool client, rogue tls.Certificate } func writeTLSFixtures(dir string) tlsFixtures { ca, caKey, caPEM := certgen.NewCA() serverCert, serverKey := certgen.NewLeaf(ca, caKey, 2) clientCert, clientKey := certgen.NewLeaf(ca, caKey, 3) rogueCA, rogueCAKey, _ := certgen.NewCA() rogueCert, rogueKey := certgen.NewLeaf(rogueCA, rogueCAKey, 2) certgen.WriteAtomic(filepath.Join(dir, "ca.crt"), caPEM) certgen.WriteAtomic(filepath.Join(dir, "server.crt"), serverCert) certgen.WriteAtomic(filepath.Join(dir, "server.key"), serverKey) roots := x509.NewCertPool() roots.AddCert(ca) return tlsFixtures{ roots: roots, client: certgen.KeyPair(clientCert, clientKey), rogue: certgen.KeyPair(rogueCert, rogueKey), } }