refactor: use actors for db configuration (#1604)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alessandro (Ale) Segala
2026-07-20 08:48:05 +02:00
committed by GitHub
co-authored by Claude
parent 472fff33ea
commit 2cfbcb4b67
53 changed files with 2061 additions and 1714 deletions
@@ -0,0 +1,200 @@
package appconfig
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/italypaleale/francis/actor"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
)
// The AppConfig singleton actor maintains the dynamic configuration for the Pocket ID cluster
// Instances of Pocket ID should bootstrap the AppConfig's actor upon startup to ensure the config is loaded (and migrated if needed)
// After startup, Peek can be used for read-only operations such as retrieving the config or listing it
// AppConfigActorType is the actor type for the AppConfig actor
const AppConfigActorType = "AppConfig"
// appConfigActor is a singleton actor that manages the dynamic app configuration
type appConfigActor struct {
log *slog.Logger
client actor.Client[*AppConfigModel]
}
// appConfigActorBootstrap is the type for the payload of the init method
type appConfigActorBootstrap struct {
LegacyConfig map[string]string
}
// NewAppConfigActor allocates a new AppConfig actor
// It satisfies actor.Factory
func NewAppConfigActor(actorID string, service *actor.Service) actor.Actor {
log := slog.
With(
slog.String("scope", "actor"),
slog.String("actorType", AppConfigActorType),
slog.String("actorID", actorID),
)
log.Info("AppConfig actor created")
return &appConfigActor{
log: log,
client: actor.NewActorClient[*AppConfigModel](AppConfigActorType, actorID, service),
}
}
// Bootstrap implements actor.ActorBootstrapper for the singleton actor
func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelope) error {
// Load the actor state
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return fmt.Errorf("error retrieving actor state: %w", err)
}
// If we already have a state, nothing else to do
if state != nil {
return nil
}
// Check if the request data contains legacy config to init from
if data != nil {
payload := appConfigActorBootstrap{}
err = data.Decode(&payload)
if err != nil {
return fmt.Errorf("request body is not valid for method 'init': %w", err)
}
if len(payload.LegacyConfig) > 0 {
state, err = fromLegacyConfig(payload.LegacyConfig)
if err != nil {
return fmt.Errorf("request body is not valid for method 'init': LegacyConfig property could not be parsed: %w", err)
}
}
}
// If we still have no state, generate a new default config
if state == nil {
state = getDefaultConfig()
}
// Save the updated state
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err = a.client.SetState(ctx, state, nil)
if err != nil {
return fmt.Errorf("error saving actor state: %w", err)
}
return nil
}
func (a *appConfigActor) Peek(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
// Only supported method is "get"
if method != "get" {
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
// Load the actor state
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return nil, fmt.Errorf("error retrieving actor state: %w", err)
}
// Return the state
return state, nil
}
func (a *appConfigActor) Invoke(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
// Check the method first
switch method {
case "get", "update", "replace":
// All good
// Note: we support "get" also via Invoke and not just Peek
default:
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
// Load the actor state
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return nil, fmt.Errorf("error retrieving actor state: %w", err)
}
switch method {
case "get":
// If the method is "get", just return the actor state, we're done
// This switch case is a no-op
case "replace":
// Replace the entire config
// The input data must be a dto.AppConfigUpdateDto
if data == nil {
return nil, errors.New("request body is empty for method 'replace'")
}
payload := dto.AppConfigUpdateDto{}
err = data.Decode(&payload)
if err != nil {
return nil, fmt.Errorf("request body is not valid for method 'replace': %w", err)
}
// Update the in-memory data
// Work on a clone to avoid touching the cached object in case of errors
newState := state.Clone()
newState.Replace(payload)
// Save the updated state, which also updates the cached object
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err = a.client.SetState(ctx, newState, nil)
if err != nil {
return nil, fmt.Errorf("error saving actor state: %w", err)
}
return newState, nil
case "update":
// Update the config
// The input data must be a map[string]string
if data == nil {
return nil, errors.New("request body is empty for method 'update'")
}
payload := map[string]string{}
err = data.Decode(&payload)
if err != nil {
return nil, fmt.Errorf("request body is not valid for method 'update': %w", err)
}
// Update the in-memory data
// Work on a clone to avoid touching the cached object in case of errors
newState := state.Clone()
err = newState.Update(payload)
if err != nil {
return nil, fmt.Errorf("request body is not valid for method 'update': %w", err)
}
// Save the updated state, which also updates the cached object
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err = a.client.SetState(ctx, newState, nil)
if err != nil {
return nil, fmt.Errorf("error saving actor state: %w", err)
}
return newState, nil
}
// Return the state
return state, nil
}
+97
View File
@@ -0,0 +1,97 @@
package appconfig
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"github.com/italypaleale/go-kit/utils"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
// This file holds the one-time migration of the legacy (pre-actor) app config
// The legacy config was stored in the "config_migrated" key of the kv table, and it's loaded here to bootstrap the AppConfig actor on first startup
// LoadLegacyConfig loads the legacy config from the database
// This was migrated to the "config_migrated" key in the kv table
func LoadLegacyConfig(ctx context.Context, db *gorm.DB) (map[string]string, error) {
// Retrieve the migrated config from the kv table
row := model.KV{
Key: "config_migrated",
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := db.WithContext(ctx).First(&row).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
// There's no migrated config in the database, nothing to do
return nil, nil
case err != nil:
return nil, fmt.Errorf("failed to load migrated config from the database: %w", err)
case row.Value == nil || len(*row.Value) == 0:
// Also no migrated config, nothing to do
return nil, nil
}
// The value is a JSON-encoded dictionary
res := map[string]string{}
err = json.Unmarshal([]byte(*row.Value), &res)
if err != nil {
return nil, fmt.Errorf("error parsing migrated config: %w", err)
}
if len(res) == 0 {
return nil, nil
}
return res, nil
}
// fromLegacyConfig builds an appConfigModel from a legacy config map
// The map's keys correspond to the "json" tags on appConfigModel, and all values are strings that are cast to each field's type
// Keys that are missing (or have an empty value) retain the default value
func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) {
// Start from the default configuration, then override with the values from the legacy config
dest := getDefaultConfig()
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
for i := range rt.NumField() {
field := rt.Field(i)
// Get the value of the json tag, taking only what's before the comma
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
// Look up the value in the legacy config
// If the key is missing or the value is empty, we keep the default value
value, ok := legacyCfg[key]
if !ok || value == "" {
continue
}
// Cast the string value to the field's type
fv := rv.Field(i)
switch fv.Kind() { //nolint:exhaustive
case reflect.String:
fv.SetString(value)
case reflect.Bool:
fv.SetBool(utils.IsTruthy(value))
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse integer value for key '%s': %w", key, err)
}
fv.SetInt(n)
default:
return nil, fmt.Errorf("unsupported field type '%s' for key '%s'", fv.Kind(), key)
}
}
return dest, nil
}
+272
View File
@@ -0,0 +1,272 @@
package appconfig
import (
"errors"
"reflect"
"strconv"
"strings"
"time"
"github.com/italypaleale/go-kit/utils"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
)
type AppConfigModel struct {
// General
AppName AppConfigValue `json:"appName" public:"true"`
SessionDuration AppConfigValue `json:"sessionDuration" type:"int"` // In minutes
HomePageURL AppConfigValue `json:"homePageUrl" public:"true"`
EmailsVerified AppConfigValue `json:"emailsVerified" type:"bool"`
AccentColor AppConfigValue `json:"accentColor" public:"true"`
DisableAnimations AppConfigValue `json:"disableAnimations" type:"bool" public:"true"`
AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" type:"bool" public:"true"`
AllowUserSignups AppConfigValue `json:"allowUserSignups" public:"true"`
SignupDefaultUserGroupIDs AppConfigValue `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings
SignupDefaultCustomClaims AppConfigValue `json:"signupDefaultCustomClaims"` // JSON-encoded array of {key:string,value:string}
// Email
RequireUserEmail AppConfigValue `json:"requireUserEmail" type:"bool" public:"true"`
SmtpHost AppConfigValue `json:"smtpHost"`
SmtpPort AppConfigValue `json:"smtpPort"`
SmtpFrom AppConfigValue `json:"smtpFrom"`
SmtpUser AppConfigValue `json:"smtpUser"`
SmtpPassword AppConfigValue `json:"smtpPassword" sensitive:"true"`
SmtpTls AppConfigValue `json:"smtpTls"`
SmtpSkipCertVerify AppConfigValue `json:"smtpSkipCertVerify" type:"bool"`
EmailLoginNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled" type:"bool"`
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" type:"bool" public:"true"`
EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" type:"bool" public:"true"`
EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" type:"bool"`
EmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled" type:"bool" public:"true"`
// LDAP
LdapEnabled AppConfigValue `json:"ldapEnabled" type:"bool" public:"true"`
LdapUrl AppConfigValue `json:"ldapUrl"`
LdapBindDn AppConfigValue `json:"ldapBindDn"`
LdapBindPassword AppConfigValue `json:"ldapBindPassword" sensitive:"true"`
LdapBase AppConfigValue `json:"ldapBase"`
LdapUserSearchFilter AppConfigValue `json:"ldapUserSearchFilter"`
LdapUserGroupSearchFilter AppConfigValue `json:"ldapUserGroupSearchFilter"`
LdapSkipCertVerify AppConfigValue `json:"ldapSkipCertVerify" type:"bool"`
LdapAttributeUserUniqueIdentifier AppConfigValue `json:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserUsername AppConfigValue `json:"ldapAttributeUserUsername"`
LdapAttributeUserEmail AppConfigValue `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName AppConfigValue `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName AppConfigValue `json:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName AppConfigValue `json:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture AppConfigValue `json:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember AppConfigValue `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier AppConfigValue `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName"`
LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName"`
LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" type:"bool"`
}
// Clone returns a deep copy of the AppConfigModel.
func (m *AppConfigModel) Clone() *AppConfigModel {
if m == nil {
return nil
}
// All fields are value types (AppConfigValue is a string), so copying the struct is sufficient for a deep copy.
clone := *m
return &clone
}
// AppConfigValue holds a value
type AppConfigValue string
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
func (a AppConfigValue) IsTrue() bool {
return utils.IsTruthy(string(a))
}
// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes.
func (a AppConfigValue) AsDurationMinutes() time.Duration {
val, err := strconv.Atoi(string(a))
if err != nil {
return 0
}
return time.Duration(val) * time.Minute
}
// String implements fmt.Stringer
func (a AppConfigValue) String() string {
return string(a)
}
func getDefaultConfig() *AppConfigModel {
// Values are the default ones
return &AppConfigModel{
// General
AppName: "Pocket ID",
SessionDuration: "60",
HomePageURL: "/settings/account",
EmailsVerified: "false",
DisableAnimations: "false",
AllowOwnAccountEdit: "true",
AllowUserSignups: "disabled",
SignupDefaultUserGroupIDs: "[]",
SignupDefaultCustomClaims: "[]",
AccentColor: "default",
// Email
RequireUserEmail: "true",
SmtpHost: "",
SmtpPort: "",
SmtpFrom: "",
SmtpUser: "",
SmtpPassword: "",
SmtpTls: "none",
SmtpSkipCertVerify: "false",
EmailLoginNotificationEnabled: "false",
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
EmailOneTimeAccessAsAdminEnabled: "false",
EmailApiKeyExpirationEnabled: "false",
EmailVerificationEnabled: "false",
// LDAP
LdapEnabled: "false",
LdapUrl: "",
LdapBindDn: "",
LdapBindPassword: "",
LdapBase: "",
LdapUserSearchFilter: "(objectClass=person)",
LdapUserGroupSearchFilter: "(objectClass=groupOfNames)",
LdapSkipCertVerify: "false",
LdapAttributeUserUniqueIdentifier: "",
LdapAttributeUserUsername: "",
LdapAttributeUserEmail: "",
LdapAttributeUserFirstName: "",
LdapAttributeUserLastName: "",
LdapAttributeUserDisplayName: "cn",
LdapAttributeUserProfilePicture: "",
LdapAttributeGroupMember: "member",
LdapAttributeGroupUniqueIdentifier: "",
LdapAttributeGroupName: "",
LdapAdminGroupName: "",
LdapSoftDeleteUsers: "true",
}
}
// Replace updates every configuration property with the values from the input DTO
// An empty string value resets the corresponding property to its default value
func (m *AppConfigModel) Replace(input dto.AppConfigUpdateDto) {
// Collect the values from the input DTO into a map, keyed by the "json" tag
inRv := reflect.ValueOf(input)
inRt := inRv.Type()
values := make(map[string]string, inRt.NumField())
for i := range inRt.NumField() {
// Get the value of the json tag, taking only what's before the comma
key, _, _ := strings.Cut(inRt.Field(i).Tag.Get("json"), ",")
values[key] = inRv.Field(i).String()
}
// Iterate through all the properties, setting each one from the input
// Properties that are missing from the input or have an empty value are reset to their default
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
rv := reflect.ValueOf(m).Elem()
rt := rv.Type()
for i := range rt.NumField() {
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
value, ok := values[key]
if !ok || value == "" {
value = defaults.Field(i).String()
}
rv.Field(i).SetString(value)
}
}
// Update sets configuration properties from the provided key-value pairs
// Keys correspond to the "json" tags on the model
// An empty string value resets the property to its default value
func (m *AppConfigModel) Update(values map[string]string) error {
rv := reflect.ValueOf(m).Elem()
rt := rv.Type()
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
// Iterate through the key-value pairs
for key, value := range values {
// Find the field in the struct whose "json" tag matches
fieldIdx := -1
for j := range rt.NumField() {
// Separate the key (before the comma) from any optional attributes after
tagValue, _, _ := strings.Cut(rt.Field(j).Tag.Get("json"), ",")
if tagValue == key {
fieldIdx = j
break
}
}
if fieldIdx < 0 {
return AppConfigKeyNotFoundError{field: key}
}
// An empty string means we use the default value for the property
if value == "" {
value = defaults.Field(fieldIdx).String()
}
rv.Field(fieldIdx).SetString(value)
}
return nil
}
// AppConfigVariable is a single application configuration property, as a key/value pair
type AppConfigVariable struct {
Key string
Value string
}
// ToAppConfigVariableSlice returns the configuration as a slice of key/value pairs
// If showAll is false, only properties marked as public are included
// If redactSensitiveValues is true, sensitive values are redacted when the UI config is disabled
func (m *AppConfigModel) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues bool) []AppConfigVariable {
// Iterate through all fields
cfgValue := reflect.ValueOf(m).Elem()
cfgType := cfgValue.Type()
res := make([]AppConfigVariable, 0, cfgType.NumField())
for i := range cfgType.NumField() {
field := cfgType.Field(i)
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
if key == "" {
continue
}
// If we're only showing public variables and this is not public, skip it
if !showAll && field.Tag.Get("public") != "true" {
continue
}
value := cfgValue.Field(i).String()
// Redact sensitive values if the value isn't empty, the UI config is disabled, and redactSensitiveValues is true
if value != "" && common.EnvConfig.UiConfigDisabled && redactSensitiveValues && field.Tag.Get("sensitive") == "true" {
value = "XXXXXXXXXX"
}
res = append(res, AppConfigVariable{
Key: key,
Value: value,
})
}
return res
}
type AppConfigKeyNotFoundError struct {
field string
}
func (e AppConfigKeyNotFoundError) Error() string {
return "cannot find config key '" + e.field + "'"
}
func (e AppConfigKeyNotFoundError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError
_, ok := errors.AsType[*AppConfigKeyNotFoundError](target)
return ok
}
+225
View File
@@ -0,0 +1,225 @@
package appconfig
import (
"errors"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/dto"
)
// dtoWithMarkerValues returns a DTO where every field is set to a unique, non-empty marker derived from its "json" key, so we can assert each value lands in the right place.
func dtoWithMarkerValues() dto.AppConfigUpdateDto {
var input dto.AppConfigUpdateDto
rv := reflect.ValueOf(&input).Elem()
rt := rv.Type()
for i := range rt.NumField() {
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
rv.Field(i).SetString("marker-" + key)
}
return input
}
func TestAppConfigModel_Replace(t *testing.T) {
t.Run("populates every property from the DTO", func(t *testing.T) {
input := dtoWithMarkerValues()
var m AppConfigModel
m.Replace(input)
// Each model property must hold the marker built from its own "json" key.
// This also asserts that the model and the DTO share the same set of keys.
rv := reflect.ValueOf(&m).Elem()
rt := rv.Type()
for i := range rt.NumField() {
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
assert.Equalf(t, "marker-"+key, rv.Field(i).String(), "property %s (key %q)", rt.Field(i).Name, key)
}
})
t.Run("empty values fall back to their default", func(t *testing.T) {
defaults := getDefaultConfig()
// Start from all-markers, then blank out a few properties whose default is non-empty
input := dtoWithMarkerValues()
input.AppName = ""
input.SessionDuration = ""
input.SmtpTls = ""
input.LdapUserSearchFilter = ""
var m AppConfigModel
m.Replace(input)
// Blanked properties are reset to their default
assert.Equal(t, defaults.AppName, m.AppName)
assert.Equal(t, defaults.SessionDuration, m.SessionDuration)
assert.Equal(t, defaults.SmtpTls, m.SmtpTls)
assert.Equal(t, defaults.LdapUserSearchFilter, m.LdapUserSearchFilter)
// A property that was provided keeps the provided value
assert.Equal(t, AppConfigValue("marker-homePageUrl"), m.HomePageURL)
})
t.Run("an empty DTO resets every property to its default", func(t *testing.T) {
// Pre-populate with junk to prove Replace overwrites existing state
m := AppConfigModel{
AppName: "Custom Name",
LdapEnabled: "true",
SmtpHost: "smtp.example.com",
}
m.Replace(dto.AppConfigUpdateDto{})
assert.Equal(t, *getDefaultConfig(), m)
})
t.Run("provided values overwrite existing non-default values", func(t *testing.T) {
m := getDefaultConfig()
m.AppName = "Old Name"
m.LdapEnabled = "true"
input := dto.AppConfigUpdateDto{}
input.AppName = "New Name"
m.Replace(input)
// Explicitly provided value wins
assert.Equal(t, AppConfigValue("New Name"), m.AppName)
// Everything else in the DTO was empty, so it is reset to the default
assert.Equal(t, getDefaultConfig().LdapEnabled, m.LdapEnabled)
})
t.Run("stores raw string values without type coercion", func(t *testing.T) {
input := dto.AppConfigUpdateDto{}
input.SessionDuration = "120" // int-tagged property
input.LdapEnabled = "true" // bool-tagged property
var m AppConfigModel
m.Replace(input)
assert.Equal(t, AppConfigValue("120"), m.SessionDuration)
assert.Equal(t, AppConfigValue("true"), m.LdapEnabled)
})
}
func TestAppConfigModel_Clone(t *testing.T) {
t.Run("clones every property", func(t *testing.T) {
// Populate every property with a unique marker so we can assert each one is copied
var original AppConfigModel
rv := reflect.ValueOf(&original).Elem()
rt := rv.Type()
for i := range rt.NumField() {
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
rv.Field(i).SetString("marker-" + key)
}
clone := original.Clone()
require.NotNil(t, clone)
// The clone must be a distinct object with equal contents
assert.NotSame(t, &original, clone)
assert.Equal(t, original, *clone)
})
t.Run("mutating the clone does not affect the original", func(t *testing.T) {
original := getDefaultConfig()
clone := original.Clone()
clone.AppName = "Changed"
clone.LdapEnabled = "true"
// The original keeps its values
assert.Equal(t, getDefaultConfig().AppName, original.AppName)
assert.Equal(t, getDefaultConfig().LdapEnabled, original.LdapEnabled)
// The clone holds the new values
assert.Equal(t, AppConfigValue("Changed"), clone.AppName)
assert.Equal(t, AppConfigValue("true"), clone.LdapEnabled)
})
t.Run("mutating the original does not affect the clone", func(t *testing.T) {
original := getDefaultConfig()
clone := original.Clone()
original.AppName = "Changed"
assert.Equal(t, getDefaultConfig().AppName, clone.AppName)
})
t.Run("cloning a nil receiver returns nil", func(t *testing.T) {
var m *AppConfigModel
assert.Nil(t, m.Clone())
})
}
func TestAppConfigModel_Update(t *testing.T) {
t.Run("updates a single property", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update(map[string]string{"appName": "My App"})
require.NoError(t, err)
assert.Equal(t, AppConfigValue("My App"), m.AppName)
})
t.Run("updates multiple properties and leaves others untouched", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update(map[string]string{"appName": "My App", "homePageUrl": "/home", "ldapEnabled": "true"})
require.NoError(t, err)
assert.Equal(t, AppConfigValue("My App"), m.AppName)
assert.Equal(t, AppConfigValue("/home"), m.HomePageURL)
assert.Equal(t, AppConfigValue("true"), m.LdapEnabled)
// A property that was not part of the update keeps its previous value
assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration)
})
t.Run("an empty value resets the property to its default", func(t *testing.T) {
m := getDefaultConfig()
m.SmtpTls = "tls" // default is "none"
m.SessionDuration = "120" // default is "60"
err := m.Update(map[string]string{"smtpTls": "", "sessionDuration": ""})
require.NoError(t, err)
assert.Equal(t, getDefaultConfig().SmtpTls, m.SmtpTls)
assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration)
})
t.Run("stores raw string values without type coercion", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update(map[string]string{"sessionDuration": "120", "disableAnimations": "true"})
require.NoError(t, err)
assert.Equal(t, AppConfigValue("120"), m.SessionDuration)
assert.Equal(t, AppConfigValue("true"), m.DisableAnimations)
})
t.Run("an empty map is a no-op", func(t *testing.T) {
m := getDefaultConfig()
before := *m
err := m.Update(nil)
require.NoError(t, err)
assert.Equal(t, before, *m)
})
t.Run("an unknown key returns AppConfigKeyNotFoundError", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update(map[string]string{"thisKeyDoesNotExist": "value"})
require.Error(t, err)
require.EqualError(t, err, "cannot find config key 'thisKeyDoesNotExist'")
notFound, ok := errors.AsType[AppConfigKeyNotFoundError](err)
require.True(t, ok)
assert.Equal(t, "thisKeyDoesNotExist", notFound.field)
})
}
+210
View File
@@ -0,0 +1,210 @@
package appconfig
import (
"context"
"errors"
"fmt"
"os"
"reflect"
"strings"
"time"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type AppConfigService struct {
actSvc *actor.Service
envConfig *AppConfigModel
}
func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *AppConfigService, err error) {
service = &AppConfigService{}
// If the UI config is disabled, we do not need to init the config actor
if common.EnvConfig.UiConfigDisabled {
service.envConfig, err = service.loadDbConfigFromEnv()
if err != nil {
return nil, fmt.Errorf("error loading app config from the env: %w", err)
}
return service, nil
}
// Note: we need to assign to the "err" variable in this method (for tracing), do not inline this into the "if"
ctx, span := tracing.Start(ctx, "pocketid.appconfig.init")
defer tracing.End(span, err)
// Load the legacy config if any, which we need to send to the actor as bootstrap data
legacyCfg, err := LoadLegacyConfig(ctx, db)
if err != nil {
return nil, fmt.Errorf("error loading legacy config: %w", err)
}
// Register the AppConfig actor
// This is a singleton actor and it's bootstrapped with the legacy config if present
bootstrapData := &appConfigActorBootstrap{
LegacyConfig: legacyCfg,
}
err = actors.RegisterSingletonActor(
AppConfigActorType, NewAppConfigActor,
local.WithBootstrapData(bootstrapData),
local.WithIdleTimeout(-1), // Disable idle timeout for this actor
)
if err != nil {
return nil, fmt.Errorf("error registering the %s actor: %w", AppConfigActorType, err)
}
service.actSvc = actors.Service()
return service, nil
}
// GetConfig returns the application configuration
// Important: Treat the object as read-only: do not modify its properties directly!
func (s *AppConfigService) GetConfig(parentCtx context.Context) (*AppConfigModel, error) {
// If the UI config is disabled, only load from the env
if common.EnvConfig.UiConfigDisabled {
return s.envConfig, nil
}
// Retrieve the config from the actor
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
res, err := s.actSvc.Peek(ctx, AppConfigActorType, actor.SingletonActorID, "get", nil)
if err != nil {
return nil, fmt.Errorf("error retrieving config from actor: %w", err)
}
if res == nil {
return nil, errors.New("config actor response was empty")
}
var cfg AppConfigModel
err = res.Decode(&cfg)
if err != nil {
return nil, fmt.Errorf("error decoding config actor response: %w", err)
}
return &cfg, nil
}
// UpdateAppConfig replaces the entire application configuration with the values from the input DTO.
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]AppConfigVariable, error) {
// If the UI config is disabled, we cannot continue
if common.EnvConfig.UiConfigDisabled {
return nil, &common.UiConfigDisabledError{}
}
// Replace the entire config by invoking the actor
cfg, err := s.invokeConfigActor(ctx, "replace", input)
if err != nil {
return nil, err
}
// Return the updated config
return cfg.ToAppConfigVariableSlice(true, false), nil
}
// UpdateAppConfigValues updates the provided application configuration values.
// Keys correspond to the "json" tags on the config model.
// An empty string value resets the property to its default value.
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
// Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 {
return errors.New("invalid number of arguments received")
}
// If the UI config is disabled, we cannot continue
if common.EnvConfig.UiConfigDisabled {
return &common.UiConfigDisabledError{}
}
// Collect the key-value pairs into a map for the actor
// (Note the += 2, as we are iterating through key-value pairs)
values := make(map[string]string, len(keysAndValues)/2)
for i := 1; i < len(keysAndValues); i += 2 {
values[keysAndValues[i-1]] = keysAndValues[i]
}
// Update the config by invoking the actor
_, err := s.invokeConfigActor(ctx, "update", values)
return err
}
// ListAppConfig returns the application configuration as a slice of key/value pairs.
// If showAll is false, only properties marked as public are included.
func (s *AppConfigService) ListAppConfig(ctx context.Context, showAll bool) ([]AppConfigVariable, error) {
cfg, err := s.GetConfig(ctx)
if err != nil {
return nil, err
}
return cfg.ToAppConfigVariableSlice(showAll, true), nil
}
// invokeConfigActor invokes a method on the AppConfig actor and decodes the returned state.
func (s *AppConfigService) invokeConfigActor(parentCtx context.Context, method string, data any) (*AppConfigModel, error) {
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
res, err := s.actSvc.Invoke(ctx, AppConfigActorType, actor.SingletonActorID, method, data)
if err != nil {
return nil, fmt.Errorf("error invoking config actor method '%s': %w", method, err)
}
if res == nil {
return nil, errors.New("config actor response was empty")
}
var cfg AppConfigModel
err = res.Decode(&cfg)
if err != nil {
return nil, fmt.Errorf("error decoding config actor response: %w", err)
}
return &cfg, nil
}
func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) {
// First, start from the default configuration
dest := getDefaultConfig()
// Iterate through each field
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
for i := range rt.NumField() {
field := rt.Field(i)
// Derive the environment variable name from the configuration's JSON key
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
envVarName := utils.CamelCaseToScreamingSnakeCase(key)
// Set the value if it's set
value, ok := os.LookupEnv(envVarName)
if ok {
rv.Field(i).SetString(value)
continue
}
// If it's sensitive, we also allow reading from file
if field.Tag.Get("sensitive") == "true" {
fileName := os.Getenv(envVarName + "_FILE")
if fileName != "" {
// #nosec G703 - Value is provided by admin
b, err := os.ReadFile(fileName)
if err != nil {
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
}
rv.Field(i).SetString(string(b))
continue
}
}
}
return dest, nil
}
+344
View File
@@ -0,0 +1,344 @@
package appconfig
import (
"encoding/json"
"testing"
"time"
"github.com/italypaleale/francis/host/local"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// setUIConfigDisabled sets common.EnvConfig.UiConfigDisabled for the duration of the test, restoring the previous global afterwards
func setUIConfigDisabled(t *testing.T, disabled bool) {
t.Helper()
original := common.EnvConfig
t.Cleanup(func() {
common.EnvConfig = original
})
common.EnvConfig.UiConfigDisabled = disabled
}
// newActorBackedService creates an AppConfigService wired to an in-memory test actor host.
// The AppConfig singleton actor is registered and bootstrapped from db, which is also used to load any legacy config.
func newActorBackedService(t *testing.T, db *gorm.DB) *AppConfigService {
t.Helper()
var svc *AppConfigService
testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) {
var err error
svc, err = NewService(t.Context(), h, db)
require.NoError(t, err)
})
require.NotNil(t, svc)
// The singleton actor is bootstrapped asynchronously once the host is ready.
// Before bootstrap runs, the actor has no state and GetConfig decodes it into a non-nil but zero config, so wait until a non-zero (bootstrapped) config is available before returning.
require.Eventually(t, func() bool {
cfg, err := svc.GetConfig(t.Context())
return err == nil && cfg != nil && *cfg != (AppConfigModel{})
}, 10*time.Second, 20*time.Millisecond, "config actor was not bootstrapped in time")
return svc
}
// seedLegacyConfig writes a legacy config blob to the kv table so the AppConfig actor bootstraps from it.
func seedLegacyConfig(t *testing.T, db *gorm.DB, values map[string]string) {
t.Helper()
blob, err := json.Marshal(values)
require.NoError(t, err)
value := string(blob)
err = db.Create(&model.KV{Key: "config_migrated", Value: &value}).Error
require.NoError(t, err)
}
// findConfigValue returns the value for key in a slice of AppConfigVariable, and whether it was found.
func findConfigValue(vars []AppConfigVariable, key string) (string, bool) {
for _, v := range vars {
if v.Key == key {
return v.Value, true
}
}
return "", false
}
func TestService_NewService(t *testing.T) {
t.Run("bootstraps the default config when the database is empty", func(t *testing.T) {
setUIConfigDisabled(t, false)
db := testutils.NewDatabaseForTest(t)
svc := newActorBackedService(t, db)
cfg, err := svc.GetConfig(t.Context())
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, *getDefaultConfig(), *cfg)
})
t.Run("bootstraps from the legacy config in the database", func(t *testing.T) {
setUIConfigDisabled(t, false)
db := testutils.NewDatabaseForTest(t)
seedLegacyConfig(t, db, map[string]string{
"appName": "Legacy App",
"ldapEnabled": "true",
})
svc := newActorBackedService(t, db)
cfg, err := svc.GetConfig(t.Context())
require.NoError(t, err)
assert.Equal(t, AppConfigValue("Legacy App"), cfg.AppName)
assert.Equal(t, AppConfigValue("true"), cfg.LdapEnabled)
// Keys not present in the legacy config keep their defaults
assert.Equal(t, getDefaultConfig().SessionDuration, cfg.SessionDuration)
})
t.Run("loads config from the environment when the UI config is disabled", func(t *testing.T) {
setUIConfigDisabled(t, true)
t.Setenv("APP_NAME", "Environment App")
// No actor host or database is needed when the UI config is disabled
svc, err := NewService(t.Context(), nil, nil)
require.NoError(t, err)
require.NotNil(t, svc)
cfg, err := svc.GetConfig(t.Context())
require.NoError(t, err)
assert.Equal(t, AppConfigValue("Environment App"), cfg.AppName)
})
}
func TestService_GetConfig(t *testing.T) {
t.Run("returns a fresh copy on each call", func(t *testing.T) {
setUIConfigDisabled(t, false)
db := testutils.NewDatabaseForTest(t)
svc := newActorBackedService(t, db)
first, err := svc.GetConfig(t.Context())
require.NoError(t, err)
// Mutating the returned config must not affect what the service returns later
first.AppName = "Mutated"
second, err := svc.GetConfig(t.Context())
require.NoError(t, err)
assert.Equal(t, getDefaultConfig().AppName, second.AppName)
})
t.Run("returns the env config when the UI config is disabled", func(t *testing.T) {
setUIConfigDisabled(t, true)
svc := NewTestAppConfigService(&AppConfigModel{AppName: "From Env"})
cfg, err := svc.GetConfig(t.Context())
require.NoError(t, err)
assert.Equal(t, AppConfigValue("From Env"), cfg.AppName)
})
}
func TestService_UpdateAppConfig(t *testing.T) {
t.Run("replaces the configuration and returns all variables", func(t *testing.T) {
setUIConfigDisabled(t, false)
db := testutils.NewDatabaseForTest(t)
svc := newActorBackedService(t, db)
input := dto.AppConfigUpdateDto{
AppName: "Replaced App",
SessionDuration: "120",
LdapEnabled: "true",
SmtpTls: "tls",
}
res, err := svc.UpdateAppConfig(t.Context(), input)
require.NoError(t, err)
// The returned slice includes all variables, both public and private
got, ok := findConfigValue(res, "appName")
require.True(t, ok)
assert.Equal(t, "Replaced App", got)
got, ok = findConfigValue(res, "smtpTls")
require.True(t, ok, "the returned slice should include private variables")
assert.Equal(t, "tls", got)
// The change is persisted and visible on subsequent reads
cfg, err := svc.GetConfig(t.Context())
require.NoError(t, err)
assert.Equal(t, AppConfigValue("Replaced App"), cfg.AppName)
assert.Equal(t, AppConfigValue("120"), cfg.SessionDuration)
assert.Equal(t, AppConfigValue("true"), cfg.LdapEnabled)
})
t.Run("resets fields omitted from the DTO to their defaults", func(t *testing.T) {
setUIConfigDisabled(t, false)
db := testutils.NewDatabaseForTest(t)
svc := newActorBackedService(t, db)
// First set some non-default values
_, err := svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
AppName: "First",
LdapEnabled: "true",
SmtpTls: "tls",
})
require.NoError(t, err)
// Replace again with only AppName set: the rest must reset to their defaults
_, err = svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{AppName: "Second"})
require.NoError(t, err)
cfg, err := svc.GetConfig(t.Context())
require.NoError(t, err)
assert.Equal(t, AppConfigValue("Second"), cfg.AppName)
assert.Equal(t, getDefaultConfig().LdapEnabled, cfg.LdapEnabled)
assert.Equal(t, getDefaultConfig().SmtpTls, cfg.SmtpTls)
})
t.Run("returns UiConfigDisabledError when the UI config is disabled", func(t *testing.T) {
setUIConfigDisabled(t, true)
svc := NewTestAppConfigService(nil)
_, err := svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{AppName: "X"})
require.Error(t, err)
var target *common.UiConfigDisabledError
assert.ErrorAs(t, err, &target)
})
}
func TestService_UpdateAppConfigValues(t *testing.T) {
t.Run("updates a subset of keys and leaves the rest unchanged", func(t *testing.T) {
setUIConfigDisabled(t, false)
db := testutils.NewDatabaseForTest(t)
svc := newActorBackedService(t, db)
err := svc.UpdateAppConfigValues(t.Context(), "appName", "Updated", "sessionDuration", "120")
require.NoError(t, err)
cfg, err := svc.GetConfig(t.Context())
require.NoError(t, err)
assert.Equal(t, AppConfigValue("Updated"), cfg.AppName)
assert.Equal(t, AppConfigValue("120"), cfg.SessionDuration)
// A key that was not part of the update keeps its default
assert.Equal(t, getDefaultConfig().LdapEnabled, cfg.LdapEnabled)
})
t.Run("an empty value resets the property to its default", func(t *testing.T) {
setUIConfigDisabled(t, false)
db := testutils.NewDatabaseForTest(t)
svc := newActorBackedService(t, db)
// Set a non-default value first
err := svc.UpdateAppConfigValues(t.Context(), "sessionDuration", "120")
require.NoError(t, err)
// Then reset it with an empty value
err = svc.UpdateAppConfigValues(t.Context(), "sessionDuration", "")
require.NoError(t, err)
cfg, err := svc.GetConfig(t.Context())
require.NoError(t, err)
assert.Equal(t, getDefaultConfig().SessionDuration, cfg.SessionDuration)
})
t.Run("an odd number of arguments returns an error", func(t *testing.T) {
setUIConfigDisabled(t, false)
db := testutils.NewDatabaseForTest(t)
svc := newActorBackedService(t, db)
err := svc.UpdateAppConfigValues(t.Context(), "appName")
require.Error(t, err)
assert.ErrorContains(t, err, "invalid number of arguments received")
})
t.Run("an unknown key returns an error and does not change the config", func(t *testing.T) {
setUIConfigDisabled(t, false)
db := testutils.NewDatabaseForTest(t)
svc := newActorBackedService(t, db)
err := svc.UpdateAppConfigValues(t.Context(), "thisKeyDoesNotExist", "value")
require.Error(t, err)
// The config must not have been modified
cfg, err := svc.GetConfig(t.Context())
require.NoError(t, err)
assert.Equal(t, *getDefaultConfig(), *cfg)
})
t.Run("returns UiConfigDisabledError when the UI config is disabled", func(t *testing.T) {
setUIConfigDisabled(t, true)
svc := NewTestAppConfigService(nil)
// An even number of arguments so the count check passes and we reach the UI-config check
err := svc.UpdateAppConfigValues(t.Context(), "appName", "X")
require.Error(t, err)
var target *common.UiConfigDisabledError
assert.ErrorAs(t, err, &target)
})
}
func TestService_ListAppConfig(t *testing.T) {
t.Run("returns only public variables when showAll is false", func(t *testing.T) {
setUIConfigDisabled(t, false)
db := testutils.NewDatabaseForTest(t)
svc := newActorBackedService(t, db)
vars, err := svc.ListAppConfig(t.Context(), false)
require.NoError(t, err)
// appName is public and must be present
_, ok := findConfigValue(vars, "appName")
assert.True(t, ok, "public variable appName should be present")
// smtpHost is not public and must be excluded
_, ok = findConfigValue(vars, "smtpHost")
assert.False(t, ok, "private variable smtpHost should be excluded")
})
t.Run("returns all variables when showAll is true", func(t *testing.T) {
setUIConfigDisabled(t, false)
db := testutils.NewDatabaseForTest(t)
svc := newActorBackedService(t, db)
vars, err := svc.ListAppConfig(t.Context(), true)
require.NoError(t, err)
_, ok := findConfigValue(vars, "appName")
assert.True(t, ok)
_, ok = findConfigValue(vars, "smtpHost")
assert.True(t, ok, "private variables should be included when showAll is true")
})
t.Run("reflects updates made through the service", func(t *testing.T) {
setUIConfigDisabled(t, false)
db := testutils.NewDatabaseForTest(t)
svc := newActorBackedService(t, db)
err := svc.UpdateAppConfigValues(t.Context(), "appName", "Listed App")
require.NoError(t, err)
vars, err := svc.ListAppConfig(t.Context(), true)
require.NoError(t, err)
got, ok := findConfigValue(vars, "appName")
require.True(t, ok)
assert.Equal(t, "Listed App", got)
})
t.Run("redacts sensitive values when the UI config is disabled", func(t *testing.T) {
setUIConfigDisabled(t, true)
svc := NewTestAppConfigService(&AppConfigModel{
SmtpPassword: "super-secret",
})
vars, err := svc.ListAppConfig(t.Context(), true)
require.NoError(t, err)
got, ok := findConfigValue(vars, "smtpPassword")
require.True(t, ok)
assert.Equal(t, "XXXXXXXXXX", got)
})
}
@@ -0,0 +1,27 @@
//go:build unit
// This file contains utils for unit tests and it's only built when the "unit" tag is set
package appconfig
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
func NewTestAppConfigService(config *AppConfigModel) *AppConfigService {
if config == nil {
// If there's no config, set the default one
config = getDefaultConfig()
}
service := &AppConfigService{
envConfig: config,
}
return service
}
// NewTestConfig returns an application configuration for use in tests, falling back to the default configuration when none is provided
func NewTestConfig(config *AppConfigModel) *AppConfigModel {
if config == nil {
config = getDefaultConfig()
}
return config
}