mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
234 lines
9.7 KiB
Go
234 lines
9.7 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|