785 lines
30 KiB
Go
785 lines
30 KiB
Go
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")
|
|
}
|