photoprism/internal/service/cluster/registry/registry_test.go
Keith Martin b6687b6c29
Tests: Improve isolation and add fixes #5263
* Tests: fix random selection of deleted record causing test failure
* Tests: fix test folders being left after test
* Tests: ensure that required records are available
* Tests: make each package execute against it's own testdata folder to ensure no interpackage test failures
* Tests: add unit tests of CleanupTestFolder
* Tests:  Allow defer in TestMain
* Tests: rename testMain to runTestMain
2026-07-12 05:47:21 +02:00

372 lines
13 KiB
Go

package registry
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
)
// TestMain executes runTestMain returning it's results. It is done this way so that defer can be used to cleanup.
func TestMain(m *testing.M) {
os.Exit(runTestMain(m))
}
// TestMain ensures SQLite test DB artifacts are purged after the suite runs.
func runTestMain(m *testing.M) int {
// Remove temporary SQLite files before running the tests.
fs.PurgeTestDbFiles(".", false)
// Remove temporary SQLite files after running the tests.
defer fs.PurgeTestDbFiles(".", false)
// Run unit tests.
return m.Run()
}
func TestClientRegistry_GetAndDelete(t *testing.T) {
c := newRegistryTestConfig(t, "cluster-registry-delete")
r, _ := NewClientRegistryWithConfig(c)
// Missing / invalid uuid
if _, err := r.Get("not-a-uuid"); err == nil {
t.Fatalf("expected error for invalid uuid")
}
// Create node with an instance-declared group policy (write-control
// GroupsSrc marks this write's provenance).
n := &Node{Node: cluster.Node{Name: "pp-del", Role: cluster.RoleInstance, UUID: rnd.UUIDv7()}}
n.AllowGroups = []string{"g-ops"}
n.GroupsSrc = entity.ClientGroupsSrcNode
assert.NoError(t, r.Put(n))
assert.NotEmpty(t, n.ClientID)
assert.True(t, rnd.IsUID(n.ClientID, entity.ClientUID))
assert.True(t, rnd.IsUUID(n.UUID))
// Put reflects the persisted provenance into the read DTO field.
assert.Equal(t, entity.ClientGroupsSrcNode, n.Node.GroupsSrc)
// Get by UUID
got, err := r.Get(n.UUID)
assert.NoError(t, err)
if assert.NotNil(t, got) {
assert.Equal(t, n.UUID, got.UUID)
assert.Equal(t, "pp-del", got.Name)
assert.True(t, rnd.IsUUID(got.UUID))
assert.True(t, rnd.IsUID(got.ClientID, entity.ClientUID))
assert.Equal(t, []string{"g-ops"}, got.AllowGroups)
// toNode maps ClientData.GroupsSrc onto the read DTO field; got.GroupsSrc
// (registry.Node) is the separate write-control field, hence the qualifier.
assert.Equal(t, entity.ClientGroupsSrcNode, got.Node.GroupsSrc)
}
// Delete by UUID
assert.NoError(t, r.Delete(n.UUID))
// Now missing
_, err = r.Get(n.UUID)
assert.Error(t, err)
_, err = r.FindByName("pp-del")
assert.Error(t, err)
// Deleting again yields not found
assert.Error(t, r.Delete(n.UUID))
}
func TestClientRegistry_ListOrderByUpdatedAtDesc(t *testing.T) {
c := newRegistryTestConfig(t, "cluster-registry-order")
r, _ := NewClientRegistryWithConfig(c)
a := &Node{Node: cluster.Node{Name: "pp-a", Role: cluster.RoleInstance, UUID: rnd.UUIDv7()}}
b := &Node{Node: cluster.Node{Name: "pp-b", Role: "service", UUID: rnd.UUIDv7()}}
assert.NoError(t, r.Put(a))
// Ensure distinct UpdatedAt values (DBs often have second precision)
time.Sleep(1100 * time.Millisecond)
assert.NoError(t, r.Put(b))
// Update a to make it most recent
time.Sleep(1100 * time.Millisecond)
assert.NoError(t, r.Put(&Node{Node: cluster.Node{ClientID: a.ClientID, Name: a.Name}}))
list, err := r.List()
assert.NoError(t, err)
if assert.GreaterOrEqual(t, len(list), 2) {
// First should be the most recently updated (a)
assert.Equal(t, "pp-a", list[0].Name)
// Basic ID shape checks
assert.True(t, rnd.IsUUID(list[0].UUID))
assert.True(t, rnd.IsUID(list[0].ClientID, entity.ClientUID))
}
}
func TestResponseBuilders_RedactionAndOpts(t *testing.T) {
// Base node with all fields
n := Node{
Node: cluster.Node{
ClientID: "cs5gfen1bgxz7s9i",
Name: "pp-node",
Role: cluster.RoleInstance,
SiteUrl: "https://photos.example.com",
AdvertiseUrl: "http://node:2342",
Labels: map[string]string{"env": "prod"},
CreatedAt: time.Now().UTC().Format(time.RFC3339),
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
},
}
dbInfo := n.ensureDatabase()
dbInfo.Name = "dbn"
dbInfo.User = "dbu"
dbInfo.RotatedAt = time.Now().UTC().Format(time.RFC3339)
fullView := true
n.AllowGroups = []string{"media-acme-admin"}
n.AllowGroupRoles = map[string]string{"media-acme-admin": "admin"}
n.GroupsFullView = &fullView
// Set the read DTO field, not the registry.Node write-control GroupsSrc.
n.Node.GroupsSrc = entity.ClientGroupsSrcNode
// Non-admin (default opts): redact advertise/database and access rules
out := BuildClusterNode(n, NodeOpts{})
assert.Equal(t, "", out.AdvertiseUrl)
assert.Nil(t, out.Database)
assert.Nil(t, out.AllowGroups)
assert.Nil(t, out.AllowGroupRoles)
assert.Nil(t, out.GroupsFullView)
assert.Empty(t, out.GroupsSrc)
// Include advertise only
out2 := BuildClusterNode(n, NodeOpts{IncludeAdvertiseUrl: true})
assert.Equal(t, "http://node:2342", out2.AdvertiseUrl)
assert.Nil(t, out2.Database)
// Include advertise + database
out3 := BuildClusterNode(n, NodeOpts{IncludeAdvertiseUrl: true, IncludeDatabase: true})
if assert.NotNil(t, out3.Database) {
assert.Equal(t, "dbn", out3.Database.Name)
assert.Equal(t, "dbu", out3.Database.User)
}
assert.Nil(t, out3.AllowGroups, "access rules require IncludeAccessRules")
// Include access rules (full admin view)
out4 := BuildClusterNode(n, NodeOpts{IncludeAdvertiseUrl: true, IncludeDatabase: true, IncludeAccessRules: true})
assert.Equal(t, []string{"media-acme-admin"}, out4.AllowGroups)
assert.Equal(t, map[string]string{"media-acme-admin": "admin"}, out4.AllowGroupRoles)
if assert.NotNil(t, out4.GroupsFullView) {
assert.True(t, *out4.GroupsFullView)
}
assert.Equal(t, entity.ClientGroupsSrcNode, out4.GroupsSrc)
// BuildClusterNodes on empty input returns empty slice (not nil)
list := BuildClusterNodes(nil, NodeOpts{})
assert.NotNil(t, list)
assert.Equal(t, 0, len(list))
}
func TestApplyGroupConfig(t *testing.T) {
boolPtr := func(v bool) *bool { return &v }
declared := func(groups []string, roles map[string]string, fullView bool) *Node {
n := &Node{GroupsSrc: entity.ClientGroupsSrcNode}
n.AllowGroups = groups
n.AllowGroupRoles = roles
if fullView {
n.GroupsFullView = boolPtr(true)
}
return n
}
t.Run("NodeDeclares", func(t *testing.T) {
data := &entity.ClientData{}
applyGroupConfig(data, declared([]string{"g1"}, map[string]string{"g1": "admin"}, true))
assert.Equal(t, []string{"g1"}, data.AllowGroups)
assert.Equal(t, map[string]string{"g1": "admin"}, data.AllowGroupRoles)
assert.True(t, data.GroupsFullView)
assert.Equal(t, entity.ClientGroupsSrcNode, data.GroupsSrc)
})
t.Run("NodeNeverClobbersManual", func(t *testing.T) {
data := &entity.ClientData{AllowGroups: []string{"pinned"}, GroupsSrc: entity.ClientGroupsSrcManual}
applyGroupConfig(data, declared([]string{"g1"}, nil, false))
assert.Equal(t, []string{"pinned"}, data.AllowGroups)
assert.Equal(t, entity.ClientGroupsSrcManual, data.GroupsSrc)
})
t.Run("EmptyDeclarationClearsNodeValues", func(t *testing.T) {
data := &entity.ClientData{AllowGroups: []string{"g1"}, GroupsFullView: true, GroupsSrc: entity.ClientGroupsSrcNode}
applyGroupConfig(data, declared(nil, nil, false))
assert.Empty(t, data.AllowGroups)
assert.False(t, data.GroupsFullView)
assert.Equal(t, "", data.GroupsSrc)
})
t.Run("EmptyDeclarationKeepsUnmanagedValues", func(t *testing.T) {
data := &entity.ClientData{AllowGroups: []string{"legacy"}}
applyGroupConfig(data, declared(nil, nil, false))
assert.Equal(t, []string{"legacy"}, data.AllowGroups, "values without provenance must not be cleared")
})
t.Run("ManualEditPins", func(t *testing.T) {
data := &entity.ClientData{AllowGroups: []string{"g1"}, GroupsSrc: entity.ClientGroupsSrcNode}
n := &Node{GroupsSrc: entity.ClientGroupsSrcManual}
n.AllowGroups = []string{"g2"}
applyGroupConfig(data, n)
assert.Equal(t, []string{"g2"}, data.AllowGroups)
assert.Equal(t, entity.ClientGroupsSrcManual, data.GroupsSrc)
})
t.Run("ManualClearAllUnpins", func(t *testing.T) {
data := &entity.ClientData{AllowGroups: []string{"g1"}, GroupsFullView: true, GroupsSrc: entity.ClientGroupsSrcManual}
n := &Node{GroupsSrc: entity.ClientGroupsSrcManual}
n.AllowGroups = []string{}
n.AllowGroupRoles = map[string]string{}
n.GroupsFullView = boolPtr(false)
applyGroupConfig(data, n)
assert.Empty(t, data.AllowGroups)
assert.Equal(t, "", data.GroupsSrc, "a fully cleared manual config must un-pin")
})
t.Run("ManualNoFieldsNoChange", func(t *testing.T) {
data := &entity.ClientData{AllowGroups: []string{"g1"}, GroupsSrc: entity.ClientGroupsSrcNode}
applyGroupConfig(data, &Node{GroupsSrc: entity.ClientGroupsSrcManual})
assert.Equal(t, []string{"g1"}, data.AllowGroups)
assert.Equal(t, entity.ClientGroupsSrcNode, data.GroupsSrc)
})
t.Run("UnmanagedCallerLegacySemantics", func(t *testing.T) {
data := &entity.ClientData{AllowGroups: []string{"g1"}, GroupsSrc: entity.ClientGroupsSrcNode}
n := &Node{}
n.AllowGroups = []string{"g2"}
applyGroupConfig(data, n)
assert.Equal(t, []string{"g2"}, data.AllowGroups)
assert.Equal(t, entity.ClientGroupsSrcNode, data.GroupsSrc, "no provenance update without a source")
})
}
func TestNodeOptsForSession_AdminVsNonAdmin(t *testing.T) {
// Admin: SuperAdmin=true suffices for IsAdmin()
admin := &entity.User{SuperAdmin: true}
sAdmin, _ := entity.NewSession(0, 0), (&entity.User{})
sAdmin.SetUser(admin)
optsA := NodeOptsForSession(sAdmin)
assert.True(t, optsA.IncludeAdvertiseUrl)
assert.True(t, optsA.IncludeDatabase)
// Non-admin: empty session/user
s := &entity.Session{}
opts := NodeOptsForSession(s)
assert.False(t, opts.IncludeAdvertiseUrl)
assert.False(t, opts.IncludeDatabase)
// Nil session defaults to redacted
optsNil := NodeOptsForSession(nil)
assert.False(t, optsNil.IncludeAdvertiseUrl)
assert.False(t, optsNil.IncludeDatabase)
}
func TestToNode_Mapping(t *testing.T) {
newRegistryTestConfig(t, "cluster-registry-map")
m := entity.NewClient().SetName("pp-map").SetRole(cluster.RoleInstance)
m.NodeUUID = rnd.UUIDv7()
m.ClientURL = "http://pp-map:2342"
data := m.GetData()
data.Labels = map[string]string{"tier": "gold"}
data.SiteURL = "https://photos.example.com"
data.Database = &entity.ClientDatabase{Name: "dbn", User: "dbu", RotatedAt: time.Now().UTC().Format(time.RFC3339)}
m.SetData(data)
assert.NoError(t, m.Create())
n := toNode(m)
if assert.NotNil(t, n) {
assert.Equal(t, "pp-map", n.Name)
assert.Equal(t, cluster.RoleInstance, n.Role)
assert.Equal(t, "http://pp-map:2342", n.AdvertiseUrl)
assert.Equal(t, "gold", n.Labels["tier"])
assert.Equal(t, "https://photos.example.com", n.SiteUrl)
assert.Equal(t, "dbn", n.Database.Name)
assert.Equal(t, "dbu", n.Database.User)
_, err := time.Parse(time.RFC3339, n.CreatedAt)
assert.NoError(t, err)
_, err = time.Parse(time.RFC3339, n.UpdatedAt)
assert.NoError(t, err)
}
}
// TestClientRegistry_RedirectURIs_RoundTrip confirms RedirectURIs flow
// through Put → reload → toNode unchanged, that a nil slice on update
// means "no change" (preserves the previous set), and that a non-nil
// slice (even empty) replaces it.
func TestClientRegistry_RedirectURIs_RoundTrip(t *testing.T) {
c := newRegistryTestConfig(t, "cluster-registry-redirecturis")
assert.NoError(t, c.Init())
r, _ := NewClientRegistryWithConfig(c)
uri1 := "https://photos.example.com/api/v1/oidc/redirect"
uri2 := "http://127.0.0.1:2342/api/v1/oidc/redirect"
n := &Node{Node: cluster.Node{
Name: "pp-redir",
Role: cluster.RoleInstance,
UUID: rnd.UUIDv7(),
RedirectURIs: []string{uri1, uri2},
}}
assert.NoError(t, r.Put(n))
reloaded, err := r.FindByNodeUUID(n.UUID)
assert.NoError(t, err)
if assert.NotNil(t, reloaded) {
assert.Equal(t, []string{uri1, uri2}, reloaded.RedirectURIs)
}
// nil on a subsequent Put preserves the previously persisted set.
reloaded.RedirectURIs = nil
reloaded.Name = "pp-redir-renamed"
assert.NoError(t, r.Put(reloaded))
again, err := r.FindByNodeUUID(n.UUID)
assert.NoError(t, err)
if assert.NotNil(t, again) {
assert.Equal(t, []string{uri1, uri2}, again.RedirectURIs, "nil slice must not clear persisted redirect uris")
}
// Empty non-nil slice replaces (clears) the persisted set.
again.RedirectURIs = []string{}
assert.NoError(t, r.Put(again))
cleared, err := r.FindByNodeUUID(n.UUID)
assert.NoError(t, err)
if assert.NotNil(t, cleared) {
assert.Empty(t, cleared.RedirectURIs, "empty slice must clear the persisted set")
}
}
func TestClientRegistry_GetClusterNodeByUUID(t *testing.T) {
c := newRegistryTestConfig(t, "cluster-registry-getbyuuid")
assert.NoError(t, c.Init())
r, _ := NewClientRegistryWithConfig(c)
// Insert a node with NodeUUID
nu := rnd.UUIDv7()
n := &Node{Node: cluster.Node{Name: "pp-getuuid", Role: cluster.RoleInstance, UUID: nu}}
assert.NoError(t, r.Put(n))
// Fetch DTO by NodeUUID
dto, err := r.GetClusterNodeByUUID(nu, NodeOpts{})
assert.NoError(t, err)
assert.Equal(t, "pp-getuuid", dto.Name)
assert.Equal(t, nu, dto.UUID)
assert.True(t, rnd.IsUUID(dto.UUID))
}
func TestClientRegistry_FindByName_NormalizesDNSLabel(t *testing.T) {
c := newRegistryTestConfig(t, "cluster-registry-findname")
assert.NoError(t, c.Init())
r, _ := NewClientRegistryWithConfig(c)
// Create canonical node name
n := &Node{Node: cluster.Node{Name: "my-node-prod", Role: cluster.RoleInstance}}
assert.NoError(t, r.Put(n))
// Lookup using mixed separators and case
got, err := r.FindByName("My.Node/Prod")
assert.NoError(t, err)
if assert.NotNil(t, got) {
assert.Equal(t, "my-node-prod", got.Name)
}
}