mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-08 10:49:06 +02:00
611 lines
22 KiB
Go
611 lines
22 KiB
Go
//go:build exclude_frontend && unit
|
|
|
|
package devicelogin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/italypaleale/francis/actor"
|
|
"github.com/italypaleale/francis/host/local"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
|
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
|
"github.com/pocket-id/pocket-id/backend/internal/auditlogs"
|
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
|
)
|
|
|
|
const (
|
|
testSessionDuration = time.Hour
|
|
)
|
|
|
|
type fakeReauthenticationTokenConsumer struct {
|
|
expectedValue string
|
|
createdAt time.Time
|
|
}
|
|
|
|
func (f *fakeReauthenticationTokenConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, _ string) (time.Time, error) {
|
|
if token != f.expectedValue {
|
|
return time.Time{}, apperror.ReauthenticationRequired()
|
|
}
|
|
if !f.createdAt.IsZero() {
|
|
return f.createdAt, nil
|
|
}
|
|
return time.Now(), nil
|
|
}
|
|
|
|
type fakeTokenService struct {
|
|
mu sync.Mutex
|
|
userID string
|
|
authenticationMethod string
|
|
sessionDuration time.Duration
|
|
generated int
|
|
err error
|
|
}
|
|
|
|
func (f *fakeTokenService) GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.userID = user.ID
|
|
f.authenticationMethod = authenticationMethod
|
|
f.sessionDuration = sessionDuration
|
|
f.generated++
|
|
if f.err != nil {
|
|
return "", f.err
|
|
}
|
|
return "device-login-access-token", nil
|
|
}
|
|
|
|
func (f *fakeTokenService) generatedToken() (string, string, time.Duration, int) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.userID, f.authenticationMethod, f.sessionDuration, f.generated
|
|
}
|
|
|
|
type auditEntry struct {
|
|
event auditlogs.Event
|
|
ipAddress string
|
|
userAgent string
|
|
userID string
|
|
}
|
|
|
|
type fakeAuditLogger struct {
|
|
mu sync.Mutex
|
|
entries []auditEntry
|
|
notifications []auditlogs.SignInResult
|
|
}
|
|
|
|
type fakeIPLocationResolver struct {
|
|
country string
|
|
city string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeIPLocationResolver) GetLocationByIP(context.Context, string) (string, string, error) {
|
|
return f.country, f.city, f.err
|
|
}
|
|
|
|
func (f *fakeAuditLogger) Create(_ context.Context, event auditlogs.Event, ipAddress, userAgent, userID string, _ auditlogs.Data, _ *gorm.DB) (auditlogs.AuditLog, bool) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.entries = append(f.entries, auditEntry{event: event, ipAddress: ipAddress, userAgent: userAgent, userID: userID})
|
|
return auditlogs.AuditLog{}, true
|
|
}
|
|
|
|
func (f *fakeAuditLogger) DeviceStringFromUserAgent(userAgent string) string {
|
|
return "Parsed " + userAgent
|
|
}
|
|
|
|
func (f *fakeAuditLogger) lastEntry() auditEntry {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.entries[len(f.entries)-1]
|
|
}
|
|
|
|
func (f *fakeAuditLogger) entryCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.entries)
|
|
}
|
|
|
|
type serviceFixture struct {
|
|
service *Service
|
|
actors *actor.Service
|
|
signer *fakeTokenService
|
|
auditLog *fakeAuditLogger
|
|
reauth *fakeReauthenticationTokenConsumer
|
|
}
|
|
|
|
func TestRequestLifecycle(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
fixture := newServiceFixture(t, db)
|
|
|
|
user := model.User{
|
|
Base: model.Base{ID: "device-login-user"},
|
|
Username: "device-login-user",
|
|
}
|
|
require.NoError(t, db.Create(&user).Error)
|
|
|
|
request, deviceToken, err := fixture.service.Create(t.Context(), "192.0.2.10", "Mozilla/5.0 Chrome/125.0.0.0")
|
|
require.NoError(t, err)
|
|
require.Regexp(t, `^P[ABCDEFGHJKMNPQRSTUVWXYZ0123456789]{7}$`, request.Code)
|
|
require.Equal(t, request.Code, request.ID)
|
|
require.Equal(t, RequestStatusPending, request.Status)
|
|
|
|
state := getRequestActorState(t, fixture.actors, request.ID)
|
|
require.Equal(t, utils.CreateSha256Hash(deviceToken), state.DeviceTokenHash)
|
|
require.NotEqual(t, deviceToken, state.DeviceTokenHash)
|
|
require.Equal(t, RequestStatusPending, state.Status)
|
|
|
|
info, err := fixture.service.Inspect(t.Context(), strings.ToLower(request.Code))
|
|
require.NoError(t, err)
|
|
require.Equal(t, request.Code, info.UserCode)
|
|
require.Equal(t, "192.0.2.10", info.IPAddress)
|
|
require.Equal(t, "Parsed Mozilla/5.0 Chrome/125.0.0.0", info.Device)
|
|
require.Equal(t, "Switzerland", info.Country)
|
|
require.Equal(t, "Zurich", info.City)
|
|
|
|
err = fixture.service.Decide(t.Context(), strings.ToLower(request.Code), "approve", user.ID, "fresh-proof")
|
|
require.NoError(t, err)
|
|
|
|
exchangedUser, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "198.51.100.20", "target-agent", "", testSessionDuration, appconfig.LoginNotificationBrowserRecognition)
|
|
require.NoError(t, err)
|
|
require.Equal(t, RequestStatusApproved, status)
|
|
require.Equal(t, user.ID, exchangedUser.ID)
|
|
require.Equal(t, "device-login-access-token", accessToken.AccessToken)
|
|
require.Len(t, fixture.auditLog.notifications, 1)
|
|
require.True(t, fixture.auditLog.notifications[0].Notify)
|
|
require.Equal(t, auditlogs.EventRemoteSignIn, fixture.auditLog.notifications[0].AuditLog.Event)
|
|
|
|
signedUserID, authenticationMethod, sessionDuration, generated := fixture.signer.generatedToken()
|
|
require.Equal(t, user.ID, signedUserID)
|
|
require.Equal(t, authenticationMethodOneTimePassword, authenticationMethod)
|
|
require.Equal(t, testSessionDuration, sessionDuration)
|
|
require.Equal(t, 1, generated)
|
|
requireRequestActorStateDeleted(t, fixture.actors, request.ID)
|
|
|
|
entry := fixture.auditLog.lastEntry()
|
|
require.Equal(t, auditlogs.EventRemoteSignIn, entry.event)
|
|
require.Equal(t, "198.51.100.20", entry.ipAddress)
|
|
require.Equal(t, "target-agent", entry.userAgent)
|
|
require.Equal(t, user.ID, entry.userID)
|
|
|
|
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
assertInvalidRequestError(t, err)
|
|
|
|
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, "wrong-token", "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
assertInvalidRequestError(t, err)
|
|
_, _, _, generated = fixture.signer.generatedToken()
|
|
require.Equal(t, 1, generated)
|
|
require.Equal(t, 1, fixture.auditLog.entryCount())
|
|
}
|
|
|
|
func TestPendingAndDeniedRequests(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
fixture := newServiceFixture(t, db)
|
|
|
|
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
|
require.NoError(t, err)
|
|
|
|
result, err := fixture.service.peek(t.Context(), request.ID, requestActorMethodPoll, requestActorPollInput{
|
|
DeviceTokenHash: utils.CreateSha256Hash(deviceToken),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, RequestStatusPending, result.Status)
|
|
|
|
err = fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "")
|
|
require.NoError(t, err)
|
|
|
|
user, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginDenied))
|
|
require.Equal(t, RequestStatusDenied, status)
|
|
require.Empty(t, user.ID)
|
|
require.Empty(t, accessToken)
|
|
}
|
|
|
|
func TestPendingExchangeObservesDecisionDuringLongPoll(t *testing.T) {
|
|
db := testutils.NewConcurrentDatabaseForTest(t)
|
|
fixture := newServiceFixture(t, db)
|
|
|
|
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
|
require.NoError(t, err)
|
|
|
|
type exchangeOutcome struct {
|
|
status RequestStatus
|
|
err error
|
|
}
|
|
result := make(chan exchangeOutcome, 1)
|
|
go func() {
|
|
_, _, status, exchangeErr := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
result <- exchangeOutcome{status: status, err: exchangeErr}
|
|
}()
|
|
|
|
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", ""))
|
|
|
|
select {
|
|
case outcome := <-result:
|
|
require.True(t, apperror.IsCode(outcome.err, apperror.CodeDeviceLoginDenied))
|
|
require.Equal(t, RequestStatusDenied, outcome.status)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("exchange did not observe the actor decision")
|
|
}
|
|
}
|
|
|
|
func TestRejectsInvalidAndExpiredRequestsWhileActorIsActive(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
fixture := newServiceFixture(t, db)
|
|
|
|
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
|
require.NoError(t, err)
|
|
|
|
unknownRequestID := strings.Repeat("a", 64)
|
|
_, _, _, err = fixture.service.Exchange(t.Context(), unknownRequestID, "device-token", "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
assertInvalidRequestError(t, err)
|
|
_, err = fixture.service.Inspect(t.Context(), unknownRequestID)
|
|
assertInvalidRequestError(t, err)
|
|
err = fixture.service.Decide(t.Context(), unknownRequestID, "deny", "device-login-user", "")
|
|
assertInvalidRequestError(t, err)
|
|
|
|
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, "wrong-token", "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
assertInvalidRequestError(t, err)
|
|
|
|
state := getRequestActorState(t, fixture.actors, request.ID)
|
|
state.ExpiresAt = time.Now().Add(-time.Second)
|
|
require.NoError(t, fixture.actors.Halt(requestActorType, request.ID))
|
|
require.NoError(t, fixture.actors.SetState(t.Context(), requestActorType, request.ID, state, nil))
|
|
_, err = fixture.service.Inspect(t.Context(), request.Code)
|
|
assertInvalidRequestError(t, err)
|
|
err = fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "")
|
|
assertInvalidRequestError(t, err)
|
|
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
assertInvalidRequestError(t, err)
|
|
}
|
|
|
|
func TestRejectsDisabledUserAtExchange(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
fixture := newServiceFixture(t, db)
|
|
|
|
user := model.User{
|
|
Base: model.Base{ID: "disabled-device-login-user"},
|
|
Username: "disabled-device-login-user",
|
|
Disabled: true,
|
|
}
|
|
require.NoError(t, db.Create(&user).Error)
|
|
|
|
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
|
require.NoError(t, err)
|
|
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
|
|
|
|
_, accessToken, _, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
require.True(t, apperror.IsCode(err, apperror.CodeUserDisabled))
|
|
require.Empty(t, accessToken)
|
|
require.Equal(t, RequestStatusApproved, getRequestActorState(t, fixture.actors, request.ID).Status)
|
|
}
|
|
|
|
func TestFailedTokenGenerationConsumesApprovedRequest(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
fixture := newServiceFixture(t, db)
|
|
fixture.signer.err = errors.New("token generation failed")
|
|
|
|
user := model.User{
|
|
Base: model.Base{ID: "token-failure-device-login-user"},
|
|
Username: "token-failure-device-login-user",
|
|
}
|
|
require.NoError(t, db.Create(&user).Error)
|
|
|
|
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
|
require.NoError(t, err)
|
|
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
|
|
|
|
_, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
require.EqualError(t, err, "token generation failed")
|
|
require.Empty(t, accessToken)
|
|
require.Equal(t, RequestStatusApproved, status)
|
|
requireRequestActorStateDeleted(t, fixture.actors, request.ID)
|
|
require.Equal(t, 0, fixture.auditLog.entryCount())
|
|
|
|
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
assertInvalidRequestError(t, err)
|
|
}
|
|
|
|
func TestApprovalRejectsMissingAndStaleReauthentication(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
fixture := newServiceFixture(t, db)
|
|
|
|
request, _, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
|
require.NoError(t, err)
|
|
|
|
err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "")
|
|
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
|
|
|
|
fixture.reauth.expectedValue = "stale-proof"
|
|
fixture.reauth.createdAt = time.Now().Add(-2 * time.Minute)
|
|
err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "stale-proof")
|
|
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
|
|
require.Equal(t, RequestStatusPending, getRequestActorState(t, fixture.actors, request.ID).Status)
|
|
}
|
|
|
|
func TestNormalizeUserCodeAliases(t *testing.T) {
|
|
require.Equal(t, "P100-110", normalizeUserCode(" piO0-i1o "))
|
|
}
|
|
|
|
func TestConcurrentExchangeAllowsOnlyOneSuccess(t *testing.T) {
|
|
db := testutils.NewConcurrentDatabaseForTest(t)
|
|
fixture := newServiceFixture(t, db)
|
|
|
|
user := model.User{
|
|
Base: model.Base{ID: "single-use-device-login-user"},
|
|
Username: "single-use-device-login-user",
|
|
}
|
|
require.NoError(t, db.Create(&user).Error)
|
|
|
|
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
|
require.NoError(t, err)
|
|
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
|
|
|
|
var waitGroup sync.WaitGroup
|
|
type exchangeResult struct {
|
|
token string
|
|
err error
|
|
}
|
|
results := make(chan exchangeResult, 2)
|
|
for range 2 {
|
|
waitGroup.Add(1)
|
|
go func() {
|
|
defer waitGroup.Done()
|
|
_, token, _, exchangeErr := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
results <- exchangeResult{token: token.AccessToken, err: exchangeErr}
|
|
}()
|
|
}
|
|
waitGroup.Wait()
|
|
close(results)
|
|
|
|
var successfulTokens []string
|
|
var invalidExchanges int
|
|
for result := range results {
|
|
if result.err == nil {
|
|
successfulTokens = append(successfulTokens, result.token)
|
|
continue
|
|
}
|
|
require.True(t, apperror.IsCode(result.err, apperror.CodeDeviceLoginExpired))
|
|
invalidExchanges++
|
|
}
|
|
require.Equal(t, []string{"device-login-access-token"}, successfulTokens)
|
|
require.Equal(t, 1, invalidExchanges)
|
|
require.Equal(t, 1, fixture.auditLog.entryCount())
|
|
_, _, _, generated := fixture.signer.generatedToken()
|
|
require.Equal(t, 1, generated)
|
|
requireRequestActorStateDeleted(t, fixture.actors, request.ID)
|
|
}
|
|
|
|
func TestCreateCollisionPreservesOriginalActorState(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
fixture := newServiceFixture(t, db)
|
|
|
|
request, deviceToken, err := fixture.service.Create(t.Context(), "192.0.2.1", "original-agent")
|
|
require.NoError(t, err)
|
|
original := getRequestActorState(t, fixture.actors, request.ID)
|
|
|
|
result, err := fixture.service.invoke(t.Context(), request.ID, requestActorMethodCreate, requestActorCreateInput{
|
|
Code: request.Code,
|
|
DeviceTokenHash: utils.CreateSha256Hash("different-token"),
|
|
IPAddress: "198.51.100.1",
|
|
UserAgent: "replacement-agent",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, requestActorResultCollision, result.Code)
|
|
require.Equal(t, original, getRequestActorState(t, fixture.actors, request.ID))
|
|
require.NotEqual(t, deviceToken, original.DeviceTokenHash)
|
|
}
|
|
|
|
func TestRequestStateSurvivesActorHostRestart(t *testing.T) {
|
|
db := testutils.NewConcurrentDatabaseForTest(t)
|
|
deps := persistentTestDependencies(db)
|
|
|
|
firstModule, stopFirst := startPersistentDeviceLoginHost(t, db, deps)
|
|
request, deviceToken, err := firstModule.service.Create(t.Context(), "192.0.2.1", "persistent-agent")
|
|
require.NoError(t, err)
|
|
stopFirst()
|
|
|
|
secondModule, stopSecond := startPersistentDeviceLoginHost(t, db, deps)
|
|
defer stopSecond()
|
|
info, err := secondModule.service.Inspect(t.Context(), request.Code)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "persistent-agent", strings.TrimPrefix(info.Device, "Parsed "))
|
|
require.NoError(t, secondModule.service.Decide(t.Context(), request.Code, "deny", "device-login-user", ""))
|
|
_, _, status, err := secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginDenied))
|
|
require.Equal(t, RequestStatusDenied, status)
|
|
}
|
|
|
|
func TestCompletedExchangeIsInvalidAfterActorHostRestart(t *testing.T) {
|
|
db := testutils.NewConcurrentDatabaseForTest(t)
|
|
deps := persistentTestDependencies(db)
|
|
|
|
user := model.User{
|
|
Base: model.Base{ID: "completed-exchange-user"},
|
|
Username: "completed-exchange-user",
|
|
}
|
|
require.NoError(t, db.Create(&user).Error)
|
|
|
|
firstModule, stopFirst := startPersistentDeviceLoginHost(t, db, deps)
|
|
request, deviceToken, err := firstModule.service.Create(t.Context(), "", "persistent-agent")
|
|
require.NoError(t, err)
|
|
require.NoError(t, firstModule.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
|
|
_, _, firstStatus, err := firstModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
require.NoError(t, err)
|
|
require.Equal(t, RequestStatusApproved, firstStatus)
|
|
stopFirst()
|
|
|
|
secondModule, stopSecond := startPersistentDeviceLoginHost(t, db, deps)
|
|
defer stopSecond()
|
|
_, _, _, err = secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
assertInvalidRequestError(t, err)
|
|
|
|
_, _, _, generated := deps.Signer.(*fakeTokenService).generatedToken()
|
|
require.Equal(t, 1, generated)
|
|
require.Equal(t, 1, deps.AuditLog.(*fakeAuditLogger).entryCount())
|
|
}
|
|
|
|
func TestPendingExchangeStopsWhenRequestIsCanceled(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
fixture := newServiceFixture(t, db)
|
|
|
|
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
|
require.NoError(t, err)
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
started := make(chan struct{})
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
close(started)
|
|
_, _, _, exchangeErr := fixture.service.Exchange(ctx, request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
|
|
result <- exchangeErr
|
|
}()
|
|
|
|
<-started
|
|
cancel()
|
|
|
|
select {
|
|
case exchangeErr := <-result:
|
|
require.ErrorIs(t, exchangeErr, context.Canceled)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("canceled exchange did not stop")
|
|
}
|
|
}
|
|
|
|
func newServiceFixture(t *testing.T, db *gorm.DB) serviceFixture {
|
|
t.Helper()
|
|
signer := &fakeTokenService{}
|
|
auditLog := &fakeAuditLogger{}
|
|
ipLocator := &fakeIPLocationResolver{country: "Switzerland", city: "Zurich"}
|
|
reauth := &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"}
|
|
var module *Module
|
|
host := testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) {
|
|
var err error
|
|
module, err = New(Dependencies{
|
|
DB: db,
|
|
Actors: host,
|
|
Signer: signer,
|
|
AuditLog: auditLog,
|
|
IPLocator: ipLocator,
|
|
Reauth: reauth,
|
|
})
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
return serviceFixture{
|
|
service: module.service,
|
|
actors: host.Service(),
|
|
signer: signer,
|
|
auditLog: auditLog,
|
|
reauth: reauth,
|
|
}
|
|
}
|
|
|
|
func getRequestActorState(t *testing.T, actors *actor.Service, actorID string) requestActorState {
|
|
t.Helper()
|
|
var state requestActorState
|
|
require.NoError(t, actors.GetState(t.Context(), requestActorType, actorID, &state))
|
|
return state
|
|
}
|
|
|
|
func requireRequestActorStateDeleted(t *testing.T, actors *actor.Service, actorID string) {
|
|
t.Helper()
|
|
var state requestActorState
|
|
require.ErrorIs(t, actors.GetState(t.Context(), requestActorType, actorID, &state), actor.ErrStateNotFound)
|
|
}
|
|
|
|
func assertInvalidRequestError(t *testing.T, err error) {
|
|
t.Helper()
|
|
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginExpired))
|
|
}
|
|
|
|
func persistentTestDependencies(db *gorm.DB) Dependencies {
|
|
return Dependencies{
|
|
DB: db,
|
|
Signer: &fakeTokenService{},
|
|
AuditLog: &fakeAuditLogger{},
|
|
IPLocator: &fakeIPLocationResolver{},
|
|
Reauth: &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"},
|
|
}
|
|
}
|
|
|
|
func startPersistentDeviceLoginHost(t *testing.T, db *gorm.DB, deps Dependencies) (*Module, func()) {
|
|
t.Helper()
|
|
sqlDB, err := db.DB()
|
|
require.NoError(t, err)
|
|
_, err = sqlDB.Exec("PRAGMA foreign_keys = ON")
|
|
require.NoError(t, err)
|
|
|
|
host, err := local.NewHost(
|
|
local.WithAddress(freeLoopbackAddress(t)),
|
|
local.WithRuntimePSKs([]byte("pocket-id-device-login-test-host-psk")),
|
|
local.WithSQLiteProvider(local.SQLiteProviderOptions{DB: sqlDB}),
|
|
local.WithShutdownGracePeriod(time.Second),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
deps.Actors = host
|
|
module, err := New(deps)
|
|
require.NoError(t, err)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- host.Run(ctx)
|
|
}()
|
|
|
|
select {
|
|
case <-host.Ready():
|
|
case runErr := <-errCh:
|
|
t.Fatalf("persistent actor host stopped before becoming ready: %v", runErr)
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("timed out waiting for persistent actor host")
|
|
}
|
|
|
|
var stopOnce sync.Once
|
|
stop := func() {
|
|
stopOnce.Do(func() {
|
|
cancel()
|
|
runErr := <-errCh
|
|
if runErr != nil && !errors.Is(runErr, context.Canceled) {
|
|
require.NoError(t, runErr)
|
|
}
|
|
})
|
|
}
|
|
t.Cleanup(stop)
|
|
return module, stop
|
|
}
|
|
|
|
func freeLoopbackAddress(t *testing.T) string {
|
|
t.Helper()
|
|
var listenConfig net.ListenConfig
|
|
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
address := listener.Addr().String()
|
|
require.NoError(t, listener.Close())
|
|
return address
|
|
}
|
|
|
|
func (f *fakeAuditLogger) CreateSignIn(ctx context.Context, event auditlogs.Event, ipAddress, userAgent, userID, browserToken string, tx *gorm.DB, mode appconfig.AppConfigValue) auditlogs.SignInResult {
|
|
entry, created := f.Create(ctx, event, ipAddress, userAgent, userID, auditlogs.Data{}, tx)
|
|
entry.Event = event
|
|
return auditlogs.SignInResult{AuditLog: entry, Created: created, Notify: mode != appconfig.LoginNotificationDisabled, KnownBrowserToken: "recognized-browser"}
|
|
}
|
|
|
|
func (f *fakeAuditLogger) SendSignInNotification(_ context.Context, result auditlogs.SignInResult) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.notifications = append(f.notifications, result)
|
|
}
|