package app import ( "bytes" "context" "errors" "io" "net" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" "galaxy/authsession/internal/config" "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" ) func TestNewRuntimeStartsAndStopsHTTPServers(t *testing.T) { t.Parallel() redisServer := miniredis.RunT(t) cfg := config.DefaultConfig() cfg.Redis.Addr = redisServer.Addr() cfg.PublicHTTP.Addr = mustFreeAddr(t) cfg.InternalHTTP.Addr = mustFreeAddr(t) cfg.ShutdownTimeout = 10 * time.Second runtime, err := NewRuntime(context.Background(), cfg, zap.NewNop(), nil) require.NoError(t, err) defer func() { require.NoError(t, runtime.Close()) }() runCtx, cancel := context.WithCancel(context.Background()) defer cancel() runErrCh := make(chan error, 1) go func() { runErrCh <- runtime.App.Run(runCtx) }() client := newTestHTTPClient(t) waitForPublicSendEmailCodeReady(t, client, cfg.PublicHTTP.Addr) waitForInternalGetMissingReady(t, client, cfg.InternalHTTP.Addr) cancel() waitForAppRunResult(t, runErrCh, cfg.ShutdownTimeout+2*time.Second) } func TestNewRuntimeUsesRESTUserDirectoryWhenConfigured(t *testing.T) { t.Parallel() redisServer := miniredis.RunT(t) userService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && r.URL.Path == "/api/v1/internal/users/user-1/exists" { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"exists":true}`) return } http.NotFound(w, r) })) defer userService.Close() cfg := config.DefaultConfig() cfg.Redis.Addr = redisServer.Addr() cfg.PublicHTTP.Addr = mustFreeAddr(t) cfg.InternalHTTP.Addr = mustFreeAddr(t) cfg.UserService.Mode = "rest" cfg.UserService.BaseURL = userService.URL cfg.UserService.RequestTimeout = 250 * time.Millisecond cfg.ShutdownTimeout = 10 * time.Second runtime, err := NewRuntime(context.Background(), cfg, zap.NewNop(), nil) require.NoError(t, err) defer func() { require.NoError(t, runtime.Close()) }() runCtx, cancel := context.WithCancel(context.Background()) defer cancel() runErrCh := make(chan error, 1) go func() { runErrCh <- runtime.App.Run(runCtx) }() client := newTestHTTPClient(t) waitForInternalRevokeAllReady(t, client, cfg.InternalHTTP.Addr, "user-1") cancel() waitForAppRunResult(t, runErrCh, cfg.ShutdownTimeout+2*time.Second) } func TestNewRuntimeUsesRESTMailSenderWhenConfigured(t *testing.T) { t.Parallel() redisServer := miniredis.RunT(t) var calls atomic.Int64 mailService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && r.URL.Path == "/api/v1/internal/login-code-deliveries" { calls.Add(1) w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"outcome":"suppressed"}`) return } http.NotFound(w, r) })) defer mailService.Close() cfg := config.DefaultConfig() cfg.Redis.Addr = redisServer.Addr() cfg.PublicHTTP.Addr = mustFreeAddr(t) cfg.InternalHTTP.Addr = mustFreeAddr(t) cfg.MailService.Mode = "rest" cfg.MailService.BaseURL = mailService.URL cfg.MailService.RequestTimeout = 250 * time.Millisecond cfg.ShutdownTimeout = 10 * time.Second runtime, err := NewRuntime(context.Background(), cfg, zap.NewNop(), nil) require.NoError(t, err) defer func() { require.NoError(t, runtime.Close()) }() runCtx, cancel := context.WithCancel(context.Background()) defer cancel() runErrCh := make(chan error, 1) go func() { runErrCh <- runtime.App.Run(runCtx) }() client := newTestHTTPClient(t) waitForPublicSendEmailCodeReady(t, client, cfg.PublicHTTP.Addr) require.Eventually(t, func() bool { return calls.Load() == 1 }, 5*time.Second, 25*time.Millisecond, "REST mail sender was not invoked") cancel() waitForAppRunResult(t, runErrCh, cfg.ShutdownTimeout+2*time.Second) } func TestNewRuntimeFailsFastWhenRedisPingChecksFail(t *testing.T) { t.Parallel() cfg := config.DefaultConfig() cfg.Redis.Addr = mustFreeAddr(t) runtime, err := NewRuntime(context.Background(), cfg, zap.NewNop(), nil) require.Nil(t, runtime) require.Error(t, err) assert.ErrorContains(t, err, "new authsession runtime: ping") } func mustFreeAddr(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) defer func() { assert.NoError(t, listener.Close()) }() return listener.Addr().String() } func newTestHTTPClient(t *testing.T) *http.Client { t.Helper() transport := &http.Transport{ DisableKeepAlives: true, } t.Cleanup(transport.CloseIdleConnections) return &http.Client{ Timeout: 250 * time.Millisecond, Transport: transport, } } func waitForPublicSendEmailCodeReady(t *testing.T, client *http.Client, addr string) { t.Helper() require.Eventually(t, func() bool { response, err := client.Post( "http://"+addr+"/api/v1/public/auth/send-email-code", "application/json", bytes.NewBufferString(`{"email":"pilot@example.com"}`), ) if err != nil { return false } defer response.Body.Close() _, _ = io.ReadAll(response.Body) return response.StatusCode == http.StatusOK }, 5*time.Second, 25*time.Millisecond, "public authsession listener did not become reachable") } func waitForInternalGetMissingReady(t *testing.T, client *http.Client, addr string) { t.Helper() require.Eventually(t, func() bool { response, err := client.Get("http://" + addr + "/api/v1/internal/sessions/missing") if err != nil { return false } defer response.Body.Close() _, _ = io.ReadAll(response.Body) return response.StatusCode == http.StatusNotFound }, 5*time.Second, 25*time.Millisecond, "internal authsession listener did not become reachable") } func waitForInternalRevokeAllReady(t *testing.T, client *http.Client, addr string, userID string) { t.Helper() require.Eventually(t, func() bool { response, err := client.Post( "http://"+addr+"/api/v1/internal/users/"+userID+"/sessions/revoke-all", "application/json", bytes.NewBufferString(`{"reason_code":"logout_all","actor":{"type":"system"}}`), ) if err != nil { return false } defer response.Body.Close() payload, err := io.ReadAll(response.Body) if err != nil { return false } return response.StatusCode == http.StatusOK && bytes.Contains(payload, []byte(`"outcome":"no_active_sessions"`)) && bytes.Contains(payload, []byte(`"user_id":"`+userID+`"`)) }, 5*time.Second, 25*time.Millisecond, "internal revoke-all route did not become reachable") } func waitForAppRunResult(t *testing.T, runErrCh <-chan error, waitTimeout time.Duration) { t.Helper() require.Positive(t, waitTimeout, "wait timeout must be positive") var err error require.Eventually(t, func() bool { select { case err = <-runErrCh: return true default: return false } }, waitTimeout, 10*time.Millisecond, "authsession app did not stop") require.True(t, err == nil || errors.Is(err, context.Canceled), "unexpected app run error: %v", err) require.NoError(t, err) }