From 85ccefc7ffde3928d6b00c0ff65d8bfdb51587a8 Mon Sep 17 00:00:00 2001 From: IliaDenisov Date: Thu, 9 Apr 2026 12:52:00 +0200 Subject: [PATCH] chore: sync testing plan with authsession --- TESTING.md | 43 +++- .../internal/api/internalhttp/server_test.go | 122 ++++++++++-- .../internal/api/publichttp/server_test.go | 129 ++++++++++-- authsession/internal/app/runtime_test.go | 187 +++++++++++------- 4 files changed, 367 insertions(+), 114 deletions(-) diff --git a/TESTING.md b/TESTING.md index 9e25f12..fed89fc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -117,7 +117,7 @@ The testing plan follows this service order: --- -## 1. Edge Gateway Service +## 1. [Edge Gateway](gateway/README.md) Service ### Service tests @@ -230,7 +230,7 @@ The testing plan follows this service order: --- -## 2. Auth / Session Service +## 2. [Auth / Session](authsession/README.md) Service ### Service tests @@ -273,9 +273,14 @@ The testing plan follows this service order: * stored session reread before publish to avoid stale active projection * Public API tests: - * JSON decoding and unknown field rejection + * JSON decoding, input validation, and invalid-request mapping * public error mapping * stable success DTO shape + * end-to-end public HTTP send/confirm scenarios + * timeout mapping and invalid-success-payload rejection + * stable public OpenAPI validation and gateway contract parity + * stable public error examples + * trace/metric emission and sensitive-field log redaction * Internal API tests: * `GetSession` @@ -283,12 +288,24 @@ The testing plan follows this service order: * `RevokeDeviceSession` * `RevokeAllUserSessions` * `BlockUser` + * path/body validation and invalid-request mapping + * end-to-end internal HTTP read/revoke/block scenarios + * timeout mapping and invalid-success-payload rejection + * stable internal OpenAPI validation and frozen mutation DTO/enums + * trace/metric emission and sensitive-field log redaction * Redis adapter tests: * challenge store * session store * config provider * projection publisher +* Runtime and architecture tests: + + * public/internal HTTP server lifecycle + * intentional absence of `/healthz`, `/readyz`, and `/metrics` + * runtime wiring for `stub|rest` user-service and mail-service adapters + * startup fail-fast on Redis-backed ping failure + * storage-agnostic core for domain/service/ports layers ### Inter-service integration tests with already implemented components @@ -303,6 +320,7 @@ The testing plan follows this service order: * challenge persistence * session persistence * session projection compatibility + * duplicate publish keeps gateway cache canonical * `Gateway <-> Auth / Session <-> Redis` * login creates session @@ -310,11 +328,21 @@ The testing plan follows this service order: * repeated confirm repairs a previously failed projection publish * revoked session invalidates gateway authentication path * revoked session closes gateway push stream + * malformed client public key keeps stable client-facing error * `Auth / Session <-> stub Mail` * auth code send path * suppression path * explicit mail failure path +* `Auth / Session <-> Mail REST` + + * sent/suppressed/failure compatibility + * blocked/throttled sends skip mail delivery +* `Auth / Session <-> User REST` + + * resolve-by-email compatibility for public send + * ensure-user compatibility for confirm + * exists/block compatibility for internal revoke/block flows ### Regression tests to keep from this stage onward @@ -323,11 +351,16 @@ The testing plan follows this service order: * Confirm idempotency window behavior remains stable. * Projection repair-on-retry remains safe after source-of-truth commits. * Confirm-race cleanup does not leave multiple active winner sessions. +* Projection repair continues working after process restart. +* Redis reconnect on the same live process preserves recovery semantics. +* Expired challenges continue returning `challenge_expired` during grace and `challenge_not_found` after TTL cleanup. +* Large session-list and bulk-revoke paths remain stable. +* Concurrent confirm, revoke-all, and block flows do not leak active sessions. * Session projection remains compatible with gateway expectations. --- -## 3. User Service +## 3. [User](user/README.md) Service ### Service tests @@ -778,7 +811,7 @@ The testing plan follows this service order: --- -## 10. Geo Profile Service +## 10. [Geo Profile](geoprofile/README.md) Service ### Service tests diff --git a/authsession/internal/api/internalhttp/server_test.go b/authsession/internal/api/internalhttp/server_test.go index 31a295c..f1d9cbb 100644 --- a/authsession/internal/api/internalhttp/server_test.go +++ b/authsession/internal/api/internalhttp/server_test.go @@ -3,6 +3,8 @@ package internalhttp import ( "bytes" "context" + "io" + "net" "net/http" "testing" "time" @@ -34,7 +36,7 @@ func TestServerRunAndShutdown(t *testing.T) { t.Parallel() cfg := DefaultConfig() - cfg.Addr = "127.0.0.1:0" + cfg.Addr = mustFreeAddr(t) server, err := NewServer(cfg, validDependencies()) require.NoError(t, err) @@ -44,30 +46,48 @@ func TestServerRunAndShutdown(t *testing.T) { runErr <- server.Run(context.Background()) }() - require.Eventually(t, func() bool { - server.stateMu.RLock() - defer server.stateMu.RUnlock() - return server.listener != nil - }, time.Second, 10*time.Millisecond) - - server.stateMu.RLock() - addr := server.listener.Addr().String() - server.stateMu.RUnlock() - - response, err := http.Post( - "http://"+addr+"/api/v1/internal/sessions/device-session-123/revoke", - "application/json", - bytes.NewBufferString(`{"reason_code":"admin_revoke","actor":{"type":"system"}}`), - ) - require.NoError(t, err) - defer response.Body.Close() - - assert.Equal(t, http.StatusOK, response.StatusCode) + client := newTestHTTPClient(t) + waitForInternalRevokeReady(t, client, cfg.Addr) shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() require.NoError(t, server.Shutdown(shutdownCtx)) - require.NoError(t, <-runErr) + waitForServerRunResult(t, runErr) +} + +func TestServerDoesNotExposeProbeOrMetricsRoutes(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + cfg.Addr = mustFreeAddr(t) + + server, err := NewServer(cfg, validDependencies()) + require.NoError(t, err) + + runErr := make(chan error, 1) + go func() { + runErr <- server.Run(context.Background()) + }() + + client := newTestHTTPClient(t) + waitForInternalRevokeReady(t, client, cfg.Addr) + + for _, path := range []string{"/healthz", "/readyz", "/metrics"} { + request, reqErr := http.NewRequest(http.MethodGet, "http://"+cfg.Addr+path, nil) + require.NoError(t, reqErr) + + response, err := client.Do(request) + require.NoError(t, err) + _, _ = io.ReadAll(response.Body) + response.Body.Close() + + assert.Equalf(t, http.StatusNotFound, response.StatusCode, "path %s", path) + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.NoError(t, server.Shutdown(shutdownCtx)) + waitForServerRunResult(t, runErr) } func validDependencies() Dependencies { @@ -104,3 +124,63 @@ func validDependencies() Dependencies { }), } } + +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 waitForInternalRevokeReady(t *testing.T, client *http.Client, addr string) { + t.Helper() + + require.Eventually(t, func() bool { + response, err := client.Post( + "http://"+addr+"/api/v1/internal/sessions/device-session-123/revoke", + "application/json", + bytes.NewBufferString(`{"reason_code":"admin_revoke","actor":{"type":"system"}}`), + ) + if err != nil { + return false + } + defer response.Body.Close() + _, _ = io.ReadAll(response.Body) + + return response.StatusCode == http.StatusOK + }, 5*time.Second, 25*time.Millisecond, "internal HTTP server did not become reachable") +} + +func waitForServerRunResult(t *testing.T, runErr <-chan error) { + t.Helper() + + var err error + require.Eventually(t, func() bool { + select { + case err = <-runErr: + return true + default: + return false + } + }, 5*time.Second, 10*time.Millisecond, "internal HTTP server did not stop") + require.NoError(t, err) +} + +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() +} diff --git a/authsession/internal/api/publichttp/server_test.go b/authsession/internal/api/publichttp/server_test.go index 6070d65..c3201c0 100644 --- a/authsession/internal/api/publichttp/server_test.go +++ b/authsession/internal/api/publichttp/server_test.go @@ -3,6 +3,8 @@ package publichttp import ( "bytes" "context" + "io" + "net" "net/http" "testing" "time" @@ -37,7 +39,7 @@ func TestServerRunAndShutdown(t *testing.T) { t.Parallel() cfg := DefaultConfig() - cfg.Addr = "127.0.0.1:0" + cfg.Addr = mustFreeAddr(t) server, err := NewServer(cfg, Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { @@ -54,28 +56,113 @@ func TestServerRunAndShutdown(t *testing.T) { runErr <- server.Run(context.Background()) }() - require.Eventually(t, func() bool { - server.stateMu.RLock() - defer server.stateMu.RUnlock() - return server.listener != nil - }, time.Second, 10*time.Millisecond) - - server.stateMu.RLock() - addr := server.listener.Addr().String() - server.stateMu.RUnlock() - - response, err := http.Post( - "http://"+addr+"/api/v1/public/auth/send-email-code", - "application/json", - bytes.NewBufferString(`{"email":"pilot@example.com"}`), - ) - require.NoError(t, err) - defer response.Body.Close() - - assert.Equal(t, http.StatusOK, response.StatusCode) + client := newTestHTTPClient(t) + waitForPublicSendEmailCodeReady(t, client, cfg.Addr) shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() require.NoError(t, server.Shutdown(shutdownCtx)) - require.NoError(t, <-runErr) + waitForServerRunResult(t, runErr) +} + +func TestServerDoesNotExposeProbeOrMetricsRoutes(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + cfg.Addr = mustFreeAddr(t) + + server, err := NewServer(cfg, Dependencies{ + SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { + return sendemailcode.Result{ChallengeID: "challenge-123"}, nil + }), + ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { + return confirmemailcode.Result{DeviceSessionID: "device-session-123"}, nil + }), + }) + require.NoError(t, err) + + runErr := make(chan error, 1) + go func() { + runErr <- server.Run(context.Background()) + }() + + client := newTestHTTPClient(t) + waitForPublicSendEmailCodeReady(t, client, cfg.Addr) + + for _, path := range []string{"/healthz", "/readyz", "/metrics"} { + request, reqErr := http.NewRequest(http.MethodGet, "http://"+cfg.Addr+path, nil) + require.NoError(t, reqErr) + + response, err := client.Do(request) + require.NoError(t, err) + _, _ = io.ReadAll(response.Body) + response.Body.Close() + + assert.Equalf(t, http.StatusNotFound, response.StatusCode, "path %s", path) + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.NoError(t, server.Shutdown(shutdownCtx)) + waitForServerRunResult(t, runErr) +} + +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 HTTP server did not become reachable") +} + +func waitForServerRunResult(t *testing.T, runErr <-chan error) { + t.Helper() + + var err error + require.Eventually(t, func() bool { + select { + case err = <-runErr: + return true + default: + return false + } + }, 5*time.Second, 10*time.Millisecond, "public HTTP server did not stop") + require.NoError(t, err) +} + +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() } diff --git a/authsession/internal/app/runtime_test.go b/authsession/internal/app/runtime_test.go index 62c33d5..0c8050c 100644 --- a/authsession/internal/app/runtime_test.go +++ b/authsession/internal/app/runtime_test.go @@ -3,6 +3,7 @@ package app import ( "bytes" "context" + "errors" "io" "net" "net/http" @@ -28,6 +29,7 @@ func TestNewRuntimeStartsAndStopsHTTPServers(t *testing.T) { 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) @@ -43,34 +45,12 @@ func TestNewRuntimeStartsAndStopsHTTPServers(t *testing.T) { runErrCh <- runtime.App.Run(runCtx) }() - require.Eventually(t, func() bool { - response, err := http.Post( - "http://"+cfg.PublicHTTP.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) - - require.Eventually(t, func() bool { - response, err := http.Get("http://" + cfg.InternalHTTP.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) + client := newTestHTTPClient(t) + waitForPublicSendEmailCodeReady(t, client, cfg.PublicHTTP.Addr) + waitForInternalGetMissingReady(t, client, cfg.InternalHTTP.Addr) cancel() - require.NoError(t, <-runErrCh) + waitForAppRunResult(t, runErrCh, cfg.ShutdownTimeout+2*time.Second) } func TestNewRuntimeUsesRESTUserDirectoryWhenConfigured(t *testing.T) { @@ -95,6 +75,7 @@ func TestNewRuntimeUsesRESTUserDirectoryWhenConfigured(t *testing.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) @@ -110,29 +91,11 @@ func TestNewRuntimeUsesRESTUserDirectoryWhenConfigured(t *testing.T) { runErrCh <- runtime.App.Run(runCtx) }() - require.Eventually(t, func() bool { - response, err := http.Post( - "http://"+cfg.InternalHTTP.Addr+"/api/v1/internal/users/user-1/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":"user-1"`)) - }, 5*time.Second, 25*time.Millisecond) + client := newTestHTTPClient(t) + waitForInternalRevokeAllReady(t, client, cfg.InternalHTTP.Addr, "user-1") cancel() - require.NoError(t, <-runErrCh) + waitForAppRunResult(t, runErrCh, cfg.ShutdownTimeout+2*time.Second) } func TestNewRuntimeUsesRESTMailSenderWhenConfigured(t *testing.T) { @@ -159,6 +122,7 @@ func TestNewRuntimeUsesRESTMailSenderWhenConfigured(t *testing.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) @@ -174,29 +138,26 @@ func TestNewRuntimeUsesRESTMailSenderWhenConfigured(t *testing.T) { runErrCh <- runtime.App.Run(runCtx) }() + client := newTestHTTPClient(t) + waitForPublicSendEmailCodeReady(t, client, cfg.PublicHTTP.Addr) require.Eventually(t, func() bool { - response, err := http.Post( - "http://"+cfg.PublicHTTP.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() - - payload, err := io.ReadAll(response.Body) - if err != nil { - return false - } - - return response.StatusCode == http.StatusOK && - bytes.Contains(payload, []byte(`"challenge_id":"`)) && - calls.Load() == 1 - }, 5*time.Second, 25*time.Millisecond) + return calls.Load() == 1 + }, 5*time.Second, 25*time.Millisecond, "REST mail sender was not invoked") cancel() - require.NoError(t, <-runErrCh) + 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 { @@ -210,3 +171,95 @@ func mustFreeAddr(t *testing.T) string { 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) +}