mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-20 20:06:33 +00:00
218 lines
8.2 KiB
Go
218 lines
8.2 KiB
Go
package ldapsync
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/italypaleale/francis/actor"
|
|
"github.com/italypaleale/francis/host/local"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
|
)
|
|
|
|
// fakeAppConfigResolver returns a fixed application configuration
|
|
type fakeAppConfigResolver struct {
|
|
config *appconfig.AppConfigModel
|
|
err error
|
|
}
|
|
|
|
func (f fakeAppConfigResolver) GetConfig(_ context.Context) (*appconfig.AppConfigModel, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
|
|
return f.config, nil
|
|
}
|
|
|
|
func TestSyncActorBootstrapArmsRecurringAlarm(t *testing.T) {
|
|
host, act := newSyncActorForTest(t, nil, fakeAppConfigResolver{config: defaultTestLDAPAppConfig()}, false)
|
|
|
|
require.NoError(t, act.Bootstrap(t.Context(), nil))
|
|
|
|
props, err := host.GetAlarm(t.Context(), SyncActorType, actor.SingletonActorID, alarmSync)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, syncInterval, props.Interval)
|
|
// The first occurrence is due after the initial delay, so a restart re-syncs the directory shortly after startup
|
|
// The tolerance is tight enough to catch the delay being dropped, which would put the due time at "now"
|
|
assert.WithinDuration(t, time.Now().Add(initialSyncDelay), props.DueTime, time.Second)
|
|
}
|
|
|
|
func TestSyncActorBootstrapIsIdempotent(t *testing.T) {
|
|
host, act := newSyncActorForTest(t, nil, fakeAppConfigResolver{config: defaultTestLDAPAppConfig()}, false)
|
|
|
|
// Every host bootstraps the singleton, so repeating it must leave a single alarm behind rather than failing
|
|
require.NoError(t, act.Bootstrap(t.Context(), nil))
|
|
require.NoError(t, act.Bootstrap(t.Context(), nil))
|
|
|
|
props, err := host.GetAlarm(t.Context(), SyncActorType, actor.SingletonActorID, alarmSync)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, syncInterval, props.Interval)
|
|
}
|
|
|
|
func TestSyncActorBootstrapRemovesAlarmWhenScheduleDisabled(t *testing.T) {
|
|
host, act := newSyncActorForTest(t, nil, fakeAppConfigResolver{config: defaultTestLDAPAppConfig()}, true)
|
|
|
|
// Simulate an alarm left behind by a run where the schedule was still enabled
|
|
require.NoError(t, host.SetAlarm(t.Context(), SyncActorType, actor.SingletonActorID, alarmSync, actor.AlarmProperties{
|
|
DueTime: time.Now(),
|
|
Interval: syncInterval,
|
|
}))
|
|
|
|
require.NoError(t, act.Bootstrap(t.Context(), nil))
|
|
|
|
_, err := host.GetAlarm(t.Context(), SyncActorType, actor.SingletonActorID, alarmSync)
|
|
require.ErrorIs(t, err, actor.ErrAlarmNotFound)
|
|
}
|
|
|
|
func TestSyncActorBootstrapWithScheduleDisabledAndNoAlarm(t *testing.T) {
|
|
host, act := newSyncActorForTest(t, nil, fakeAppConfigResolver{config: defaultTestLDAPAppConfig()}, true)
|
|
|
|
// There's nothing to remove, which must not be reported as a failure
|
|
require.NoError(t, act.Bootstrap(t.Context(), nil))
|
|
|
|
_, err := host.GetAlarm(t.Context(), SyncActorType, actor.SingletonActorID, alarmSync)
|
|
require.ErrorIs(t, err, actor.ErrAlarmNotFound)
|
|
}
|
|
|
|
func TestSyncActorAlarmRunsSync(t *testing.T) {
|
|
appCfg := defaultTestLDAPAppConfig()
|
|
service, db := newTestLdapService(t, newFakeLDAPClient(
|
|
ldapSearchResult(
|
|
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
|
"entryUUID": {"u-alice"},
|
|
"uid": {"alice"},
|
|
"mail": {"alice@example.com"},
|
|
"givenName": {"Alice"},
|
|
"sn": {"Jones"},
|
|
"displayName": {""},
|
|
}),
|
|
),
|
|
ldapSearchResult(),
|
|
))
|
|
|
|
_, act := newSyncActorForTest(t, service, fakeAppConfigResolver{config: appCfg}, false)
|
|
|
|
require.NoError(t, act.Alarm(t.Context(), alarmSync, nil))
|
|
|
|
var alice model.User
|
|
require.NoError(t, db.First(&alice, "ldap_id = ?", "u-alice").Error)
|
|
assert.Equal(t, "alice", alice.Username)
|
|
}
|
|
|
|
func TestSyncActorAlarmSkipsSyncWhenLdapIsDisabled(t *testing.T) {
|
|
service, db := newTestLdapService(t, newFakeLDAPClient(
|
|
ldapSearchResult(
|
|
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
|
"entryUUID": {"u-alice"},
|
|
"uid": {"alice"},
|
|
"mail": {"alice@example.com"},
|
|
}),
|
|
),
|
|
ldapSearchResult(),
|
|
))
|
|
|
|
disabledCfg := defaultTestLDAPAppConfig()
|
|
disabledCfg.LdapEnabled = "false"
|
|
_, act := newSyncActorForTest(t, service, fakeAppConfigResolver{config: disabledCfg}, false)
|
|
|
|
require.NoError(t, act.Alarm(t.Context(), alarmSync, nil))
|
|
|
|
var count int64
|
|
require.NoError(t, db.Model(&model.User{}).Count(&count).Error)
|
|
assert.Zero(t, count)
|
|
}
|
|
|
|
func TestSyncActorAlarmSwallowsSyncFailures(t *testing.T) {
|
|
// A failed sync must not surface as an error, or the framework would eventually delete the alarm and stop synchronizing altogether
|
|
service, _ := newTestLdapService(t, newFakeLDAPClient(ldapSearchResult(), ldapSearchResult()))
|
|
service.clientFactory = func(_ *appconfig.AppConfigModel) (ldapClient, error) {
|
|
return nil, errors.New("connection refused")
|
|
}
|
|
|
|
_, act := newSyncActorForTest(t, service, fakeAppConfigResolver{config: defaultTestLDAPAppConfig()}, false)
|
|
|
|
require.NoError(t, act.Alarm(t.Context(), alarmSync, nil))
|
|
}
|
|
|
|
func TestSyncActorAlarmSwallowsAppConfigFailures(t *testing.T) {
|
|
service, _ := newTestLdapService(t, newFakeLDAPClient(ldapSearchResult(), ldapSearchResult()))
|
|
|
|
_, act := newSyncActorForTest(t, service, fakeAppConfigResolver{err: errors.New("config unavailable")}, false)
|
|
|
|
require.NoError(t, act.Alarm(t.Context(), alarmSync, nil))
|
|
}
|
|
|
|
func TestSyncActorAlarmRejectsUnknownAlarm(t *testing.T) {
|
|
_, act := newSyncActorForTest(t, nil, fakeAppConfigResolver{config: defaultTestLDAPAppConfig()}, false)
|
|
|
|
err := act.Alarm(t.Context(), "unknown", nil)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "unsupported alarm")
|
|
}
|
|
|
|
func TestSyncActorRegisteredSingletonBootstrapsAndFires(t *testing.T) {
|
|
// This exercises the wiring the unit tests above bypass: the host bootstraps the singleton on its own, and the alarm it arms is delivered back to the actor
|
|
appCfg := defaultTestLDAPAppConfig()
|
|
service, db := newTestLdapService(t, newFakeLDAPClient(
|
|
ldapSearchResult(
|
|
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
|
"entryUUID": {"u-alice"},
|
|
"uid": {"alice"},
|
|
"mail": {"alice@example.com"},
|
|
"givenName": {"Alice"},
|
|
"sn": {"Jones"},
|
|
"displayName": {""},
|
|
}),
|
|
),
|
|
ldapSearchResult(),
|
|
))
|
|
|
|
// The host uses the same relaxed alarm intervals the application configures when HA is disabled, since those are what decide how soon the first occurrence is picked up
|
|
// Francis only performs an early first fetch when the poll interval is long, so with the default (short) test interval this test would pass even if that behavior regressed
|
|
host := testutils.NewActorHostForTest(t,
|
|
func(t *testing.T, h *local.Host) {
|
|
err := h.RegisterSingletonActor(SyncActorType, NewSyncActor(service, fakeAppConfigResolver{config: appCfg}, false))
|
|
require.NoError(t, err)
|
|
},
|
|
local.WithAlarmsPollInterval(5*time.Minute),
|
|
local.WithAlarmsFetchAheadInterval(5*time.Minute),
|
|
)
|
|
|
|
// The host bootstraps singletons in the background once it's ready, so wait for the alarm to show up
|
|
require.Eventually(t, func() bool {
|
|
_, err := host.GetAlarm(t.Context(), SyncActorType, actor.SingletonActorID, alarmSync)
|
|
return err == nil
|
|
}, 10*time.Second, 20*time.Millisecond, "the sync alarm was never armed")
|
|
|
|
// The first occurrence runs shortly after startup rather than waiting out the poll interval, so the deadline here is far below it
|
|
require.Eventually(t,
|
|
func() bool {
|
|
var count int64
|
|
require.NoError(t, db.Model(&model.User{}).Where("ldap_id = ?", "u-alice").Count(&count).Error)
|
|
return count == 1
|
|
},
|
|
initialSyncDelay+30*time.Second,
|
|
50*time.Millisecond,
|
|
"the sync alarm never ran",
|
|
)
|
|
}
|
|
|
|
// newSyncActorForTest starts a test actor host and allocates the sync actor against it
|
|
// The actor is not registered on the host, so the host never bootstraps or fires it on its own and the test drives it explicitly
|
|
func newSyncActorForTest(t *testing.T, service *Service, appConfig appconfig.AppConfigResolver, scheduleDisabled bool) (*local.Host, *syncActor) {
|
|
t.Helper()
|
|
|
|
host := testutils.NewActorHostForTest(t, nil)
|
|
|
|
act, ok := NewSyncActor(service, appConfig, scheduleDisabled)(actor.SingletonActorID, host.Service()).(*syncActor)
|
|
require.True(t, ok)
|
|
|
|
return host, act
|
|
}
|