mirror of
				https://github.com/photoprism/photoprism.git
				synced 2025-10-31 12:16:39 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			345 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			345 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| 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("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-], 1–32, 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
 | ||
| }
 | 
