diff --git a/internal/providers/queue/local.go b/internal/providers/queue/local.go index 593c7cb..2f93f18 100644 --- a/internal/providers/queue/local.go +++ b/internal/providers/queue/local.go @@ -56,6 +56,15 @@ func New(natsHost string) *Provider { } } +// monitorHealthURL builds the NATS monitoring /healthz URL for a host. It is a +// package var (not a method) purely as a test seam: tests point it at an +// httptest.Server so the reachable/healthy and unhealthy-status branches of +// Provision can be exercised without a real NATS pod. Production keeps the real +// "http://{host}:8222/healthz" form. +var monitorHealthURL = func(natsHost string) string { + return fmt.Sprintf("http://%s:8222/healthz", natsHost) +} + // Provision verifies NATS is reachable and returns a connection URL for the token. // // NATS runs without authentication — the returned URL requires no credentials. @@ -64,7 +73,7 @@ func New(natsHost string) *Provider { // principle: never return a URL for a server that isn't running). func (p *Provider) Provision(ctx context.Context, token, tier string) (*Credentials, error) { // Verify NATS is reachable via monitoring API. - monitorURL := fmt.Sprintf("http://%s:8222/healthz", p.natsHost) + monitorURL := monitorHealthURL(p.natsHost) req, err := http.NewRequestWithContext(ctx, http.MethodGet, monitorURL, nil) if err != nil { return nil, fmt.Errorf("queue.Provision: build health request: %w", err) diff --git a/internal/providers/queue/provision_test.go b/internal/providers/queue/provision_test.go new file mode 100644 index 0000000..e650b49 --- /dev/null +++ b/internal/providers/queue/provision_test.go @@ -0,0 +1,135 @@ +package queue + +// provision_test.go — branch coverage for Provider.Provision. These are +// white-box tests (package queue) so they can swap the monitorHealthURL seam to +// point at an httptest.Server, exercising the healthy / unhealthy-status / +// build-request-error branches without a real NATS pod. + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// withMonitorURL swaps the monitorHealthURL seam for the duration of a test and +// restores it afterward. +func withMonitorURL(t *testing.T, fn func(string) string) { + t.Helper() + orig := monitorHealthURL + monitorHealthURL = fn + t.Cleanup(func() { monitorHealthURL = orig }) +} + +// TestProvision_HappyPath exercises the full success path: NATS monitoring +// returns 200, so Provision must return a Credentials with the canonical +// full-token SubjectPrefix and the nats:// URL built from the host. +func TestProvision_HappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + host := strings.TrimPrefix(srv.URL, "http://") // "127.0.0.1:PORT" + withMonitorURL(t, func(string) string { return srv.URL + "/healthz" }) + + p := &Provider{natsHost: host, httpClient: &http.Client{Timeout: 5 * time.Second}} + + token := "abcd1234-ef56-7890-abcd-ef1234567890" + creds, err := p.Provision(context.Background(), token, "pro") + if err != nil { + t.Fatalf("Provision happy path returned error: %v", err) + } + if creds == nil { + t.Fatal("Provision returned nil Credentials with no error") + } + wantPrefix := canonicalSubjectPrefix(token) + if creds.SubjectPrefix != wantPrefix { + t.Fatalf("SubjectPrefix = %q, want canonical %q", creds.SubjectPrefix, wantPrefix) + } + wantURL := "nats://" + host + ":4222" + if creds.URL != wantURL { + t.Fatalf("URL = %q, want %q", creds.URL, wantURL) + } + if creds.ProviderResourceID != "" { + t.Fatalf("ProviderResourceID = %q, want empty for shared NATS", creds.ProviderResourceID) + } +} + +// TestProvision_AnonymousTier exercises the happy path with the anonymous +// auth_mode arm (the legacy_open fallback) — the tier only affects the log +// line, but this confirms both arms return credentials. +func TestProvision_AnonymousTier(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + withMonitorURL(t, func(string) string { return srv.URL + "/healthz" }) + p := &Provider{natsHost: "anon.host", httpClient: &http.Client{Timeout: 5 * time.Second}} + + creds, err := p.Provision(context.Background(), "anontoken1234567", "anonymous") + if err != nil { + t.Fatalf("anonymous Provision returned error: %v", err) + } + if creds.URL != "nats://anon.host:4222" { + t.Fatalf("URL = %q, want nats://anon.host:4222", creds.URL) + } +} + +// TestProvision_UnhealthyStatus covers the non-200 branch: NATS monitoring is +// reachable but returns a 5xx, so Provision must refuse to issue a URL. +func TestProvision_UnhealthyStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(srv.Close) + + withMonitorURL(t, func(string) string { return srv.URL + "/healthz" }) + p := &Provider{natsHost: "h", httpClient: &http.Client{Timeout: 5 * time.Second}} + + _, err := p.Provision(context.Background(), "tok1234567890abc", "free") + if err == nil { + t.Fatal("Provision must error when NATS returns non-200") + } + if !strings.Contains(err.Error(), "unhealthy") { + t.Fatalf("error %q must mention 'unhealthy'", err.Error()) + } +} + +// TestProvision_BuildRequestError covers the http.NewRequestWithContext error +// branch by feeding the seam a URL containing a control character that the URL +// parser rejects. +func TestProvision_BuildRequestError(t *testing.T) { + withMonitorURL(t, func(string) string { return "http://example.com/\x7f\x00bad" }) + p := &Provider{natsHost: "h", httpClient: &http.Client{Timeout: time.Second}} + + _, err := p.Provision(context.Background(), "tok1234567890abc", "free") + if err == nil { + t.Fatal("Provision must error on a malformed monitor URL") + } + if !strings.Contains(err.Error(), "build health request") { + t.Fatalf("error %q must mention 'build health request'", err.Error()) + } +} + +// TestProvision_ConnFail covers the httpClient.Do error branch (connection +// refused) — the seam points at a closed port. +func TestProvision_ConnFail(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := srv.URL + srv.Close() // close immediately so the port refuses connections + + withMonitorURL(t, func(string) string { return url + "/healthz" }) + p := &Provider{natsHost: "h", httpClient: &http.Client{Timeout: time.Second}} + + _, err := p.Provision(context.Background(), "tok1234567890abc", "free") + if err == nil { + t.Fatal("Provision must error when the NATS health endpoint is unreachable") + } + if !strings.Contains(err.Error(), "health check failed") { + t.Fatalf("error %q must mention 'health check failed'", err.Error()) + } +}