mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-24 21:17:31 +00:00
461 lines
15 KiB
Go
461 lines
15 KiB
Go
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/apperror"
|
|
"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")
|
|
t.Setenv("SIGNUP_DEFAULT_CUSTOM_CLAIMS", `[{"key":"role","value":"user"}]`)
|
|
|
|
// 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)
|
|
assert.JSONEq(t, `[{"key":"role","value":"user"}]`, string(cfg.SignupDefaultCustomClaims))
|
|
})
|
|
|
|
t.Run("rejects invalid environment application configuration", func(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
envName string
|
|
value string
|
|
wantDetail string
|
|
}{
|
|
{
|
|
name: "malformed custom claims JSON",
|
|
envName: "SIGNUP_DEFAULT_CUSTOM_CLAIMS",
|
|
value: `["immich_role": "user"]`,
|
|
wantDetail: `JSON array of objects with string "key" and "value" properties`,
|
|
},
|
|
{
|
|
name: "custom claim missing value",
|
|
envName: "SIGNUP_DEFAULT_CUSTOM_CLAIMS",
|
|
value: `[{"key":"role"}]`,
|
|
wantDetail: `JSON array of objects with string "key" and "value" properties`,
|
|
},
|
|
{
|
|
name: "user group IDs with the wrong shape",
|
|
envName: "SIGNUP_DEFAULT_USER_GROUP_IDS",
|
|
value: `{"group":"id"}`,
|
|
wantDetail: "JSON array of strings",
|
|
},
|
|
{
|
|
name: "unsafe CIMD URL pattern",
|
|
envName: "CIMD_URL_ALLOWLIST",
|
|
value: `["javascript:alert(1)"]`,
|
|
wantDetail: "JSON array of valid callback URL patterns",
|
|
},
|
|
{
|
|
name: "invalid enum",
|
|
envName: "WEBAUTHN_USER_VERIFICATION",
|
|
value: "sometimes",
|
|
wantDetail: "is invalid",
|
|
},
|
|
{
|
|
name: "invalid boolean type",
|
|
envName: "REQUIRE_USER_EMAIL",
|
|
value: "hello",
|
|
wantDetail: "must be either true or false",
|
|
},
|
|
{
|
|
name: "invalid integer type",
|
|
envName: "SESSION_DURATION",
|
|
value: "hello",
|
|
wantDetail: "must be an integer",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
setUIConfigDisabled(t, true)
|
|
t.Setenv(test.envName, test.value)
|
|
|
|
svc, err := NewService(t.Context(), nil, nil)
|
|
require.Error(t, err)
|
|
assert.Nil(t, svc)
|
|
require.ErrorContains(t, err, test.envName)
|
|
require.ErrorContains(t, err, test.wantDetail)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
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 a UI-config-disabled error 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)
|
|
assert.True(t, apperror.IsCode(err, apperror.CodeUIConfigDisabled))
|
|
})
|
|
}
|
|
|
|
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 a UI-config-disabled error 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)
|
|
assert.True(t, apperror.IsCode(err, apperror.CodeUIConfigDisabled))
|
|
})
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|
|
|
|
func TestService_CIMDURLAllowlist(t *testing.T) {
|
|
t.Run("defaults to empty", func(t *testing.T) {
|
|
setUIConfigDisabled(t, false)
|
|
db := testutils.NewDatabaseForTest(t)
|
|
svc := newActorBackedService(t, db)
|
|
|
|
assert.Empty(t, svc.GetCIMDURLAllowlist())
|
|
})
|
|
|
|
t.Run("round-trips a valid allowlist", func(t *testing.T) {
|
|
setUIConfigDisabled(t, false)
|
|
db := testutils.NewDatabaseForTest(t)
|
|
svc := newActorBackedService(t, db)
|
|
|
|
_, err := svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
|
|
AppName: "App",
|
|
SessionDuration: "60",
|
|
CIMDURLAllowlist: `["https://app.example.com/**","https://*.trusted.com/oauth"]`,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t,
|
|
[]string{"https://app.example.com/**", "https://*.trusted.com/oauth"},
|
|
svc.GetCIMDURLAllowlist(),
|
|
)
|
|
})
|
|
|
|
t.Run("rejects an invalid pattern", func(t *testing.T) {
|
|
setUIConfigDisabled(t, false)
|
|
db := testutils.NewDatabaseForTest(t)
|
|
svc := newActorBackedService(t, db)
|
|
|
|
_, err := svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
|
|
AppName: "App",
|
|
SessionDuration: "60",
|
|
CIMDURLAllowlist: `["javascript:alert(1)"]`,
|
|
})
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("returns empty on malformed value", func(t *testing.T) {
|
|
setUIConfigDisabled(t, false)
|
|
db := testutils.NewDatabaseForTest(t)
|
|
svc := newActorBackedService(t, db)
|
|
|
|
require.NoError(t, svc.UpdateAppConfigValues(t.Context(), "cimdUrlAllowlist", "not-json"))
|
|
assert.Empty(t, svc.GetCIMDURLAllowlist())
|
|
})
|
|
}
|