From cd380db0b809acea6eb2098fd2f80fad4e099342 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Thu, 2 Apr 2026 14:13:07 -0700 Subject: [PATCH 1/3] test: add E2E test against real binary --- .github/workflows/ci.yml | 2 + README.md | 10 + test/e2e/e2e_test.go | 395 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 407 insertions(+) create mode 100644 test/e2e/e2e_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bdf48e..5a2ee29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,5 @@ jobs: - run: go vet ./... - run: go test -race ./... + + - run: go test -tags e2e -count=1 ./test/e2e/... diff --git a/README.md b/README.md index b17ebfd..e4932d3 100644 --- a/README.md +++ b/README.md @@ -155,3 +155,13 @@ On shutdown (SIGINT/SIGTERM), the stream is deleted from the transmitter before go test ./... go vet ./... ``` + +### E2E tests + +The end-to-end tests in `test/e2e/` run the real compiled binary against an in-process fake transmitter and webhook sink. They are excluded from `go test ./...` by a build tag and must be run explicitly: + +```sh +go test -tags e2e -count 1 ./test/e2e/... +``` + +The test builds the binary from source automatically — no extra setup required. diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go new file mode 100644 index 0000000..487d3d9 --- /dev/null +++ b/test/e2e/e2e_test.go @@ -0,0 +1,395 @@ +//go:build e2e + +package e2e + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +var binaryPath string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "ssf-forwarder-e2e-*") + if err != nil { + fmt.Fprintf(os.Stderr, "create temp dir: %v\n", err) + os.Exit(1) + } + + binaryPath = filepath.Join(dir, "ssf-forwarder") + cmd := exec.Command("go", "build", "-o", binaryPath, "github.com/twosense/ssf-forwarder/cmd/ssf-forwarder") + if out, err := cmd.CombinedOutput(); err != nil { + os.RemoveAll(dir) + fmt.Fprintf(os.Stderr, "binary build failed: %v\n%s\n", err, out) + os.Exit(1) + } + + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + +// fakeTransmitter is an HTTP server that behaves like an SSF transmitter. +// It handles stream registration and can produce valid signed SETs. +type fakeTransmitter struct { + server *httptest.Server + privateKey *rsa.PrivateKey + kid string + + registerOnce sync.Once + registered chan struct{} + + mu sync.Mutex + pushURL string + streamID string +} + +func newFakeTransmitter(t *testing.T) *fakeTransmitter { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generating RSA key: %v", err) + } + + ft := &fakeTransmitter{ + privateKey: key, + kid: "e2e-test-key", + registered: make(chan struct{}), + } + + mux := http.NewServeMux() + mux.HandleFunc("/metadata", ft.serveMetadata) + mux.HandleFunc("/jwks", ft.serveJWKS) + mux.HandleFunc("/streams", ft.serveStreams) + + ft.server = httptest.NewServer(mux) + t.Cleanup(ft.server.Close) + + return ft +} + +func (ft *fakeTransmitter) issuer() string { + return ft.server.URL + "/" +} + +func (ft *fakeTransmitter) serveMetadata(w http.ResponseWriter, r *http.Request) { + meta := map[string]interface{}{ + "issuer": ft.issuer(), + "jwks_uri": ft.server.URL + "/jwks", + "configuration_endpoint": ft.server.URL + "/streams", + "delivery_methods_supported": []string{"urn:ietf:rfc:8935"}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(meta) +} + +func (ft *fakeTransmitter) serveJWKS(w http.ResponseWriter, r *http.Request) { + pub := &ft.privateKey.PublicKey + jwks := map[string]interface{}{ + "keys": []map[string]interface{}{{ + "kty": "RSA", + "kid": ft.kid, + "use": "sig", + "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jwks) +} + +func (ft *fakeTransmitter) serveStreams(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + // Signal no existing stream so the forwarder always creates a new one. + http.NotFound(w, r) + + case http.MethodPost: + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body", http.StatusInternalServerError) + return + } + + var req struct { + Delivery struct { + Method string `json:"method"` + EndpointURL string `json:"endpoint_url"` + } `json:"delivery"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "parse body", http.StatusBadRequest) + return + } + + ft.mu.Lock() + ft.pushURL = req.Delivery.EndpointURL + ft.streamID = "e2e-stream-001" + ft.mu.Unlock() + + resp := map[string]interface{}{ + "stream_id": "e2e-stream-001", + "iss": ft.issuer(), + "aud": req.Delivery.EndpointURL, + "delivery": map[string]string{ + "method": req.Delivery.Method, + "endpoint_url": req.Delivery.EndpointURL, + }, + "events_delivered": []string{ + "https://schemas.openid.net/secevent/ssf/event-type/verification", + }, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(resp) + + ft.registerOnce.Do(func() { close(ft.registered) }) + + case http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (ft *fakeTransmitter) waitForRegistration(t *testing.T, timeout time.Duration) { + t.Helper() + select { + case <-ft.registered: + case <-time.After(timeout): + t.Fatal("timed out waiting for stream registration") + } +} + +func (ft *fakeTransmitter) getPushURL() string { + ft.mu.Lock() + defer ft.mu.Unlock() + return ft.pushURL +} + +// signSET returns a signed SSF verification SET as a JWT string. +// The SET is valid for the forwarder to parse: correct issuer, a registered +// event type, and a subject. +func (ft *fakeTransmitter) signSET(t *testing.T) string { + t.Helper() + + headerJSON, _ := json.Marshal(map[string]interface{}{ + "alg": "RS256", + "kid": ft.kid, + "typ": "JWT", + }) + payloadJSON, _ := json.Marshal(map[string]interface{}{ + "iss": ft.issuer(), + "jti": fmt.Sprintf("e2e-%d", time.Now().UnixNano()), + "iat": time.Now().Unix(), + "events": map[string]interface{}{ + "https://schemas.openid.net/secevent/ssf/event-type/verification": map[string]interface{}{}, + }, + "sub_id": map[string]interface{}{ + "format": "email", + "email": "test@example.com", + }, + }) + + h := base64.RawURLEncoding.EncodeToString(headerJSON) + p := base64.RawURLEncoding.EncodeToString(payloadJSON) + signingInput := h + "." + p + + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, ft.privateKey, crypto.SHA256, digest[:]) + if err != nil { + t.Fatalf("signing SET: %v", err) + } + + return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +// testSink is an HTTP server that records the raw bodies of all POST requests. +type testSink struct { + server *httptest.Server + mu sync.Mutex + tokens []string + ch chan struct{} +} + +func newTestSink(t *testing.T) *testSink { + t.Helper() + + ts := &testSink{ch: make(chan struct{}, 10)} + ts.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + ts.mu.Lock() + ts.tokens = append(ts.tokens, string(body)) + ts.mu.Unlock() + ts.ch <- struct{}{} + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(ts.server.Close) + + return ts +} + +func (ts *testSink) waitForToken(t *testing.T, timeout time.Duration) string { + t.Helper() + select { + case <-ts.ch: + case <-time.After(timeout): + t.Fatal("timed out waiting for token at test sink") + } + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.tokens[len(ts.tokens)-1] +} + +// startForwarder launches the ssf-forwarder binary with the given config file +// and registers a cleanup function to send SIGTERM and wait for it to exit. +func startForwarder(t *testing.T, configPath string) { + t.Helper() + + cmd := exec.Command(binaryPath, "--config", configPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + t.Fatalf("starting forwarder: %v", err) + } + + t.Cleanup(func() { + cmd.Process.Signal(syscall.SIGTERM) + cmd.Wait() + }) +} + +// waitForServer polls url until the server responds or the timeout elapses. +func waitForServer(t *testing.T, url string, timeout time.Duration) { + t.Helper() + client := &http.Client{Timeout: 500 * time.Millisecond} + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + resp, err := client.Get(url) + if err == nil { + resp.Body.Close() + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("timed out waiting for server at %s", url) +} + +// freePort finds and returns a free TCP port on localhost. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("finding free port: %v", err) + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + return port +} + +// writeConfig writes a forwarder config.yaml to a temp file and returns its path. +func writeConfig(t *testing.T, metadataURL, sinkURL, publicURL, listenAddr string) string { + t.Helper() + + content := fmt.Sprintf(`receiver: + public_url: %q + listen_addr: %q + endpoint: /events + +transmitter: + metadata_url: %q + auth: + type: bearer + token: test-token + events_requested: + - https://schemas.openid.net/secevent/ssf/event-type/verification + +sinks: + - type: webhook + url: %q +`, publicURL, listenAddr, metadataURL, sinkURL) + + f, err := os.CreateTemp("", "ssf-forwarder-config-*.yaml") + if err != nil { + t.Fatalf("creating config temp file: %v", err) + } + t.Cleanup(func() { os.Remove(f.Name()) }) + + if _, err := f.WriteString(content); err != nil { + t.Fatalf("writing config: %v", err) + } + f.Close() + + return f.Name() +} + +func TestForwardsSETToWebhookSink(t *testing.T) { + transmitter := newFakeTransmitter(t) + sink := newTestSink(t) + + port := freePort(t) + listenAddr := fmt.Sprintf("127.0.0.1:%d", port) + publicURL := fmt.Sprintf("http://127.0.0.1:%d", port) + + cfgPath := writeConfig(t, + transmitter.server.URL+"/metadata", + sink.server.URL, + publicURL, + listenAddr, + ) + + startForwarder(t, cfgPath) + + // Block until the forwarder registers its push stream with the transmitter. + // This also confirms the forwarder started and reached the transmitter. + transmitter.waitForRegistration(t, 15*time.Second) + + // The forwarder starts its HTTP server after stream registration, + // so poll until it is accepting connections. + waitForServer(t, publicURL+"/events", 5*time.Second) + + token := transmitter.signSET(t) + + resp, err := http.Post( + transmitter.getPushURL(), + "application/secevent+jwt", + strings.NewReader(token), + ) + if err != nil { + t.Fatalf("pushing SET to forwarder: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("forwarder returned %d, want 202", resp.StatusCode) + } + + received := sink.waitForToken(t, 5*time.Second) + + if received != token { + t.Errorf("sink received unexpected token\ngot: %s\nwant: %s", received, token) + } +} From 9327a68a423886e06d7cd7282c01eed24aba4789 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Thu, 2 Apr 2026 14:32:28 -0700 Subject: [PATCH 2/3] test: add Docker mode to E2E tests --- .github/workflows/ci.yml | 4 +++ README.md | 6 ++++ test/e2e/e2e_test.go | 69 ++++++++++++++++++++++++++++++---------- 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a2ee29..a0cbaaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,7 @@ jobs: - run: go test -race ./... - run: go test -tags e2e -count=1 ./test/e2e/... + + - run: go test -tags e2e -count=1 ./test/e2e/... + env: + E2E_DOCKER: "1" diff --git a/README.md b/README.md index e4932d3..4ef3d21 100644 --- a/README.md +++ b/README.md @@ -165,3 +165,9 @@ go test -tags e2e -count 1 ./test/e2e/... ``` The test builds the binary from source automatically — no extra setup required. + +You can also run the E2E tests against the built Docker image. This requires host networking, so it will only work on Linux: + +```sh +E2E_DOCKER=1 go test -tags e2e -count 1 ./test/e2e/... +``` diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 487d3d9..f13016b 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -25,25 +25,41 @@ import ( "time" ) -var binaryPath string +const dockerImage = "ssf-forwarder:e2e-test" + +var ( + binaryPath string + useDocker = os.Getenv("E2E_DOCKER") == "1" +) func TestMain(m *testing.M) { - dir, err := os.MkdirTemp("", "ssf-forwarder-e2e-*") - if err != nil { - fmt.Fprintf(os.Stderr, "create temp dir: %v\n", err) - os.Exit(1) - } + var cleanup func() - binaryPath = filepath.Join(dir, "ssf-forwarder") - cmd := exec.Command("go", "build", "-o", binaryPath, "github.com/twosense/ssf-forwarder/cmd/ssf-forwarder") - if out, err := cmd.CombinedOutput(); err != nil { - os.RemoveAll(dir) - fmt.Fprintf(os.Stderr, "binary build failed: %v\n%s\n", err, out) - os.Exit(1) + if useDocker { + cmd := exec.Command("docker", "build", "-t", dockerImage, "../..") + if out, err := cmd.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "docker build failed: %v\n%s\n", err, out) + os.Exit(1) + } + cleanup = func() {} + } else { + dir, err := os.MkdirTemp("", "ssf-forwarder-e2e-*") + if err != nil { + fmt.Fprintf(os.Stderr, "create temp dir: %v\n", err) + os.Exit(1) + } + binaryPath = filepath.Join(dir, "ssf-forwarder") + cmd := exec.Command("go", "build", "-o", binaryPath, "github.com/twosense/ssf-forwarder/cmd/ssf-forwarder") + if out, err := cmd.CombinedOutput(); err != nil { + os.RemoveAll(dir) + fmt.Fprintf(os.Stderr, "binary build failed: %v\n%s\n", err, out) + os.Exit(1) + } + cleanup = func() { os.RemoveAll(dir) } } code := m.Run() - os.RemoveAll(dir) + cleanup() os.Exit(code) } @@ -263,12 +279,27 @@ func (ts *testSink) waitForToken(t *testing.T, timeout time.Duration) string { return ts.tokens[len(ts.tokens)-1] } -// startForwarder launches the ssf-forwarder binary with the given config file -// and registers a cleanup function to send SIGTERM and wait for it to exit. +// startForwarder launches the forwarder with the given config file and +// registers a cleanup to send SIGTERM and wait for exit. +// +// In binary mode the compiled binary is run directly. In Docker mode +// (E2E_DOCKER=1) the pre-built image is run with --network host so the +// container shares the host's network stack and can reach 127.0.0.1 services. +// Docker mode is only supported on Linux. func startForwarder(t *testing.T, configPath string) { t.Helper() - cmd := exec.Command(binaryPath, "--config", configPath) + var cmd *exec.Cmd + if useDocker { + cmd = exec.Command("docker", "run", "--rm", + "--network", "host", + "-v", configPath+":/etc/ssf-forwarder/config.yaml:ro", + dockerImage, + ) + } else { + cmd = exec.Command(binaryPath, "--config", configPath) + } + cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -311,6 +342,7 @@ func freePort(t *testing.T) int { } // writeConfig writes a forwarder config.yaml to a temp file and returns its path. +// The file is world-readable so the Docker container user can read it when mounted. func writeConfig(t *testing.T, metadataURL, sinkURL, publicURL, listenAddr string) string { t.Helper() @@ -343,6 +375,11 @@ sinks: } f.Close() + // World-readable so the container user (uid 1000) can read the mounted file. + if err := os.Chmod(f.Name(), 0644); err != nil { + t.Fatalf("chmod config: %v", err) + } + return f.Name() } From b7f65b79b8479724596b269e2c152843fbd18577 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Thu, 2 Apr 2026 16:49:08 -0700 Subject: [PATCH 3/3] ci: make step names more descriptive --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0cbaaa..54690ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,10 +17,13 @@ jobs: - run: go vet ./... - - run: go test -race ./... + - name: Run unit tests + run: go test -race ./... - - run: go test -tags e2e -count=1 ./test/e2e/... + - name: Run E2E tests with binary + run: go test -tags e2e -count=1 ./test/e2e/... - - run: go test -tags e2e -count=1 ./test/e2e/... + - name: Run E2E tests with Docker image + run: go test -tags e2e -count=1 ./test/e2e/... env: E2E_DOCKER: "1"