package api import ( "net/http" "testing" "github.com/stretchr/testify/assert" "github.com/photoprism/photoprism/internal/service/cluster" reg "github.com/photoprism/photoprism/internal/service/cluster/registry" "github.com/photoprism/photoprism/pkg/rnd" ) func TestClusterNodesRegister(t *testing.T) { t.Run("FeatureDisabled", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RoleInstance ClusterNodesRegister(router) r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`) assert.Equal(t, http.StatusForbidden, r.Code) }) // Register with existing ClientID requires clientSecret t.Run("ExistingClientRequiresSecret", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // Pre-create a node via registry and rotate to get a plaintext secret for tests regy, err := reg.NewClientRegistryWithConfig(conf) assert.NoError(t, err) n := ®.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"} assert.NoError(t, regy.Put(n)) nr, err := regy.RotateSecret(n.UUID) assert.NoError(t, err) secret := nr.ClientSecret // Missing secret → 401 body := `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `"}` r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n") assert.Equal(t, http.StatusUnauthorized, r.Code) // Wrong secret → 401 body = `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `","clientSecret":"WRONG"}` r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n") assert.Equal(t, http.StatusUnauthorized, r.Code) // Correct secret → 200 (existing-node path) body = `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `","clientSecret":"` + secret + `"}` r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n") assert.Equal(t, http.StatusOK, r.Code) }) t.Run("MissingToken", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal ClusterNodesRegister(router) r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`) assert.Equal(t, http.StatusUnauthorized, r.Code) }) t.Run("CreateNode_SucceedsWithProvisioner", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // Provisioner is independent of the main DB; with MariaDB admin DSN configured // it should successfully provision and return 201. r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`, "t0k3n") assert.Equal(t, http.StatusCreated, r.Code) body := r.Body.String() assert.Contains(t, body, "\"database\"") assert.Contains(t, body, "\"secrets\"") // New nodes return the client secret; include alias for clarity. assert.Contains(t, body, "\"clientSecret\"") }) t.Run("UUIDChangeRequiresSecret", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) regy, err := reg.NewClientRegistryWithConfig(conf) assert.NoError(t, err) // Pre-create node with a UUID n := ®.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"} assert.NoError(t, regy.Put(n)) // Attempt to change UUID via name without client credentials → 409 newUUID := rnd.UUIDv7() r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-lock","nodeUUID":"`+newUUID+`"}`, "t0k3n") assert.Equal(t, http.StatusConflict, r.Code) }) t.Run("BadAdvertiseUrlRejected", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // http scheme for public host must be rejected (require https unless localhost). r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-03","advertiseUrl":"http://example.com"}`, "t0k3n") assert.Equal(t, http.StatusBadRequest, r.Code) }) t.Run("GoodAdvertiseUrlAccepted", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // https is allowed for public host r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04","advertiseUrl":"https://example.com"}`, "t0k3n") assert.Equal(t, http.StatusCreated, r.Code) // http is allowed for localhost r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04b","advertiseUrl":"http://localhost:2342"}`, "t0k3n") assert.Equal(t, http.StatusCreated, r.Code) }) t.Run("SiteUrlValidation", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // Reject http siteUrl for public host r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-05","siteUrl":"http://example.com"}`, "t0k3n") assert.Equal(t, http.StatusBadRequest, r.Code) // Accept https siteUrl r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-06","siteUrl":"https://photos.example.com"}`, "t0k3n") assert.Equal(t, http.StatusCreated, r.Code) }) t.Run("NormalizeName", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // Mixed separators and case should normalize to DNS label body := `{"nodeName":"My.Node/Name:Prod"}` r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n") assert.Equal(t, http.StatusCreated, r.Code) regy, err := reg.NewClientRegistryWithConfig(conf) assert.NoError(t, err) n, err := regy.FindByName("my-node-name-prod") assert.NoError(t, err) if assert.NotNil(t, n) { assert.Equal(t, "my-node-name-prod", n.Name) } }) t.Run("BadName", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // Empty nodeName → 400 r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":""}`, "t0k3n") assert.Equal(t, http.StatusBadRequest, r.Code) }) t.Run("RotateSecretPersistsAndRespondsOK", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // Pre-create node in registry so handler goes through existing-node path // and rotates the secret before attempting DB ensure. Don't reuse the // Monitoring fixture client ID to avoid changing its secret, which is // used by OAuth tests running in the same package. regy, err := reg.NewClientRegistryWithConfig(conf) assert.NoError(t, err) n := ®.Node{Name: "pp-node-01", Role: "instance"} assert.NoError(t, regy.Put(n)) r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, "t0k3n") assert.Equal(t, http.StatusOK, r.Code) // Secret should have rotated and been persisted even though DB ensure failed. // Fetch by name (most-recently-updated) to avoid flakiness if another test adds // a node with the same name and a different id. n2, err := regy.FindByName("pp-node-01") assert.NoError(t, err) // With client-backed registry, plaintext secret is not persisted; only rotation timestamp is updated. assert.NotEmpty(t, n2.RotatedAt) }) t.Run("ExistingNodeSiteUrlPersistsAndRespondsOK", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // Pre-create node in registry so handler goes through existing-node path. regy, err := reg.NewClientRegistryWithConfig(conf) assert.NoError(t, err) n := ®.Node{Name: "pp-node-02", Role: "instance"} assert.NoError(t, regy.Put(n)) // Provisioner is independent; endpoint should respond 200 and persist metadata. r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-02","siteUrl":"https://Photos.Example.COM"}`, "t0k3n") assert.Equal(t, http.StatusOK, r.Code) // Ensure normalized/persisted siteUrl. n2, err := regy.FindByName("pp-node-02") assert.NoError(t, err) assert.Equal(t, "https://photos.example.com", n2.SiteUrl) }) t.Run("AssignNodeUUIDWhenMissing", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // Register without nodeUUID; server should assign one (UUID v7 preferred). r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-uuid"}`, "t0k3n") assert.Equal(t, http.StatusCreated, r.Code) // Response must include node.uuid body := r.Body.String() assert.Contains(t, body, "\"uuid\"") // Verify it is persisted in the registry regy, err := reg.NewClientRegistryWithConfig(conf) assert.NoError(t, err) n, err := regy.FindByName("pp-node-uuid") assert.NoError(t, err) if assert.NotNil(t, n) { assert.NotEmpty(t, n.UUID) } }) }