Files

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
}