Organize backend into product executable and shared Go modules
This commit is contained in:
1 parent
981e84a4b4
commit
f0720ec66b
382 files changed
+1944
-6989
No files matched your search
@@ -0,0 +1,204 @@
|
||||
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")
|
||||
}
|
||||
Reference in new issue
Block a user