package public import ( "context" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "git.sendico.io/tech/sxchnge/product/sessions/browser" "git.sendico.io/tech/sxchnge/product/sessions/store" "git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/service" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) type logoutDB struct { refreshtokens.DB mu sync.Mutex tokens map[string]*sessionmodel.RefreshToken lookupErr error revokeErr error } func (db *logoutDB) RevokeToken(_ context.Context, request *sessionmodel.ClientRefreshToken) (bool, error) { db.mu.Lock() defer db.mu.Unlock() if db.lookupErr != nil { return false, db.lookupErr } token := db.tokens[request.DeviceID] if token == nil || token.IsRevoked || token.RefreshToken != request.RefreshToken { return false, nil } if db.revokeErr != nil { return false, db.revokeErr } token.IsRevoked = true return true, nil } func newLogoutToken(accountRef bson.ObjectID, deviceID, value string) *sessionmodel.RefreshToken { return &sessionmodel.RefreshToken{ ClientRefreshToken: sessionmodel.ClientRefreshToken{ SessionIdentifier: sessionmodel.SessionIdentifier{ClientID: "web", DeviceID: deviceID, SessionVersion: "version-" + deviceID}, RefreshToken: value, }, AccountRef: &accountRef, ExpiresAt: time.Now().Add(time.Hour), } } func executeLogout(router *PublicRouter, deviceID, refreshToken string) int { body := `{"clientId":"web","deviceId":"` + deviceID + `","token":"` + refreshToken + `"}` request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/accounts/logout", strings.NewReader(body)) response := httptest.NewRecorder() router.logout(request)(response, request) return response.Code } func TestLogoutRevokesOnlyCurrentSessionAndIsIdempotent(t *testing.T) { accountRef := bson.NewObjectID() db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{ "device-a": newLogoutToken(accountRef, "device-a", "refresh-a"), "device-b": newLogoutToken(accountRef, "device-b", "refresh-b"), }} router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} if status := executeLogout(router, "device-a", "refresh-a"); status != http.StatusOK { t.Fatalf("first logout status = %d", status) } if status := executeLogout(router, "device-a", "refresh-a"); status != http.StatusOK { t.Fatalf("repeated logout status = %d", status) } if !db.tokens["device-a"].IsRevoked { t.Fatal("current device session was not revoked") } if db.tokens["device-b"].IsRevoked { t.Fatal("another device session was revoked") } } func TestConcurrentLogoutIsIdempotent(t *testing.T) { accountRef := bson.NewObjectID() db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{ "device-a": newLogoutToken(accountRef, "device-a", "refresh-a"), }} router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} statuses := make(chan int, 8) var workers sync.WaitGroup for range 8 { workers.Add(1) go func() { defer workers.Done() statuses <- executeLogout(router, "device-a", "refresh-a") }() } workers.Wait() close(statuses) for status := range statuses { if status != http.StatusOK { t.Fatalf("concurrent logout status = %d", status) } } } func TestDelayedLogoutDoesNotRevokeReplacementSession(t *testing.T) { accountRef := bson.NewObjectID() db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{ "device-a": newLogoutToken(accountRef, "device-a", "new-refresh"), }} router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} if status := executeLogout(router, "device-a", "old-refresh"); status != http.StatusOK { t.Fatalf("delayed logout status = %d", status) } if db.tokens["device-a"].IsRevoked { t.Fatal("replacement session was revoked by a stale logout request") } } func TestLogoutAcceptsAnAlreadyExpiredSession(t *testing.T) { accountRef := bson.NewObjectID() expired := newLogoutToken(accountRef, "device-a", "expired-refresh") expired.ExpiresAt = time.Now().Add(-time.Hour) db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{"device-a": expired}} router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} if status := executeLogout(router, "device-a", "expired-refresh"); status != http.StatusOK { t.Fatalf("expired logout status = %d", status) } if !expired.IsRevoked { t.Fatal("expired session was not marked revoked before cleanup") } } func TestLogoutFailsClosedWhenSessionStoreIsUnavailable(t *testing.T) { db := &logoutDB{lookupErr: merrors.Internal("database unavailable")} router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} if status := executeLogout(router, "device-a", "refresh-a"); status != http.StatusInternalServerError { t.Fatalf("logout status = %d, want %d", status, http.StatusInternalServerError) } } func TestBrowserLogoutUsesCookieCredentialAndClearsIt(t *testing.T) { accountRef := bson.NewObjectID() token := newLogoutToken(accountRef, "device-a", "cookie-refresh") db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{"device-a": token}} router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} body := `{"clientId":"web","deviceId":"device-a","token":"body-must-be-ignored"}` request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body)) request.Header.Set(browser.TransportHeader, browser.TransportCookie) request.AddCookie(&http.Cookie{Name: browser.RefreshCookie, Value: "cookie-refresh"}) response := httptest.NewRecorder() router.logout(request)(response, request) require.Equal(t, http.StatusOK, response.Code) require.True(t, token.IsRevoked) cookies := response.Result().Cookies() require.Len(t, cookies, 1) require.Equal(t, browser.RefreshCookie, cookies[0].Name) require.Equal(t, -1, cookies[0].MaxAge) require.Equal(t, "/api/v1/accounts", cookies[0].Path) } func TestBrowserLogoutWithoutCookieIsIdempotentAndClearsBrowserState(t *testing.T) { db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{}} router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} body := `{"clientId":"web","deviceId":"device-a","token":"body-must-be-ignored"}` request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body)) request.Header.Set(browser.TransportHeader, browser.TransportCookie) response := httptest.NewRecorder() router.logout(request)(response, request) require.Equal(t, http.StatusOK, response.Code) require.Contains(t, response.Header().Get("Set-Cookie"), browser.RefreshCookie+"=") require.Contains(t, response.Header().Get("Set-Cookie"), "Max-Age=0") } func TestBrowserLogoutKeepsCookieWhenRevocationFails(t *testing.T) { db := &logoutDB{ tokens: map[string]*sessionmodel.RefreshToken{}, lookupErr: merrors.Internal("database unavailable"), } router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} body := `{"clientId":"web","deviceId":"device-a"}` request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body)) request.Header.Set(browser.TransportHeader, browser.TransportCookie) request.AddCookie(&http.Cookie{Name: browser.RefreshCookie, Value: "cookie-refresh"}) response := httptest.NewRecorder() router.logout(request)(response, request) require.Equal(t, http.StatusInternalServerError, response.Code) require.Empty(t, response.Header().Get("Set-Cookie"), "client must retain the cookie so logout can retry revocation") }