feat: authsession service
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
// Package internalhttp exposes the trusted internal HTTP API used for session
|
||||
// read, revoke, and block operations.
|
||||
package internalhttp
|
||||
@@ -0,0 +1,286 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/adapters/userservice"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
"galaxy/authsession/internal/service/blockuser"
|
||||
"galaxy/authsession/internal/service/getsession"
|
||||
"galaxy/authsession/internal/service/listusersessions"
|
||||
"galaxy/authsession/internal/service/revokeallusersessions"
|
||||
"galaxy/authsession/internal/service/revokedevicesession"
|
||||
"galaxy/authsession/internal/testkit"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInternalHTTPEndToEndGetSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newEndToEndApp(t)
|
||||
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-1", "user-1", testClientPublicKey(t, validClientPublicKey), time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC))))
|
||||
server := httptest.NewServer(app.handler)
|
||||
defer server.Close()
|
||||
|
||||
response := getJSON(t, server.URL+"/api/v1/internal/sessions/device-session-1")
|
||||
|
||||
assert.Equal(t, http.StatusOK, response.StatusCode)
|
||||
assert.JSONEq(t, `{"session":{"device_session_id":"device-session-1","user_id":"user-1","client_public_key":"`+validClientPublicKey+`","status":"active","created_at":"2026-04-05T12:00:00Z"}}`, response.Body)
|
||||
}
|
||||
|
||||
func TestInternalHTTPEndToEndListUserSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newEndToEndApp(t)
|
||||
key := testClientPublicKey(t, validClientPublicKey)
|
||||
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-1", "user-1", key, time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC))))
|
||||
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-2", "user-1", key, time.Date(2026, 4, 5, 12, 1, 0, 0, time.UTC))))
|
||||
server := httptest.NewServer(app.handler)
|
||||
defer server.Close()
|
||||
|
||||
response := getJSON(t, server.URL+"/api/v1/internal/users/user-1/sessions")
|
||||
|
||||
assert.Equal(t, http.StatusOK, response.StatusCode)
|
||||
assert.Contains(t, response.Body, `"device_session_id":"device-session-2"`)
|
||||
assert.Contains(t, response.Body, `"device_session_id":"device-session-1"`)
|
||||
assert.Less(t, bytes.Index([]byte(response.Body), []byte(`"device_session_id":"device-session-2"`)), bytes.Index([]byte(response.Body), []byte(`"device_session_id":"device-session-1"`)))
|
||||
}
|
||||
|
||||
func TestInternalHTTPEndToEndListUserSessionsUnknownUserReturnsEmptyArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newEndToEndApp(t)
|
||||
server := httptest.NewServer(app.handler)
|
||||
defer server.Close()
|
||||
|
||||
response := getJSON(t, server.URL+"/api/v1/internal/users/unknown-user/sessions")
|
||||
|
||||
assert.Equal(t, http.StatusOK, response.StatusCode)
|
||||
assert.JSONEq(t, `{"sessions":[]}`, response.Body)
|
||||
}
|
||||
|
||||
func TestInternalHTTPEndToEndGetSessionNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newEndToEndApp(t)
|
||||
server := httptest.NewServer(app.handler)
|
||||
defer server.Close()
|
||||
|
||||
response := getJSON(t, server.URL+"/api/v1/internal/sessions/missing-session")
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, response.StatusCode)
|
||||
assert.JSONEq(t, `{"error":{"code":"session_not_found","message":"session not found"}}`, response.Body)
|
||||
}
|
||||
|
||||
func TestInternalHTTPEndToEndRevokeDeviceSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newEndToEndApp(t)
|
||||
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-1", "user-1", testClientPublicKey(t, validClientPublicKey), time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC))))
|
||||
server := httptest.NewServer(app.handler)
|
||||
defer server.Close()
|
||||
|
||||
response := postJSON(t, server.URL+"/api/v1/internal/sessions/device-session-1/revoke", `{"reason_code":"admin_revoke","actor":{"type":"system"}}`)
|
||||
|
||||
assert.Equal(t, http.StatusOK, response.StatusCode)
|
||||
assert.JSONEq(t, `{"outcome":"revoked","device_session_id":"device-session-1","affected_session_count":1}`, response.Body)
|
||||
}
|
||||
|
||||
func TestInternalHTTPEndToEndRevokeAllUserSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newEndToEndApp(t)
|
||||
require.NoError(t, app.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
|
||||
key := testClientPublicKey(t, validClientPublicKey)
|
||||
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-1", "user-1", key, time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC))))
|
||||
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-2", "user-1", key, time.Date(2026, 4, 5, 12, 1, 0, 0, time.UTC))))
|
||||
server := httptest.NewServer(app.handler)
|
||||
defer server.Close()
|
||||
|
||||
response := postJSON(t, server.URL+"/api/v1/internal/users/user-1/sessions/revoke-all", `{"reason_code":"logout_all","actor":{"type":"system"}}`)
|
||||
|
||||
assert.Equal(t, http.StatusOK, response.StatusCode)
|
||||
assert.JSONEq(t, `{"outcome":"revoked","user_id":"user-1","affected_session_count":2,"affected_device_session_ids":["device-session-2","device-session-1"]}`, response.Body)
|
||||
}
|
||||
|
||||
func TestInternalHTTPEndToEndRevokeAllUserSessionsNoActiveSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newEndToEndApp(t)
|
||||
require.NoError(t, app.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
|
||||
server := httptest.NewServer(app.handler)
|
||||
defer server.Close()
|
||||
|
||||
response := postJSON(t, server.URL+"/api/v1/internal/users/user-1/sessions/revoke-all", `{"reason_code":"logout_all","actor":{"type":"system"}}`)
|
||||
|
||||
assert.Equal(t, http.StatusOK, response.StatusCode)
|
||||
assert.JSONEq(t, `{"outcome":"no_active_sessions","user_id":"user-1","affected_session_count":0,"affected_device_session_ids":[]}`, response.Body)
|
||||
}
|
||||
|
||||
func TestInternalHTTPEndToEndRevokeAllUserSessionsUnknownUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newEndToEndApp(t)
|
||||
server := httptest.NewServer(app.handler)
|
||||
defer server.Close()
|
||||
|
||||
response := postJSON(t, server.URL+"/api/v1/internal/users/missing-user/sessions/revoke-all", `{"reason_code":"logout_all","actor":{"type":"system"}}`)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, response.StatusCode)
|
||||
assert.JSONEq(t, `{"error":{"code":"subject_not_found","message":"subject not found"}}`, response.Body)
|
||||
}
|
||||
|
||||
func TestInternalHTTPEndToEndBlockUserByEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newEndToEndApp(t)
|
||||
server := httptest.NewServer(app.handler)
|
||||
defer server.Close()
|
||||
|
||||
response := postJSON(t, server.URL+"/api/v1/internal/user-blocks", `{"email":"pilot@example.com","reason_code":"policy_blocked","actor":{"type":"admin"}}`)
|
||||
|
||||
assert.Equal(t, http.StatusOK, response.StatusCode)
|
||||
assert.JSONEq(t, `{"outcome":"blocked","subject_kind":"email","subject_value":"pilot@example.com","affected_session_count":0,"affected_device_session_ids":[]}`, response.Body)
|
||||
}
|
||||
|
||||
func TestInternalHTTPEndToEndBlockUserByUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newEndToEndApp(t)
|
||||
require.NoError(t, app.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
|
||||
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-1", "user-1", testClientPublicKey(t, validClientPublicKey), time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC))))
|
||||
server := httptest.NewServer(app.handler)
|
||||
defer server.Close()
|
||||
|
||||
response := postJSON(t, server.URL+"/api/v1/internal/user-blocks", `{"user_id":"user-1","reason_code":"policy_blocked","actor":{"type":"admin"}}`)
|
||||
|
||||
assert.Equal(t, http.StatusOK, response.StatusCode)
|
||||
assert.JSONEq(t, `{"outcome":"blocked","subject_kind":"user_id","subject_value":"user-1","affected_session_count":1,"affected_device_session_ids":["device-session-1"]}`, response.Body)
|
||||
}
|
||||
|
||||
func TestInternalHTTPEndToEndBlockUserUnknownUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newEndToEndApp(t)
|
||||
server := httptest.NewServer(app.handler)
|
||||
defer server.Close()
|
||||
|
||||
response := postJSON(t, server.URL+"/api/v1/internal/user-blocks", `{"user_id":"missing-user","reason_code":"policy_blocked","actor":{"type":"admin"}}`)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, response.StatusCode)
|
||||
assert.JSONEq(t, `{"error":{"code":"subject_not_found","message":"subject not found"}}`, response.Body)
|
||||
}
|
||||
|
||||
type endToEndApp struct {
|
||||
handler http.Handler
|
||||
sessionStore *testkit.InMemorySessionStore
|
||||
userDirectory *userservice.StubDirectory
|
||||
}
|
||||
|
||||
func newEndToEndApp(t *testing.T) endToEndApp {
|
||||
t.Helper()
|
||||
|
||||
sessionStore := &testkit.InMemorySessionStore{}
|
||||
userDirectory := &userservice.StubDirectory{}
|
||||
publisher := &testkit.RecordingProjectionPublisher{}
|
||||
clock := testkit.FixedClock{Time: time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)}
|
||||
|
||||
getSessionService, err := getsession.New(sessionStore)
|
||||
require.NoError(t, err)
|
||||
listUserSessionsService, err := listusersessions.New(sessionStore)
|
||||
require.NoError(t, err)
|
||||
revokeDeviceSessionService, err := revokedevicesession.New(sessionStore, publisher, clock)
|
||||
require.NoError(t, err)
|
||||
revokeAllUserSessionsService, err := revokeallusersessions.New(sessionStore, userDirectory, publisher, clock)
|
||||
require.NoError(t, err)
|
||||
blockUserService, err := blockuser.New(userDirectory, sessionStore, publisher, clock)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
|
||||
GetSession: getSessionService,
|
||||
ListUserSessions: listUserSessionsService,
|
||||
RevokeDeviceSession: revokeDeviceSessionService,
|
||||
RevokeAllUserSessions: revokeAllUserSessionsService,
|
||||
BlockUser: blockUserService,
|
||||
})
|
||||
|
||||
return endToEndApp{
|
||||
handler: handler,
|
||||
sessionStore: sessionStore,
|
||||
userDirectory: userDirectory,
|
||||
}
|
||||
}
|
||||
|
||||
type httpResponse struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func getJSON(t *testing.T, url string) httpResponse {
|
||||
t.Helper()
|
||||
|
||||
response, err := http.Get(url)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return httpResponse{StatusCode: response.StatusCode, Body: string(payload)}
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, url string, body string) httpResponse {
|
||||
t.Helper()
|
||||
|
||||
response, err := http.Post(url, "application/json", bytes.NewBufferString(body))
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return httpResponse{StatusCode: response.StatusCode, Body: string(payload)}
|
||||
}
|
||||
|
||||
func postJSONValue(t *testing.T, url string, value any) httpResponse {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
return postJSON(t, url, string(body))
|
||||
}
|
||||
|
||||
func activeSession(id string, userID string, key common.ClientPublicKey, createdAt time.Time) devicesession.Session {
|
||||
return devicesession.Session{
|
||||
ID: common.DeviceSessionID(id),
|
||||
UserID: common.UserID(userID),
|
||||
ClientPublicKey: key,
|
||||
Status: devicesession.StatusActive,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func testClientPublicKey(t *testing.T, encoded string) common.ClientPublicKey {
|
||||
t.Helper()
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := common.NewClientPublicKey(ed25519.PublicKey(decoded))
|
||||
require.NoError(t, err)
|
||||
return key
|
||||
}
|
||||
|
||||
const validClientPublicKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
@@ -0,0 +1,513 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/service/blockuser"
|
||||
"galaxy/authsession/internal/service/getsession"
|
||||
"galaxy/authsession/internal/service/listusersessions"
|
||||
"galaxy/authsession/internal/service/revokeallusersessions"
|
||||
"galaxy/authsession/internal/service/revokedevicesession"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
"galaxy/authsession/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
)
|
||||
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
const internalHTTPServiceName = "galaxy-authsession-internal"
|
||||
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type actorRequest struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type sessionResponseDTO struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
UserID string `json:"user_id"`
|
||||
ClientPublicKey string `json:"client_public_key"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
RevokedAt *string `json:"revoked_at,omitempty"`
|
||||
RevokeReasonCode *string `json:"revoke_reason_code,omitempty"`
|
||||
RevokeActorType *string `json:"revoke_actor_type,omitempty"`
|
||||
RevokeActorID *string `json:"revoke_actor_id,omitempty"`
|
||||
}
|
||||
|
||||
type getSessionResponse struct {
|
||||
Session sessionResponseDTO `json:"session"`
|
||||
}
|
||||
|
||||
type listUserSessionsResponse struct {
|
||||
Sessions []sessionResponseDTO `json:"sessions"`
|
||||
}
|
||||
|
||||
type revokeDeviceSessionRequest struct {
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorRequest `json:"actor"`
|
||||
}
|
||||
|
||||
type revokeDeviceSessionResponse struct {
|
||||
Outcome string `json:"outcome"`
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
AffectedSessionCount int64 `json:"affected_session_count"`
|
||||
}
|
||||
|
||||
type revokeAllUserSessionsRequest struct {
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorRequest `json:"actor"`
|
||||
}
|
||||
|
||||
type revokeAllUserSessionsResponse struct {
|
||||
Outcome string `json:"outcome"`
|
||||
UserID string `json:"user_id"`
|
||||
AffectedSessionCount int64 `json:"affected_session_count"`
|
||||
AffectedDeviceSessionIDs []string `json:"affected_device_session_ids"`
|
||||
}
|
||||
|
||||
type blockUserRequest struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorRequest `json:"actor"`
|
||||
}
|
||||
|
||||
type blockUserResponse struct {
|
||||
Outcome string `json:"outcome"`
|
||||
SubjectKind string `json:"subject_kind"`
|
||||
SubjectValue string `json:"subject_value"`
|
||||
AffectedSessionCount int64 `json:"affected_session_count"`
|
||||
AffectedDeviceSessionIDs []string `json:"affected_device_session_ids"`
|
||||
}
|
||||
|
||||
var configureGinModeOnce sync.Once
|
||||
|
||||
func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalizedDeps, err := normalizeDependencies(deps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configureGinModeOnce.Do(func() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
})
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(newOTelMiddleware(normalizedDeps.Telemetry))
|
||||
engine.Use(withInternalObservability(normalizedDeps.Logger, normalizedDeps.Telemetry))
|
||||
engine.GET("/api/v1/internal/sessions/:device_session_id", handleGetSession(normalizedDeps.GetSession, cfg.RequestTimeout))
|
||||
engine.GET("/api/v1/internal/users/:user_id/sessions", handleListUserSessions(normalizedDeps.ListUserSessions, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/sessions/:device_session_id/revoke", handleRevokeDeviceSession(normalizedDeps.RevokeDeviceSession, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/sessions/revoke-all", handleRevokeAllUserSessions(normalizedDeps.RevokeAllUserSessions, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/user-blocks", handleBlockUser(normalizedDeps.BlockUser, cfg.RequestTimeout))
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
func newOTelMiddleware(runtime *telemetry.Runtime) gin.HandlerFunc {
|
||||
options := []otelgin.Option{}
|
||||
if runtime != nil {
|
||||
options = append(
|
||||
options,
|
||||
otelgin.WithTracerProvider(runtime.TracerProvider()),
|
||||
otelgin.WithMeterProvider(runtime.MeterProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
return otelgin.Middleware(internalHTTPServiceName, options...)
|
||||
}
|
||||
|
||||
func handleGetSession(useCase GetSessionUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, getsession.Input{
|
||||
DeviceSessionID: c.Param("device_session_id"),
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, projectInternalError(err))
|
||||
return
|
||||
}
|
||||
if err := validateGetSessionResult(&result); err != nil {
|
||||
abortWithProjection(c, internalErrorProjection(fmt.Errorf("get session response: %w", err)))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, getSessionResponse{Session: toSessionResponseDTO(result.Session)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleListUserSessions(useCase ListUserSessionsUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, listusersessions.Input{
|
||||
UserID: c.Param("user_id"),
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, projectInternalError(err))
|
||||
return
|
||||
}
|
||||
if err := validateListUserSessionsResult(&result); err != nil {
|
||||
abortWithProjection(c, internalErrorProjection(fmt.Errorf("list user sessions response: %w", err)))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, listUserSessionsResponse{Sessions: toSessionResponseDTOs(result.Sessions)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleRevokeDeviceSession(useCase RevokeDeviceSessionUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request revokeDeviceSessionRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
if err := validateAuditRequest(request.ReasonCode, request.Actor); err != nil {
|
||||
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, revokedevicesession.Input{
|
||||
DeviceSessionID: c.Param("device_session_id"),
|
||||
ReasonCode: request.ReasonCode,
|
||||
ActorType: request.Actor.Type,
|
||||
ActorID: request.Actor.ID,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, projectInternalError(err))
|
||||
return
|
||||
}
|
||||
if err := validateRevokeDeviceSessionResult(&result); err != nil {
|
||||
abortWithProjection(c, internalErrorProjection(fmt.Errorf("revoke device session response: %w", err)))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, revokeDeviceSessionResponse{
|
||||
Outcome: result.Outcome,
|
||||
DeviceSessionID: result.DeviceSessionID,
|
||||
AffectedSessionCount: result.AffectedSessionCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleRevokeAllUserSessions(useCase RevokeAllUserSessionsUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request revokeAllUserSessionsRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
if err := validateAuditRequest(request.ReasonCode, request.Actor); err != nil {
|
||||
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, revokeallusersessions.Input{
|
||||
UserID: c.Param("user_id"),
|
||||
ReasonCode: request.ReasonCode,
|
||||
ActorType: request.Actor.Type,
|
||||
ActorID: request.Actor.ID,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, projectInternalError(err))
|
||||
return
|
||||
}
|
||||
if err := validateRevokeAllUserSessionsResult(&result); err != nil {
|
||||
abortWithProjection(c, internalErrorProjection(fmt.Errorf("revoke all user sessions response: %w", err)))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, revokeAllUserSessionsResponse{
|
||||
Outcome: result.Outcome,
|
||||
UserID: result.UserID,
|
||||
AffectedSessionCount: result.AffectedSessionCount,
|
||||
AffectedDeviceSessionIDs: cloneStrings(result.AffectedDeviceSessionIDs),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleBlockUser(useCase BlockUserUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request blockUserRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
if err := validateBlockUserRequest(&request); err != nil {
|
||||
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, blockuser.Input{
|
||||
UserID: request.UserID,
|
||||
Email: request.Email,
|
||||
ReasonCode: request.ReasonCode,
|
||||
ActorType: request.Actor.Type,
|
||||
ActorID: request.Actor.ID,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, projectInternalError(err))
|
||||
return
|
||||
}
|
||||
if err := validateBlockUserResult(&result); err != nil {
|
||||
abortWithProjection(c, internalErrorProjection(fmt.Errorf("block user response: %w", err)))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, blockUserResponse{
|
||||
Outcome: result.Outcome,
|
||||
SubjectKind: result.SubjectKind,
|
||||
SubjectValue: result.SubjectValue,
|
||||
AffectedSessionCount: result.AffectedSessionCount,
|
||||
AffectedDeviceSessionIDs: cloneStrings(result.AffectedDeviceSessionIDs),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func toSessionResponseDTO(session shared.Session) sessionResponseDTO {
|
||||
return sessionResponseDTO{
|
||||
DeviceSessionID: session.DeviceSessionID,
|
||||
UserID: session.UserID,
|
||||
ClientPublicKey: session.ClientPublicKey,
|
||||
Status: session.Status,
|
||||
CreatedAt: session.CreatedAt,
|
||||
RevokedAt: cloneStringPointer(session.RevokedAt),
|
||||
RevokeReasonCode: cloneStringPointer(session.RevokeReasonCode),
|
||||
RevokeActorType: cloneStringPointer(session.RevokeActorType),
|
||||
RevokeActorID: cloneStringPointer(session.RevokeActorID),
|
||||
}
|
||||
}
|
||||
|
||||
func toSessionResponseDTOs(sessions []shared.Session) []sessionResponseDTO {
|
||||
result := make([]sessionResponseDTO, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
result = append(result, toSessionResponseDTO(session))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneStrings(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
return append(result, values...)
|
||||
}
|
||||
|
||||
func cloneStringPointer(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func validateAuditRequest(reasonCode string, actor actorRequest) error {
|
||||
if strings.TrimSpace(reasonCode) == "" {
|
||||
return errors.New("reason_code must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(actor.Type) == "" {
|
||||
return errors.New("actor.type must not be empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBlockUserRequest(request *blockUserRequest) error {
|
||||
if err := validateAuditRequest(request.ReasonCode, request.Actor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasUserID := strings.TrimSpace(request.UserID) != ""
|
||||
hasEmail := strings.TrimSpace(request.Email) != ""
|
||||
switch {
|
||||
case hasUserID && hasEmail:
|
||||
return errors.New("exactly one of user_id or email must be provided")
|
||||
case !hasUserID && !hasEmail:
|
||||
return errors.New("exactly one of user_id or email must be provided")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateSessionDTO(session *shared.Session) error {
|
||||
switch {
|
||||
case strings.TrimSpace(session.DeviceSessionID) == "":
|
||||
return errors.New("session.device_session_id must not be empty")
|
||||
case strings.TrimSpace(session.UserID) == "":
|
||||
return errors.New("session.user_id must not be empty")
|
||||
case strings.TrimSpace(session.ClientPublicKey) == "":
|
||||
return errors.New("session.client_public_key must not be empty")
|
||||
case strings.TrimSpace(session.CreatedAt) == "":
|
||||
return errors.New("session.created_at must not be empty")
|
||||
}
|
||||
|
||||
if _, err := time.Parse(time.RFC3339, session.CreatedAt); err != nil {
|
||||
return fmt.Errorf("session.created_at: %w", err)
|
||||
}
|
||||
|
||||
switch session.Status {
|
||||
case "active":
|
||||
if session.RevokedAt != nil || session.RevokeReasonCode != nil || session.RevokeActorType != nil || session.RevokeActorID != nil {
|
||||
return errors.New("active session must not contain revoke metadata")
|
||||
}
|
||||
case "revoked":
|
||||
switch {
|
||||
case session.RevokedAt == nil || strings.TrimSpace(*session.RevokedAt) == "":
|
||||
return errors.New("revoked session must contain revoked_at")
|
||||
case session.RevokeReasonCode == nil || strings.TrimSpace(*session.RevokeReasonCode) == "":
|
||||
return errors.New("revoked session must contain revoke_reason_code")
|
||||
case session.RevokeActorType == nil || strings.TrimSpace(*session.RevokeActorType) == "":
|
||||
return errors.New("revoked session must contain revoke_actor_type")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, *session.RevokedAt); err != nil {
|
||||
return fmt.Errorf("session.revoked_at: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("session.status %q is unsupported", session.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGetSessionResult(result *getsession.Result) error {
|
||||
return validateSessionDTO(&result.Session)
|
||||
}
|
||||
|
||||
func validateListUserSessionsResult(result *listusersessions.Result) error {
|
||||
if result.Sessions == nil {
|
||||
return errors.New("sessions must not be null")
|
||||
}
|
||||
|
||||
for index := range result.Sessions {
|
||||
if err := validateSessionDTO(&result.Sessions[index]); err != nil {
|
||||
return fmt.Errorf("sessions[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRevokeDeviceSessionResult(result *revokedevicesession.Result) error {
|
||||
switch result.Outcome {
|
||||
case "revoked":
|
||||
if result.AffectedSessionCount != 1 {
|
||||
return errors.New("revoked outcome must affect exactly one session")
|
||||
}
|
||||
case "already_revoked":
|
||||
if result.AffectedSessionCount != 0 {
|
||||
return errors.New("already_revoked outcome must affect zero sessions")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("revoke device session outcome %q is unsupported", result.Outcome)
|
||||
}
|
||||
if strings.TrimSpace(result.DeviceSessionID) == "" {
|
||||
return errors.New("device_session_id must not be empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRevokeAllUserSessionsResult(result *revokeallusersessions.Result) error {
|
||||
switch result.Outcome {
|
||||
case "revoked", "no_active_sessions":
|
||||
default:
|
||||
return fmt.Errorf("revoke all user sessions outcome %q is unsupported", result.Outcome)
|
||||
}
|
||||
if strings.TrimSpace(result.UserID) == "" {
|
||||
return errors.New("user_id must not be empty")
|
||||
}
|
||||
if result.AffectedSessionCount < 0 {
|
||||
return errors.New("affected_session_count must not be negative")
|
||||
}
|
||||
if result.AffectedDeviceSessionIDs == nil {
|
||||
return errors.New("affected_device_session_ids must not be null")
|
||||
}
|
||||
if int64(len(result.AffectedDeviceSessionIDs)) != result.AffectedSessionCount {
|
||||
return errors.New("affected_device_session_ids length must match affected_session_count")
|
||||
}
|
||||
for index, deviceSessionID := range result.AffectedDeviceSessionIDs {
|
||||
if strings.TrimSpace(deviceSessionID) == "" {
|
||||
return fmt.Errorf("affected_device_session_ids[%d] must not be empty", index)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBlockUserResult(result *blockuser.Result) error {
|
||||
switch result.Outcome {
|
||||
case "blocked", "already_blocked":
|
||||
default:
|
||||
return fmt.Errorf("block user outcome %q is unsupported", result.Outcome)
|
||||
}
|
||||
switch result.SubjectKind {
|
||||
case blockuser.SubjectKindUserID, blockuser.SubjectKindEmail:
|
||||
default:
|
||||
return fmt.Errorf("subject_kind %q is unsupported", result.SubjectKind)
|
||||
}
|
||||
if strings.TrimSpace(result.SubjectValue) == "" {
|
||||
return errors.New("subject_value must not be empty")
|
||||
}
|
||||
if result.AffectedSessionCount < 0 {
|
||||
return errors.New("affected_session_count must not be negative")
|
||||
}
|
||||
if result.AffectedDeviceSessionIDs == nil {
|
||||
return errors.New("affected_device_session_ids must not be null")
|
||||
}
|
||||
if int64(len(result.AffectedDeviceSessionIDs)) != result.AffectedSessionCount {
|
||||
return errors.New("affected_device_session_ids length must match affected_session_count")
|
||||
}
|
||||
for index, deviceSessionID := range result.AffectedDeviceSessionIDs {
|
||||
if strings.TrimSpace(deviceSessionID) == "" {
|
||||
return fmt.Errorf("affected_device_session_ids[%d] must not be empty", index)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func projectInternalError(err error) shared.InternalErrorProjection {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return shared.ProjectInternalError(shared.ServiceUnavailable(err))
|
||||
}
|
||||
|
||||
return shared.ProjectInternalError(err)
|
||||
}
|
||||
|
||||
func internalErrorProjection(err error) shared.InternalErrorProjection {
|
||||
return shared.ProjectInternalError(shared.InternalError(err))
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/service/blockuser"
|
||||
"galaxy/authsession/internal/service/getsession"
|
||||
"galaxy/authsession/internal/service/listusersessions"
|
||||
"galaxy/authsession/internal/service/revokeallusersessions"
|
||||
"galaxy/authsession/internal/service/revokedevicesession"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func TestGetSessionHandlerSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
|
||||
GetSession: getSessionFunc(func(_ context.Context, input getsession.Input) (getsession.Result, error) {
|
||||
assert.Equal(t, getsession.Input{DeviceSessionID: "device-session-123"}, input)
|
||||
return getsession.Result{
|
||||
Session: validSessionDTO(),
|
||||
}, nil
|
||||
}),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/sessions/device-session-123", nil)
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
||||
assert.JSONEq(t, `{"session":{"device_session_id":"device-session-123","user_id":"user-123","client_public_key":"public-key-material","status":"active","created_at":"2026-04-05T12:00:00Z"}}`, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestListUserSessionsHandlerSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
|
||||
GetSession: getSessionFunc(unexpectedGetSession),
|
||||
ListUserSessions: listUserSessionsFunc(func(_ context.Context, input listusersessions.Input) (listusersessions.Result, error) {
|
||||
assert.Equal(t, listusersessions.Input{UserID: "user-123"}, input)
|
||||
first := validSessionDTO()
|
||||
second := validRevokedSessionDTO()
|
||||
second.DeviceSessionID = "device-session-122"
|
||||
return listusersessions.Result{Sessions: []shared.Session{first, second}}, nil
|
||||
}),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/users/user-123/sessions", nil)
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
||||
assert.Contains(t, recorder.Body.String(), `"sessions":[`)
|
||||
assert.Contains(t, recorder.Body.String(), `"device_session_id":"device-session-123"`)
|
||||
assert.Contains(t, recorder.Body.String(), `"device_session_id":"device-session-122"`)
|
||||
}
|
||||
|
||||
func TestListUserSessionsHandlerUnknownUserReturnsEmptyArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
|
||||
GetSession: getSessionFunc(unexpectedGetSession),
|
||||
ListUserSessions: listUserSessionsFunc(func(_ context.Context, input listusersessions.Input) (listusersessions.Result, error) {
|
||||
assert.Equal(t, listusersessions.Input{UserID: "unknown-user"}, input)
|
||||
return listusersessions.Result{Sessions: []shared.Session{}}, nil
|
||||
}),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/users/unknown-user/sessions", nil)
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
||||
assert.JSONEq(t, `{"sessions":[]}`, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestRevokeDeviceSessionHandlerAlreadyRevoked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
|
||||
GetSession: getSessionFunc(unexpectedGetSession),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(func(_ context.Context, input revokedevicesession.Input) (revokedevicesession.Result, error) {
|
||||
assert.Equal(t, revokedevicesession.Input{
|
||||
DeviceSessionID: "device-session-123",
|
||||
ReasonCode: "admin_revoke",
|
||||
ActorType: "system",
|
||||
}, input)
|
||||
return revokedevicesession.Result{
|
||||
Outcome: "already_revoked",
|
||||
DeviceSessionID: "device-session-123",
|
||||
AffectedSessionCount: 0,
|
||||
}, nil
|
||||
}),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/sessions/device-session-123/revoke",
|
||||
bytes.NewBufferString(`{"reason_code":"admin_revoke","actor":{"type":"system"}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
||||
assert.JSONEq(t, `{"outcome":"already_revoked","device_session_id":"device-session-123","affected_session_count":0}`, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestRevokeAllUserSessionsHandlerNoActiveSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
|
||||
GetSession: getSessionFunc(unexpectedGetSession),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(func(_ context.Context, input revokeallusersessions.Input) (revokeallusersessions.Result, error) {
|
||||
assert.Equal(t, revokeallusersessions.Input{
|
||||
UserID: "user-123",
|
||||
ReasonCode: "logout_all",
|
||||
ActorType: "system",
|
||||
}, input)
|
||||
return revokeallusersessions.Result{
|
||||
Outcome: "no_active_sessions",
|
||||
UserID: "user-123",
|
||||
AffectedSessionCount: 0,
|
||||
AffectedDeviceSessionIDs: []string{},
|
||||
}, nil
|
||||
}),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/users/user-123/sessions/revoke-all",
|
||||
bytes.NewBufferString(`{"reason_code":"logout_all","actor":{"type":"system"}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
||||
assert.JSONEq(t, `{"outcome":"no_active_sessions","user_id":"user-123","affected_session_count":0,"affected_device_session_ids":[]}`, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestBlockUserHandlerSuccessByEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
|
||||
GetSession: getSessionFunc(unexpectedGetSession),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(func(_ context.Context, input blockuser.Input) (blockuser.Result, error) {
|
||||
assert.Equal(t, blockuser.Input{
|
||||
Email: "pilot@example.com",
|
||||
ReasonCode: "policy_blocked",
|
||||
ActorType: "admin",
|
||||
}, input)
|
||||
return blockuser.Result{
|
||||
Outcome: "blocked",
|
||||
SubjectKind: blockuser.SubjectKindEmail,
|
||||
SubjectValue: "pilot@example.com",
|
||||
AffectedSessionCount: 0,
|
||||
AffectedDeviceSessionIDs: []string{},
|
||||
}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/user-blocks",
|
||||
bytes.NewBufferString(`{"email":"pilot@example.com","reason_code":"policy_blocked","actor":{"type":"admin"}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
||||
assert.JSONEq(t, `{"outcome":"blocked","subject_kind":"email","subject_value":"pilot@example.com","affected_session_count":0,"affected_device_session_ids":[]}`, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestBlockUserHandlerSuccessByUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
|
||||
GetSession: getSessionFunc(unexpectedGetSession),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(func(_ context.Context, input blockuser.Input) (blockuser.Result, error) {
|
||||
assert.Equal(t, blockuser.Input{
|
||||
UserID: "user-123",
|
||||
ReasonCode: "policy_blocked",
|
||||
ActorType: "admin",
|
||||
}, input)
|
||||
return blockuser.Result{
|
||||
Outcome: "already_blocked",
|
||||
SubjectKind: blockuser.SubjectKindUserID,
|
||||
SubjectValue: "user-123",
|
||||
AffectedSessionCount: 0,
|
||||
AffectedDeviceSessionIDs: []string{},
|
||||
}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/user-blocks",
|
||||
bytes.NewBufferString(`{"user_id":"user-123","reason_code":"policy_blocked","actor":{"type":"admin"}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
||||
assert.JSONEq(t, `{"outcome":"already_blocked","subject_kind":"user_id","subject_value":"user-123","affected_session_count":0,"affected_device_session_ids":[]}`, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestInternalHandlersRejectInvalidPathParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
target string
|
||||
body string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "get session empty device session id",
|
||||
method: http.MethodGet,
|
||||
target: "/api/v1/internal/sessions/%20",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"device session id must not be empty"}}`,
|
||||
},
|
||||
{
|
||||
name: "list sessions empty user id",
|
||||
method: http.MethodGet,
|
||||
target: "/api/v1/internal/users/%20/sessions",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"user id must not be empty"}}`,
|
||||
},
|
||||
{
|
||||
name: "revoke all empty user id",
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/internal/users/%20/sessions/revoke-all",
|
||||
body: `{"reason_code":"logout_all","actor":{"type":"system"}}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"user id must not be empty"}}`,
|
||||
},
|
||||
}
|
||||
|
||||
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
|
||||
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
|
||||
return getsession.Result{}, shared.InvalidRequest("device session id must not be empty")
|
||||
}),
|
||||
ListUserSessions: listUserSessionsFunc(func(context.Context, listusersessions.Input) (listusersessions.Result, error) {
|
||||
return listusersessions.Result{}, shared.InvalidRequest("user id must not be empty")
|
||||
}),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(func(context.Context, revokeallusersessions.Input) (revokeallusersessions.Result, error) {
|
||||
return revokeallusersessions.Result{}, shared.InvalidRequest("user id must not be empty")
|
||||
}),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
})
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(tt.method, tt.target, bytes.NewBufferString(tt.body))
|
||||
if tt.body != "" {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, tt.wantStatus, recorder.Code)
|
||||
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
||||
assert.JSONEq(t, tt.wantBody, recorder.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalMutationHandlersRejectInvalidRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
target string
|
||||
body string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "revoke device session empty body",
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/internal/sessions/device-session-123/revoke",
|
||||
body: ``,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"request body must not be empty"}}`,
|
||||
},
|
||||
{
|
||||
name: "revoke device session malformed json",
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/internal/sessions/device-session-123/revoke",
|
||||
body: `{"reason_code":`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"request body contains malformed JSON"}}`,
|
||||
},
|
||||
{
|
||||
name: "revoke device session multiple objects",
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/internal/sessions/device-session-123/revoke",
|
||||
body: `{"reason_code":"admin_revoke","actor":{"type":"system"}}{"reason_code":"admin_revoke","actor":{"type":"system"}}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"request body must contain a single JSON object"}}`,
|
||||
},
|
||||
{
|
||||
name: "revoke device session unknown field",
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/internal/sessions/device-session-123/revoke",
|
||||
body: `{"reason_code":"admin_revoke","actor":{"type":"system"},"extra":true}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
|
||||
},
|
||||
{
|
||||
name: "revoke device session invalid json type",
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/internal/sessions/device-session-123/revoke",
|
||||
body: `{"reason_code":123,"actor":{"type":"system"}}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"request body contains an invalid value for \"reason_code\""}}`,
|
||||
},
|
||||
{
|
||||
name: "revoke all missing reason code",
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/internal/users/user-123/sessions/revoke-all",
|
||||
body: `{"reason_code":" ","actor":{"type":"system"}}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"reason_code must not be empty"}}`,
|
||||
},
|
||||
{
|
||||
name: "block user missing actor type",
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/internal/user-blocks",
|
||||
body: `{"email":"pilot@example.com","reason_code":"policy_blocked","actor":{"type":" "}}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"actor.type must not be empty"}}`,
|
||||
},
|
||||
{
|
||||
name: "block user missing subject",
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/internal/user-blocks",
|
||||
body: `{"reason_code":"policy_blocked","actor":{"type":"system"}}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"exactly one of user_id or email must be provided"}}`,
|
||||
},
|
||||
{
|
||||
name: "block user conflicting subjects",
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/internal/user-blocks",
|
||||
body: `{"user_id":"user-123","email":"pilot@example.com","reason_code":"policy_blocked","actor":{"type":"system"}}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"exactly one of user_id or email must be provided"}}`,
|
||||
},
|
||||
}
|
||||
|
||||
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
|
||||
GetSession: getSessionFunc(unexpectedGetSession),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
})
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(tt.method, tt.target, bytes.NewBufferString(tt.body))
|
||||
if tt.body != "" {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, tt.wantStatus, recorder.Code)
|
||||
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
||||
assert.JSONEq(t, tt.wantBody, recorder.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalHandlersMapServiceErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
target string
|
||||
body string
|
||||
deps Dependencies
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "get session not found",
|
||||
method: http.MethodGet,
|
||||
target: "/api/v1/internal/sessions/missing",
|
||||
deps: Dependencies{
|
||||
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
|
||||
return getsession.Result{}, shared.SessionNotFound()
|
||||
}),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
},
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: `{"error":{"code":"session_not_found","message":"session not found"}}`,
|
||||
},
|
||||
{
|
||||
name: "revoke all subject not found",
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/internal/users/missing/sessions/revoke-all",
|
||||
body: `{"reason_code":"logout_all","actor":{"type":"system"}}`,
|
||||
deps: Dependencies{
|
||||
GetSession: getSessionFunc(unexpectedGetSession),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(func(context.Context, revokeallusersessions.Input) (revokeallusersessions.Result, error) {
|
||||
return revokeallusersessions.Result{}, shared.SubjectNotFound()
|
||||
}),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
},
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
|
||||
},
|
||||
{
|
||||
name: "service unavailable",
|
||||
method: http.MethodGet,
|
||||
target: "/api/v1/internal/sessions/device-session-123",
|
||||
deps: Dependencies{
|
||||
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
|
||||
return getsession.Result{}, shared.ServiceUnavailable(errors.New("redis timeout"))
|
||||
}),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
},
|
||||
wantStatus: http.StatusServiceUnavailable,
|
||||
wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`,
|
||||
},
|
||||
{
|
||||
name: "internal error",
|
||||
method: http.MethodGet,
|
||||
target: "/api/v1/internal/sessions/device-session-123",
|
||||
deps: Dependencies{
|
||||
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
|
||||
return getsession.Result{}, shared.InternalError(errors.New("broken invariant"))
|
||||
}),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: `{"error":{"code":"internal_error","message":"internal server error"}}`,
|
||||
},
|
||||
{
|
||||
name: "unexpected error hidden",
|
||||
method: http.MethodGet,
|
||||
target: "/api/v1/internal/sessions/device-session-123",
|
||||
deps: Dependencies{
|
||||
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
|
||||
return getsession.Result{}, errors.New("boom")
|
||||
}),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: `{"error":{"code":"internal_error","message":"internal server error"}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, DefaultConfig(), tt.deps)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(tt.method, tt.target, bytes.NewBufferString(tt.body))
|
||||
if tt.body != "" {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, tt.wantStatus, recorder.Code)
|
||||
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
||||
assert.JSONEq(t, tt.wantBody, recorder.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalHandlerTimeoutMapsToServiceUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.RequestTimeout = 5 * time.Millisecond
|
||||
|
||||
handler := mustNewHandler(t, cfg, Dependencies{
|
||||
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
|
||||
return getsession.Result{}, context.DeadlineExceeded
|
||||
}),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/sessions/device-session-123", nil)
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
assert.JSONEq(t, `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestInternalHandlersRejectInvalidSuccessPayloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
target string
|
||||
body string
|
||||
deps Dependencies
|
||||
}{
|
||||
{
|
||||
name: "get session malformed response",
|
||||
method: http.MethodGet,
|
||||
target: "/api/v1/internal/sessions/device-session-123",
|
||||
deps: Dependencies{
|
||||
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
|
||||
dto := validSessionDTO()
|
||||
dto.DeviceSessionID = ""
|
||||
return getsession.Result{Session: dto}, nil
|
||||
}),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "revoke all malformed response",
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/internal/users/user-123/sessions/revoke-all",
|
||||
body: `{"reason_code":"logout_all","actor":{"type":"system"}}`,
|
||||
deps: Dependencies{
|
||||
GetSession: getSessionFunc(unexpectedGetSession),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(func(context.Context, revokeallusersessions.Input) (revokeallusersessions.Result, error) {
|
||||
return revokeallusersessions.Result{
|
||||
Outcome: "revoked",
|
||||
UserID: "user-123",
|
||||
AffectedSessionCount: 2,
|
||||
AffectedDeviceSessionIDs: []string{"device-session-1"},
|
||||
}, nil
|
||||
}),
|
||||
BlockUser: blockUserFunc(unexpectedBlockUser),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, DefaultConfig(), tt.deps)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(tt.method, tt.target, bytes.NewBufferString(tt.body))
|
||||
if tt.body != "" {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, recorder.Code)
|
||||
assert.JSONEq(t, `{"error":{"code":"internal_error","message":"internal server error"}}`, recorder.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalHandlerLogsDoNotContainSensitiveFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger, buffer := newObservedLogger()
|
||||
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
|
||||
Logger: logger,
|
||||
GetSession: getSessionFunc(unexpectedGetSession),
|
||||
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
|
||||
BlockUser: blockUserFunc(func(context.Context, blockuser.Input) (blockuser.Result, error) {
|
||||
return blockuser.Result{
|
||||
Outcome: "blocked",
|
||||
SubjectKind: blockuser.SubjectKindEmail,
|
||||
SubjectValue: "pilot@example.com",
|
||||
AffectedSessionCount: 0,
|
||||
AffectedDeviceSessionIDs: []string{},
|
||||
}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/user-blocks",
|
||||
bytes.NewBufferString(`{"email":"pilot@example.com","reason_code":"policy_blocked","actor":{"type":"admin","id":"admin-1"}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
logOutput := buffer.String()
|
||||
assert.NotContains(t, logOutput, "pilot@example.com")
|
||||
assert.NotContains(t, logOutput, "admin-1")
|
||||
assert.NotContains(t, logOutput, "reason_code")
|
||||
}
|
||||
|
||||
func mustNewHandler(t *testing.T, cfg Config, deps Dependencies) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
handler, err := newHandlerWithConfig(cfg, deps)
|
||||
require.NoError(t, err)
|
||||
return handler
|
||||
}
|
||||
|
||||
type getSessionFunc func(ctx context.Context, input getsession.Input) (getsession.Result, error)
|
||||
|
||||
func (f getSessionFunc) Execute(ctx context.Context, input getsession.Input) (getsession.Result, error) {
|
||||
return f(ctx, input)
|
||||
}
|
||||
|
||||
type listUserSessionsFunc func(ctx context.Context, input listusersessions.Input) (listusersessions.Result, error)
|
||||
|
||||
func (f listUserSessionsFunc) Execute(ctx context.Context, input listusersessions.Input) (listusersessions.Result, error) {
|
||||
return f(ctx, input)
|
||||
}
|
||||
|
||||
type revokeDeviceSessionFunc func(ctx context.Context, input revokedevicesession.Input) (revokedevicesession.Result, error)
|
||||
|
||||
func (f revokeDeviceSessionFunc) Execute(ctx context.Context, input revokedevicesession.Input) (revokedevicesession.Result, error) {
|
||||
return f(ctx, input)
|
||||
}
|
||||
|
||||
type revokeAllUserSessionsFunc func(ctx context.Context, input revokeallusersessions.Input) (revokeallusersessions.Result, error)
|
||||
|
||||
func (f revokeAllUserSessionsFunc) Execute(ctx context.Context, input revokeallusersessions.Input) (revokeallusersessions.Result, error) {
|
||||
return f(ctx, input)
|
||||
}
|
||||
|
||||
type blockUserFunc func(ctx context.Context, input blockuser.Input) (blockuser.Result, error)
|
||||
|
||||
func (f blockUserFunc) Execute(ctx context.Context, input blockuser.Input) (blockuser.Result, error) {
|
||||
return f(ctx, input)
|
||||
}
|
||||
|
||||
func validSessionDTO() shared.Session {
|
||||
return shared.Session{
|
||||
DeviceSessionID: "device-session-123",
|
||||
UserID: "user-123",
|
||||
ClientPublicKey: "public-key-material",
|
||||
Status: "active",
|
||||
CreatedAt: "2026-04-05T12:00:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
func validRevokedSessionDTO() shared.Session {
|
||||
dto := validSessionDTO()
|
||||
dto.Status = "revoked"
|
||||
revokedAt := "2026-04-05T12:01:00Z"
|
||||
reasonCode := "admin_revoke"
|
||||
actorType := "admin"
|
||||
actorID := "admin-1"
|
||||
dto.RevokedAt = &revokedAt
|
||||
dto.RevokeReasonCode = &reasonCode
|
||||
dto.RevokeActorType = &actorType
|
||||
dto.RevokeActorID = &actorID
|
||||
return dto
|
||||
}
|
||||
|
||||
func newObservedLogger() (*zap.Logger, *bytes.Buffer) {
|
||||
buffer := &bytes.Buffer{}
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.TimeKey = ""
|
||||
|
||||
core := zapcore.NewCore(
|
||||
zapcore.NewJSONEncoder(encoderConfig),
|
||||
zapcore.AddSync(buffer),
|
||||
zap.DebugLevel,
|
||||
)
|
||||
|
||||
return zap.New(core), buffer
|
||||
}
|
||||
|
||||
func unexpectedGetSession(context.Context, getsession.Input) (getsession.Result, error) {
|
||||
return getsession.Result{}, errors.New("unexpected call")
|
||||
}
|
||||
|
||||
func unexpectedListUserSessions(context.Context, listusersessions.Input) (listusersessions.Result, error) {
|
||||
return listusersessions.Result{}, errors.New("unexpected call")
|
||||
}
|
||||
|
||||
func unexpectedRevokeDeviceSession(context.Context, revokedevicesession.Input) (revokedevicesession.Result, error) {
|
||||
return revokedevicesession.Result{}, errors.New("unexpected call")
|
||||
}
|
||||
|
||||
func unexpectedRevokeAllUserSessions(context.Context, revokeallusersessions.Input) (revokeallusersessions.Result, error) {
|
||||
return revokeallusersessions.Result{}, errors.New("unexpected call")
|
||||
}
|
||||
|
||||
func unexpectedBlockUser(context.Context, blockuser.Input) (blockuser.Result, error) {
|
||||
return blockuser.Result{}, errors.New("unexpected call")
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const internalErrorCodeContextKey = "internal_error_code"
|
||||
|
||||
type malformedJSONRequestError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *malformedJSONRequestError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return e.message
|
||||
}
|
||||
|
||||
func decodeJSONRequest(request *http.Request, target any) error {
|
||||
if request == nil || request.Body == nil {
|
||||
return &malformedJSONRequestError{message: "request body must not be empty"}
|
||||
}
|
||||
|
||||
return decodeJSONReader(request.Body, target)
|
||||
}
|
||||
|
||||
func decodeJSONReader(reader io.Reader, target any) error {
|
||||
decoder := json.NewDecoder(reader)
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return describeJSONDecodeError(err)
|
||||
}
|
||||
|
||||
if err := decoder.Decode(&struct{}{}); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
|
||||
}
|
||||
|
||||
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
|
||||
}
|
||||
|
||||
func describeJSONDecodeError(err error) error {
|
||||
var syntaxErr *json.SyntaxError
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
|
||||
switch {
|
||||
case errors.Is(err, io.EOF):
|
||||
return &malformedJSONRequestError{message: "request body must not be empty"}
|
||||
case errors.As(err, &syntaxErr):
|
||||
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
|
||||
case errors.As(err, &typeErr):
|
||||
if strings.TrimSpace(typeErr.Field) != "" {
|
||||
return &malformedJSONRequestError{
|
||||
message: fmt.Sprintf("request body contains an invalid value for %q", typeErr.Field),
|
||||
}
|
||||
}
|
||||
|
||||
return &malformedJSONRequestError{message: "request body contains an invalid JSON value"}
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
return &malformedJSONRequestError{
|
||||
message: fmt.Sprintf("request body contains unknown field %s", strings.TrimPrefix(err.Error(), "json: unknown field ")),
|
||||
}
|
||||
default:
|
||||
return &malformedJSONRequestError{message: "request body contains invalid JSON"}
|
||||
}
|
||||
}
|
||||
|
||||
func abortWithProjection(c *gin.Context, projection shared.InternalErrorProjection) {
|
||||
c.Set(internalErrorCodeContextKey, projection.Code)
|
||||
c.AbortWithStatusJSON(projection.StatusCode, errorResponse{
|
||||
Error: errorBody{
|
||||
Code: projection.Code,
|
||||
Message: projection.Message,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
authlogging "galaxy/authsession/internal/logging"
|
||||
"galaxy/authsession/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type edgeOutcome string
|
||||
|
||||
const (
|
||||
edgeOutcomeSuccess edgeOutcome = "success"
|
||||
edgeOutcomeRejected edgeOutcome = "rejected"
|
||||
edgeOutcomeFailed edgeOutcome = "failed"
|
||||
)
|
||||
|
||||
func withInternalObservability(logger *zap.Logger, metrics *telemetry.Runtime) gin.HandlerFunc {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
statusCode := c.Writer.Status()
|
||||
route := c.FullPath()
|
||||
if route == "" {
|
||||
route = "unmatched"
|
||||
}
|
||||
|
||||
errorCode, _ := c.Get(internalErrorCodeContextKey)
|
||||
errorCodeValue, _ := errorCode.(string)
|
||||
outcome := outcomeFromStatusCode(statusCode)
|
||||
duration := time.Since(start)
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("component", "internal_http"),
|
||||
zap.String("transport", "http"),
|
||||
zap.String("route", route),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.Int("status_code", statusCode),
|
||||
zap.Float64("duration_ms", float64(duration.Microseconds())/1000),
|
||||
zap.String("edge_outcome", string(outcome)),
|
||||
}
|
||||
if errorCodeValue != "" {
|
||||
fields = append(fields, zap.String("error_code", errorCodeValue))
|
||||
}
|
||||
fields = append(fields, authlogging.TraceFieldsFromContext(c.Request.Context())...)
|
||||
|
||||
metricAttrs := []attribute.KeyValue{
|
||||
attribute.String("route", route),
|
||||
attribute.String("method", c.Request.Method),
|
||||
attribute.String("edge_outcome", string(outcome)),
|
||||
}
|
||||
if errorCodeValue != "" {
|
||||
metricAttrs = append(metricAttrs, attribute.String("error_code", errorCodeValue))
|
||||
}
|
||||
metrics.RecordInternalHTTPRequest(c.Request.Context(), metricAttrs, duration)
|
||||
|
||||
switch outcome {
|
||||
case edgeOutcomeSuccess:
|
||||
logger.Info("internal request completed", fields...)
|
||||
case edgeOutcomeFailed:
|
||||
logger.Error("internal request failed", fields...)
|
||||
default:
|
||||
logger.Warn("internal request rejected", fields...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func outcomeFromStatusCode(statusCode int) edgeOutcome {
|
||||
switch {
|
||||
case statusCode >= 500:
|
||||
return edgeOutcomeFailed
|
||||
case statusCode >= 400:
|
||||
return edgeOutcomeRejected
|
||||
default:
|
||||
return edgeOutcomeSuccess
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"galaxy/authsession/internal/service/blockuser"
|
||||
"galaxy/authsession/internal/service/getsession"
|
||||
"galaxy/authsession/internal/service/listusersessions"
|
||||
"galaxy/authsession/internal/service/revokeallusersessions"
|
||||
"galaxy/authsession/internal/service/revokedevicesession"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
authtelemetry "galaxy/authsession/internal/telemetry"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func TestInternalHandlerEmitsTraceFieldsAndMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger, buffer := newObservedLogger()
|
||||
telemetryRuntime, reader, recorder := newObservedInternalTelemetryRuntime(t)
|
||||
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
|
||||
Logger: logger,
|
||||
Telemetry: telemetryRuntime,
|
||||
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
|
||||
return getsession.Result{Session: validSessionDTO()}, nil
|
||||
}),
|
||||
ListUserSessions: listUserSessionsFunc(func(context.Context, listusersessions.Input) (listusersessions.Result, error) {
|
||||
return listusersessions.Result{Sessions: []shared.Session{}}, nil
|
||||
}),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(func(context.Context, revokedevicesession.Input) (revokedevicesession.Result, error) {
|
||||
return revokedevicesession.Result{}, nil
|
||||
}),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(func(context.Context, revokeallusersessions.Input) (revokeallusersessions.Result, error) {
|
||||
return revokeallusersessions.Result{}, nil
|
||||
}),
|
||||
BlockUser: blockUserFunc(func(context.Context, blockuser.Input) (blockuser.Result, error) {
|
||||
return blockuser.Result{}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
recorderHTTP := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/sessions/device-session-123", nil)
|
||||
|
||||
handler.ServeHTTP(recorderHTTP, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorderHTTP.Code)
|
||||
require.NotEmpty(t, recorder.Ended())
|
||||
assert.Contains(t, buffer.String(), "otel_trace_id")
|
||||
assert.Contains(t, buffer.String(), "otel_span_id")
|
||||
|
||||
assertMetricCount(t, reader, "authsession.internal_http.requests", map[string]string{
|
||||
"route": "/api/v1/internal/sessions/:device_session_id",
|
||||
"method": http.MethodGet,
|
||||
"edge_outcome": "success",
|
||||
}, 1)
|
||||
}
|
||||
|
||||
func newObservedInternalTelemetryRuntime(t *testing.T) (*authtelemetry.Runtime, *sdkmetric.ManualReader, *tracetest.SpanRecorder) {
|
||||
t.Helper()
|
||||
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
|
||||
runtime, err := authtelemetry.NewWithProviders(meterProvider, tracerProvider)
|
||||
require.NoError(t, err)
|
||||
|
||||
return runtime, reader, recorder
|
||||
}
|
||||
|
||||
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
sum, ok := metric.Data.(metricdata.Sum[int64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range sum.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Equal(t, wantValue, point.Value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if want[string(value.Key)] != value.Value.AsString() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/service/blockuser"
|
||||
"galaxy/authsession/internal/service/getsession"
|
||||
"galaxy/authsession/internal/service/listusersessions"
|
||||
"galaxy/authsession/internal/service/revokeallusersessions"
|
||||
"galaxy/authsession/internal/service/revokedevicesession"
|
||||
"galaxy/authsession/internal/telemetry"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAddr = ":8081"
|
||||
defaultReadHeaderTimeout = 2 * time.Second
|
||||
defaultReadTimeout = 10 * time.Second
|
||||
defaultIdleTimeout = time.Minute
|
||||
defaultRequestTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// GetSessionUseCase describes the trusted internal get-session service
|
||||
// consumed by the HTTP transport layer.
|
||||
type GetSessionUseCase interface {
|
||||
// Execute loads one device session for trusted internal callers.
|
||||
Execute(ctx context.Context, input getsession.Input) (getsession.Result, error)
|
||||
}
|
||||
|
||||
// ListUserSessionsUseCase describes the trusted internal list-user-sessions
|
||||
// service consumed by the HTTP transport layer.
|
||||
type ListUserSessionsUseCase interface {
|
||||
// Execute lists all sessions of one user for trusted internal callers.
|
||||
Execute(ctx context.Context, input listusersessions.Input) (listusersessions.Result, error)
|
||||
}
|
||||
|
||||
// RevokeDeviceSessionUseCase describes the trusted internal single-session
|
||||
// revoke service consumed by the HTTP transport layer.
|
||||
type RevokeDeviceSessionUseCase interface {
|
||||
// Execute revokes one device session and returns the frozen
|
||||
// acknowledgement.
|
||||
Execute(ctx context.Context, input revokedevicesession.Input) (revokedevicesession.Result, error)
|
||||
}
|
||||
|
||||
// RevokeAllUserSessionsUseCase describes the trusted internal bulk-revoke
|
||||
// service consumed by the HTTP transport layer.
|
||||
type RevokeAllUserSessionsUseCase interface {
|
||||
// Execute revokes all active sessions of one user and returns the frozen
|
||||
// acknowledgement.
|
||||
Execute(ctx context.Context, input revokeallusersessions.Input) (revokeallusersessions.Result, error)
|
||||
}
|
||||
|
||||
// BlockUserUseCase describes the trusted internal block-user service consumed
|
||||
// by the HTTP transport layer.
|
||||
type BlockUserUseCase interface {
|
||||
// Execute applies a block state to one subject and returns the frozen
|
||||
// acknowledgement.
|
||||
Execute(ctx context.Context, input blockuser.Input) (blockuser.Result, error)
|
||||
}
|
||||
|
||||
// Config describes the trusted internal HTTP listener owned by authsession.
|
||||
type Config struct {
|
||||
// Addr is the TCP listen address used by the trusted internal HTTP server.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds how long the listener may spend reading request
|
||||
// headers before the server rejects the connection.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds how long the listener may spend reading one trusted
|
||||
// internal request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long the listener keeps an idle keep-alive
|
||||
// connection open.
|
||||
IdleTimeout time.Duration
|
||||
|
||||
// RequestTimeout bounds one application-layer internal use-case call.
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a usable internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.Addr == "":
|
||||
return errors.New("internal HTTP addr must not be empty")
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return errors.New("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return errors.New("internal HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return errors.New("internal HTTP idle timeout must be positive")
|
||||
case cfg.RequestTimeout <= 0:
|
||||
return errors.New("internal HTTP request timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default trusted internal HTTP listener settings.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Addr: defaultAddr,
|
||||
ReadHeaderTimeout: defaultReadHeaderTimeout,
|
||||
ReadTimeout: defaultReadTimeout,
|
||||
IdleTimeout: defaultIdleTimeout,
|
||||
RequestTimeout: defaultRequestTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// Dependencies describes the collaborators used by the trusted internal HTTP
|
||||
// transport layer.
|
||||
type Dependencies struct {
|
||||
// GetSession executes the trusted internal get-session use case.
|
||||
GetSession GetSessionUseCase
|
||||
|
||||
// ListUserSessions executes the trusted internal list-user-sessions use
|
||||
// case.
|
||||
ListUserSessions ListUserSessionsUseCase
|
||||
|
||||
// RevokeDeviceSession executes the trusted internal single-session revoke
|
||||
// use case.
|
||||
RevokeDeviceSession RevokeDeviceSessionUseCase
|
||||
|
||||
// RevokeAllUserSessions executes the trusted internal bulk-revoke use case.
|
||||
RevokeAllUserSessions RevokeAllUserSessionsUseCase
|
||||
|
||||
// BlockUser executes the trusted internal block-user use case.
|
||||
BlockUser BlockUserUseCase
|
||||
|
||||
// Logger writes structured transport logs. When nil, a no-op logger is
|
||||
// used.
|
||||
Logger *zap.Logger
|
||||
|
||||
// Telemetry records OpenTelemetry spans and low-cardinality HTTP metrics.
|
||||
// When nil, the transport still serves requests with no-op providers.
|
||||
Telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// Server owns the trusted internal HTTP listener exposed by authsession.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
|
||||
handler http.Handler
|
||||
logger *zap.Logger
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs one trusted internal HTTP server for cfg and deps.
|
||||
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
handler, err := newHandlerWithConfig(cfg, deps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
logger = logger.Named("internal_http")
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: handler,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the trusted internal HTTP
|
||||
// surface until Shutdown closes the server.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run internal HTTP server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", s.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run internal HTTP server: listen on %q: %w", s.cfg.Addr, err)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: s.handler,
|
||||
ReadHeaderTimeout: s.cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: s.cfg.ReadTimeout,
|
||||
IdleTimeout: s.cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
s.stateMu.Lock()
|
||||
s.server = server
|
||||
s.listener = listener
|
||||
s.stateMu.Unlock()
|
||||
|
||||
s.logger.Info("internal HTTP server started", zap.String("addr", listener.Addr().String()))
|
||||
|
||||
defer func() {
|
||||
s.stateMu.Lock()
|
||||
s.server = nil
|
||||
s.listener = nil
|
||||
s.stateMu.Unlock()
|
||||
}()
|
||||
|
||||
err = server.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, http.ErrServerClosed):
|
||||
s.logger.Info("internal HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run internal HTTP server: serve on %q: %w", s.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the trusted internal HTTP server within ctx.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown internal HTTP server: nil context")
|
||||
}
|
||||
|
||||
s.stateMu.RLock()
|
||||
server := s.server
|
||||
s.stateMu.RUnlock()
|
||||
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDependencies(deps Dependencies) (Dependencies, error) {
|
||||
switch {
|
||||
case deps.GetSession == nil:
|
||||
return Dependencies{}, errors.New("get session use case must not be nil")
|
||||
case deps.ListUserSessions == nil:
|
||||
return Dependencies{}, errors.New("list user sessions use case must not be nil")
|
||||
case deps.RevokeDeviceSession == nil:
|
||||
return Dependencies{}, errors.New("revoke device session use case must not be nil")
|
||||
case deps.RevokeAllUserSessions == nil:
|
||||
return Dependencies{}, errors.New("revoke all user sessions use case must not be nil")
|
||||
case deps.BlockUser == nil:
|
||||
return Dependencies{}, errors.New("block user use case must not be nil")
|
||||
case deps.Logger == nil:
|
||||
deps.Logger = zap.NewNop()
|
||||
}
|
||||
|
||||
deps.Logger = deps.Logger.Named("internal_http")
|
||||
return deps, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/service/blockuser"
|
||||
"galaxy/authsession/internal/service/getsession"
|
||||
"galaxy/authsession/internal/service/listusersessions"
|
||||
"galaxy/authsession/internal/service/revokeallusersessions"
|
||||
"galaxy/authsession/internal/service/revokedevicesession"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewServerRejectsInvalidConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Addr = ""
|
||||
|
||||
_, err := NewServer(cfg, validDependencies())
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "addr")
|
||||
}
|
||||
|
||||
func TestServerRunAndShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Addr = "127.0.0.1:0"
|
||||
|
||||
server, err := NewServer(cfg, validDependencies())
|
||||
require.NoError(t, err)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
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)
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
require.NoError(t, <-runErr)
|
||||
}
|
||||
|
||||
func validDependencies() Dependencies {
|
||||
return Dependencies{
|
||||
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
|
||||
return getsession.Result{Session: validSessionDTO()}, nil
|
||||
}),
|
||||
ListUserSessions: listUserSessionsFunc(func(context.Context, listusersessions.Input) (listusersessions.Result, error) {
|
||||
return listusersessions.Result{Sessions: []shared.Session{validSessionDTO()}}, nil
|
||||
}),
|
||||
RevokeDeviceSession: revokeDeviceSessionFunc(func(context.Context, revokedevicesession.Input) (revokedevicesession.Result, error) {
|
||||
return revokedevicesession.Result{
|
||||
Outcome: "revoked",
|
||||
DeviceSessionID: "device-session-123",
|
||||
AffectedSessionCount: 1,
|
||||
}, nil
|
||||
}),
|
||||
RevokeAllUserSessions: revokeAllUserSessionsFunc(func(context.Context, revokeallusersessions.Input) (revokeallusersessions.Result, error) {
|
||||
return revokeallusersessions.Result{
|
||||
Outcome: "revoked",
|
||||
UserID: "user-123",
|
||||
AffectedSessionCount: 1,
|
||||
AffectedDeviceSessionIDs: []string{"device-session-123"},
|
||||
}, nil
|
||||
}),
|
||||
BlockUser: blockUserFunc(func(context.Context, blockuser.Input) (blockuser.Result, error) {
|
||||
return blockuser.Result{
|
||||
Outcome: "blocked",
|
||||
SubjectKind: blockuser.SubjectKindEmail,
|
||||
SubjectValue: "pilot@example.com",
|
||||
AffectedSessionCount: 0,
|
||||
AffectedDeviceSessionIDs: []string{},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user