Files
photoprism/internal/config/config_cluster_test.go
2025-09-26 02:38:49 +02:00

418 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package config
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v2"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestConfig_PortalUrl(t *testing.T) {
t.Run("Unset", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.PortalUrl = ""
c.options.ClusterDomain = "example.dev"
assert.Equal(t, "", c.PortalUrl())
c.options.PortalUrl = DefaultPortalUrl
})
t.Run("Default", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.PortalUrl = DefaultPortalUrl
c.options.ClusterDomain = "foo.bar.baz"
assert.Equal(t, "https://portal.foo.bar.baz", c.PortalUrl())
})
t.Run("Substitute_PHOTOPRISM_CLUSTER_DOMAIN", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.ClusterDomain = "example.dev"
// Use curly braces style as found in repo fixtures; resolver normalizes to ${...}.
c.options.PortalUrl = "https://portal.${PHOTOPRISM_CLUSTER_DOMAIN}"
assert.Equal(t, "https://portal.example.dev", c.PortalUrl())
c.options.PortalUrl = DefaultPortalUrl
})
t.Run("Substitute_CLUSTER_DOMAIN", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.ClusterDomain = "example.dev"
c.options.PortalUrl = "https://portal.${CLUSTER_DOMAIN}"
assert.Equal(t, "https://portal.example.dev", c.PortalUrl())
c.options.PortalUrl = DefaultPortalUrl
})
t.Run("Substitute_cluster_dash_domain_Curly", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.ClusterDomain = "example.dev"
// Curly brace variant {cluster-domain} is normalized by ExpandVars.
c.options.PortalUrl = "https://portal.${cluster-domain}"
assert.Equal(t, "https://portal.example.dev", c.PortalUrl())
c.options.PortalUrl = DefaultPortalUrl
})
t.Run("LiteralPreserved", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.PortalUrl = "https://portal.example.test"
c.options.ClusterDomain = "ignored.dev"
assert.Equal(t, "https://portal.example.test", c.PortalUrl())
c.options.PortalUrl = DefaultPortalUrl
})
}
func TestConfig_Cluster(t *testing.T) {
t.Run("Flags", func(t *testing.T) {
c := NewConfig(CliTestContext())
// Defaults
assert.False(t, c.IsPortal())
// Toggle values
c.Options().NodeRole = string(cluster.RolePortal)
assert.True(t, c.IsPortal())
c.Options().NodeRole = ""
})
t.Run("JWKSUrlSetter", func(t *testing.T) {
const existing = "https://existing.example/.well-known/jwks.json"
tests := []struct {
name string
prev string
input string
expect string
}{
{
name: "TrimHTTPS",
prev: "",
input: " https://portal.example/.well-known/jwks.json ",
expect: "https://portal.example/.well-known/jwks.json",
},
{
name: "CaseInsensitiveScheme",
prev: "",
input: "HTTPS://portal.example/.well-known/jwks.json",
expect: "HTTPS://portal.example/.well-known/jwks.json",
},
{
name: "AllowHTTPOnLocalhost",
prev: "",
input: "http://localhost:2342/.well-known/jwks.json",
expect: "http://localhost:2342/.well-known/jwks.json",
},
{
name: "AllowHTTPOnLoopbackIPv4",
prev: "",
input: "http://127.0.0.1/.well-known/jwks.json",
expect: "http://127.0.0.1/.well-known/jwks.json",
},
{
name: "AllowHTTPOnLoopbackIPv6",
prev: "",
input: "http://[::1]/.well-known/jwks.json",
expect: "http://[::1]/.well-known/jwks.json",
},
{
name: "RejectHTTPNonLoopback",
prev: existing,
input: "http://portal.example/.well-known/jwks.json",
expect: existing,
},
{
name: "RejectUnsupportedScheme",
prev: existing,
input: "ftp://portal.example/.well-known/jwks.json",
expect: existing,
},
{
name: "RejectMalformedURL",
prev: existing,
input: "://not-a-url",
expect: existing,
},
{
name: "ClearValue",
prev: existing,
input: "",
expect: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.JWKSUrl = tc.prev
c.SetJWKSUrl(tc.input)
assert.Equal(t, tc.expect, c.JWKSUrl())
})
}
})
t.Run("Paths", func(t *testing.T) {
c := NewConfig(CliTestContext())
// Use an isolated config path so we don't affect repo storage fixtures.
tempCfg := t.TempDir()
c.options.ConfigPath = tempCfg
c.options.NodeClientSecret = ""
c.options.PortalUrl = ""
c.options.JoinToken = ""
c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml")
// Clear values potentially loaded at NewConfig creation.
c.options.NodeClientSecret = ""
c.options.PortalUrl = ""
c.options.JoinToken = ""
c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml")
// Clear values that may have been loaded from repo fixtures before we
// isolated the config path.
c.options.NodeClientSecret = ""
c.options.PortalUrl = ""
c.options.JoinToken = ""
c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml")
// PortalConfigPath always points to a "cluster" subfolder under ConfigPath.
expectedCluster := filepath.Join(c.ConfigPath(), fs.PortalDir)
assert.Equal(t, expectedCluster, c.PortalConfigPath())
// PortalThemePath falls back to ThemePath if cluster dir does not exist.
expectedTheme := filepath.Join(c.ConfigPath(), fs.ThemeDir)
assert.Equal(t, expectedTheme, c.PortalThemePath())
// When only the cluster directory exists (without a theme subfolder), it still falls back to ThemePath.
assert.NoError(t, os.MkdirAll(expectedCluster, fs.ModeDir))
assert.Equal(t, expectedTheme, c.PortalThemePath())
// When the cluster theme directory exists, PortalThemePath returns it.
expectedClusterTheme := filepath.Join(expectedCluster, fs.ThemeDir)
assert.NoError(t, os.MkdirAll(expectedClusterTheme, fs.ModeDir))
assert.Equal(t, expectedClusterTheme, c.PortalThemePath())
})
t.Run("PortalAndSecrets", func(t *testing.T) {
// Isolate config so defaults aren't overridden by repo fixtures: set config-path
// before creating the Config so NewConfig does not load repository options.yml.
tempCfg := t.TempDir()
ctx := CliTestContext()
assert.NoError(t, ctx.Set("config-path", tempCfg))
c := NewConfig(ctx)
// Defaults (no options.yml present). Clear the flag default for portal-url
// so we can assert the derived (unset) behavior.
c.options.PortalUrl = ""
assert.Equal(t, "", c.PortalUrl())
assert.Equal(t, "", c.JoinToken())
assert.Equal(t, "", c.NodeClientSecret())
// Set and read back values
c.options.PortalUrl = "https://portal.example.test"
c.options.JoinToken = "join-token"
c.options.NodeClientSecret = "node-secret"
assert.Equal(t, "https://portal.example.test", c.PortalUrl())
assert.Equal(t, "join-token", c.JoinToken())
assert.Equal(t, "node-secret", c.NodeClientSecret())
})
t.Run("AbsolutePaths", func(t *testing.T) {
c := NewConfig(CliTestContext())
tempCfg := t.TempDir()
c.options.ConfigPath = tempCfg
// ThemePath should be absolute.
assert.True(t, filepath.IsAbs(c.ThemePath()))
// PortalThemePath should be absolute (fallback case).
assert.True(t, filepath.IsAbs(c.PortalThemePath()))
// Create cluster theme directory and verify again.
clusterTheme := filepath.Join(c.PortalConfigPath(), fs.ThemeDir)
assert.NoError(t, os.MkdirAll(clusterTheme, fs.ModeDir))
assert.True(t, filepath.IsAbs(c.PortalThemePath()))
})
t.Run("NodeName", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.SiteUrl = "https://app.localssl.dev"
h, d, found := c.deriveNodeNameAndDomainFromHttpHost()
assert.Equal(t, "app", h)
assert.Equal(t, "localssl.dev", d)
assert.True(t, found)
c.options.NodeName = " Client Credentials幸"
assert.Equal(t, "client-credentials", c.NodeName())
c.options.NodeName = ""
// With defaults, NodeName derives from hostname or falls back to a stable identifier.
got := c.NodeName()
assert.NotEmpty(t, got)
assert.Equal(t, "app", h)
assert.Equal(t, "localssl.dev", d)
// Must be DNS label compatible (lowercase [a-z0-9-], 132, start/end alnum).
assert.Regexp(t, `^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$`, got)
})
t.Run("NodeNameNormalization", func(t *testing.T) {
orig := getHostname
getHostname = func() (string, error) { return "", nil }
t.Cleanup(func() { getHostname = orig })
c := NewConfig(CliTestContext())
c.options.NodeName = " My.Host/Name:Prod "
assert.Equal(t, "my-host-name-prod", c.NodeName())
c.options.NodeName = "-._a--"
assert.Equal(t, "a", c.NodeName())
c.options.NodeName = strings.Repeat("a", 40)
assert.Equal(t, strings.Repeat("a", 32), c.NodeName())
})
t.Run("NodeNameFromHostname", func(t *testing.T) {
orig := getHostname
getHostname = func() (string, error) { return "My.Host/Name:Prod", nil }
t.Cleanup(func() { getHostname = orig })
c := NewConfig(CliTestContext())
c.options.NodeName = ""
assert.Equal(t, "my-host-name-prod", c.NodeName())
})
t.Run("NodeRoleValues", func(t *testing.T) {
c := NewConfig(CliTestContext())
// Default / unknown → node
c.options.NodeRole = ""
assert.Equal(t, string(cluster.RoleInstance), c.NodeRole())
c.options.NodeRole = "unknown"
assert.Equal(t, string(cluster.RoleInstance), c.NodeRole())
// Explicit values
c.options.NodeRole = string(cluster.RoleInstance)
assert.Equal(t, string(cluster.RoleInstance), c.NodeRole())
c.options.NodeRole = string(cluster.RolePortal)
assert.Equal(t, string(cluster.RolePortal), c.NodeRole())
c.options.NodeRole = string(cluster.RoleService)
assert.Equal(t, string(cluster.RoleService), c.NodeRole())
})
t.Run("SecretsFromFiles", func(t *testing.T) {
c := NewConfig(CliTestContext())
// Create temp secret/token files.
dir := t.TempDir()
nsFile := filepath.Join(dir, "node_client_secret")
tkFile := filepath.Join(dir, "portal_token")
assert.NoError(t, os.WriteFile(nsFile, []byte("s3cr3t"), 0o600))
assert.NoError(t, os.WriteFile(tkFile, []byte("t0k3n"), 0o600))
// Clear inline values so file-based lookup is used.
c.options.NodeClientSecret = ""
c.options.JoinToken = ""
// Point env vars at the files and verify.
t.Setenv("PHOTOPRISM_NODE_CLIENT_SECRET_FILE", nsFile)
t.Setenv("PHOTOPRISM_JOIN_TOKEN_FILE", tkFile)
assert.Equal(t, "s3cr3t", c.NodeClientSecret())
assert.Equal(t, "t0k3n", c.JoinToken())
// Empty / missing should yield empty strings.
t.Setenv("PHOTOPRISM_NODE_CLIENT_SECRET_FILE", filepath.Join(dir, "missing"))
t.Setenv("PHOTOPRISM_JOIN_TOKEN_FILE", filepath.Join(dir, "missing"))
assert.Equal(t, "", c.NodeClientSecret())
assert.Equal(t, "", c.JoinToken())
})
}
func TestConfig_ClusterUUID_FileOverridesEnv(t *testing.T) {
c := NewConfig(CliTestContext())
// Isolate config path.
tempCfg := t.TempDir()
c.options.ConfigPath = tempCfg
// Prepare options.yml with a UUID; file should override env/CLI.
opts := map[string]any{"ClusterUUID": "11111111-1111-4111-8111-111111111111"}
b, _ := yaml.Marshal(opts)
assert.NoError(t, os.WriteFile(filepath.Join(tempCfg, "options.yml"), b, fs.ModeFile))
// Set env; file value must win for consistency with other options.
t.Setenv("PHOTOPRISM_CLUSTER_UUID", "22222222-2222-4222-8222-222222222222")
// Load options.yml into options struct (we updated ConfigPath after creation).
assert.NoError(t, c.options.Load(c.OptionsYaml()))
got := c.ClusterUUID()
assert.Equal(t, "11111111-1111-4111-8111-111111111111", got)
}
func TestConfig_ClusterUUID_FromOptions(t *testing.T) {
c := NewConfig(CliTestContext())
optionsOriginal := c.OptionsYaml()
tempCfg := t.TempDir()
if err := fs.MkdirAll(tempCfg); err != nil {
t.Fatal(err)
}
c.options.ConfigPath = tempCfg
optionsYaml := filepath.Join(tempCfg, "options.yml")
c.options.OptionsYaml = optionsYaml
opts := map[string]any{"ClusterUUID": "33333333-3333-4333-8333-333333333333"}
b, _ := yaml.Marshal(opts)
assert.NoError(t, os.WriteFile(optionsYaml, b, fs.ModeFile))
// Ensure env is not set.
t.Setenv("PHOTOPRISM_CLUSTER_UUID", "")
// Load options.yml into options struct (we updated ConfigPath after creation).
assert.NoError(t, c.options.Load(optionsYaml))
// Access the value via getter.
got := c.ClusterUUID()
assert.Equal(t, "33333333-3333-4333-8333-333333333333", got)
c.options.OptionsYaml = optionsOriginal
}
func TestConfig_ClusterUUID_FromCLIFlag(t *testing.T) {
// Create a config path so NewConfig reads/writes here and options.yml does not exist.
tempCfg := t.TempDir()
// Start from the default CLI test context and override flags we care about.
ctx := CliTestContext()
assert.NoError(t, ctx.Set("config-path", tempCfg))
assert.NoError(t, ctx.Set("cluster-uuid", "44444444-4444-4444-8444-444444444444"))
c := NewConfig(ctx)
// No env and no options.yml: should take the CLI flag value directly from options.
t.Setenv("PHOTOPRISM_CLUSTER_UUID", "")
got := c.ClusterUUID()
assert.Equal(t, "44444444-4444-4444-8444-444444444444", got)
}
func TestConfig_ClusterUUID_GenerateAndPersist(t *testing.T) {
c := NewConfig(CliTestContext())
optionsOriginal := c.OptionsYaml()
tempCfg := t.TempDir()
if err := fs.MkdirAll(tempCfg); err != nil {
t.Fatal(err)
}
c.options.ConfigPath = tempCfg
optionsYaml := filepath.Join(tempCfg, "options.yml")
c.options.OptionsYaml = optionsYaml
// No env, no options.yml → should generate and persist.
t.Setenv("PHOTOPRISM_CLUSTER_UUID", "")
if err := c.SaveClusterUUID(rnd.UUID()); err != nil {
t.Fatal(err)
}
got := c.ClusterUUID()
if !rnd.IsUUID(got) {
t.Fatalf("expected a UUIDv4, got %q", got)
}
// Verify content persisted to options.yml.
b, err := os.ReadFile(optionsYaml)
assert.NoError(t, err)
var m map[string]any
assert.NoError(t, yaml.Unmarshal(b, &m))
assert.Equal(t, got, m["ClusterUUID"])
// Second call returns the same value (from options in-memory / file).
got2 := c.ClusterUUID()
assert.Equal(t, got, got2)
c.options.OptionsYaml = optionsOriginal
}