feat: authsession service
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user