Files

173 lines
5.4 KiB
Go

package onetimeaccess
import (
"context"
"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/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
type fakeSigner struct{}
func (fakeSigner) GenerateAccessToken(_ model.User, _ string, _ time.Duration) (string, error) {
return "access-token", nil
}
type fakeAuditLogger struct {
events []model.AuditLogEvent
}
func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, _, _, _ string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
f.events = append(f.events, event)
return model.AuditLog{}, true
}
type fakeUserProvider struct {
db *gorm.DB
}
func (f fakeUserProvider) GetUser(ctx context.Context, userID string) (model.User, error) {
var user model.User
err := f.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error
return user, err
}
type fakeEmailSender struct{}
func (fakeEmailSender) SendOneTimeAccessEmail(_ context.Context, _ *appconfig.AppConfigModel, _ email.Address, _ EmailData) error {
return nil
}
// newServiceForTest sets up a Service backed by an in-memory test actor host, and returns it together with the host and the audit logger it records into
func newServiceForTest(t *testing.T, db *gorm.DB) (*Service, *local.Host, *fakeAuditLogger) {
t.Helper()
auditLog := &fakeAuditLogger{}
var svc *Service
host := testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) {
err := h.RegisterActor(TokenActorType, NewTokenActor)
require.NoError(t, err)
svc = newService(Dependencies{
DB: db,
Signer: fakeSigner{},
AuditLog: auditLog,
UserProvider: fakeUserProvider{db: db},
EmailSender: fakeEmailSender{},
}, h.Service())
})
require.NotNil(t, svc)
return svc, host, auditLog
}
func TestExchangeTokenSuccess(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc, host, auditLog := newServiceForTest(t, db)
user := model.User{
Base: model.Base{ID: "enabled-user"},
Username: "enabled-user",
}
require.NoError(t, db.Create(&user).Error)
token, _, err := StoreToken(t.Context(), svc.actorService, user.ID, time.Minute, false)
require.NoError(t, err)
dbConfig := appconfig.NewTestConfig(nil)
exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "1.2.3.4", "test-agent")
require.NoError(t, err)
require.Equal(t, user.ID, exchangedUser.ID)
require.NotEmpty(t, accessToken)
// The token must have been consumed
var state TokenState
err = host.GetState(t.Context(), TokenActorType, token, &state)
require.ErrorIs(t, err, actor.ErrStateNotFound)
// A sign-in audit log must have been created
require.Equal(t, []model.AuditLogEvent{model.AuditLogEventOneTimeAccessTokenSignIn}, auditLog.events)
}
func TestExchangeTokenInvalidToken(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc, _, _ := newServiceForTest(t, db)
dbConfig := appconfig.NewTestConfig(nil)
_, _, err := svc.ExchangeToken(t.Context(), dbConfig, "does-not-exist", "", "", "")
var invalidErr *common.TokenInvalidOrExpiredError
require.ErrorAs(t, err, &invalidErr)
}
func TestExchangeTokenDeviceMismatch(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc, host, _ := newServiceForTest(t, db)
user := model.User{
Base: model.Base{ID: "device-user"},
Username: "device-user",
}
require.NoError(t, db.Create(&user).Error)
// Store a token that requires a device token
token, deviceToken, err := StoreToken(t.Context(), svc.actorService, user.ID, time.Minute, true)
require.NoError(t, err)
require.NotNil(t, deviceToken)
dbConfig := appconfig.NewTestConfig(nil)
_, _, err = svc.ExchangeToken(t.Context(), dbConfig, token, "wrong-device-token", "", "")
var deviceErr *common.DeviceCodeInvalid
require.ErrorAs(t, err, &deviceErr)
// The token must not have been consumed on a device-token mismatch
var state TokenState
err = host.GetState(t.Context(), TokenActorType, token, &state)
require.NoError(t, err)
require.Equal(t, user.ID, state.UserID)
}
func TestExchangeTokenRejectsDisabledUser(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc, host, auditLog := newServiceForTest(t, db)
user := model.User{
Base: model.Base{ID: "disabled-user"},
Username: "disabled-user",
Disabled: true,
}
require.NoError(t, db.Create(&user).Error)
// Store a one-time access token for the disabled user in the actor state store
token, _, err := StoreToken(t.Context(), svc.actorService, user.ID, time.Minute, false)
require.NoError(t, err)
dbConfig := appconfig.NewTestConfig(nil)
exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "", "")
var userDisabledErr *common.UserDisabledError
require.ErrorAs(t, err, &userDisabledErr)
require.Empty(t, exchangedUser.ID)
require.Empty(t, accessToken)
// The token must have been restored (not consumed), since the exchange failed because the user is disabled
var state TokenState
err = host.GetState(t.Context(), TokenActorType, token, &state)
require.NoError(t, err)
require.Equal(t, user.ID, state.UserID)
require.Empty(t, auditLog.events)
}