Files
photoprism/internal/api/cluster_nodes_register_test.go
2025-10-19 12:44:21 +02:00

343 lines
14 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/internal/service/cluster/provisioner"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/fs"
"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 = cluster.ExampleJoinToken
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 := &reg.Node{Node: cluster.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, cluster.ExampleJoinToken)
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, cluster.ExampleJoinToken)
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, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusOK, r.Code)
cleanupRegisterProvisioning(t, conf, r)
})
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("CreateNodeWithoutRotateSkipsProvisioner", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = cluster.ExampleJoinToken
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"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
body := r.Body.String()
assert.Contains(t, body, "\"Database\"")
assert.Contains(t, body, "\"Secrets\"")
assert.Contains(t, body, "\"ClientSecret\"")
assert.Equal(t, "", gjson.Get(body, "Database.Name").String())
assert.False(t, gjson.Get(body, "AlreadyProvisioned").Bool())
cleanupRegisterProvisioning(t, conf, r)
})
t.Run("CreateNodeRotateDatabaseProvisioned", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-rotate","RotateDatabase":true}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
body := r.Body.String()
assert.NotEqual(t, "", gjson.Get(body, "Database.Name").String())
assert.NotEqual(t, "", gjson.Get(body, "Database.Password").String())
assert.True(t, gjson.Get(body, "AlreadyProvisioned").Bool())
cleanupRegisterProvisioning(t, conf, r)
})
t.Run("UUIDChangeRequiresSecret", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
// Pre-create node with a UUID
n := &reg.Node{Node: cluster.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+`"}`, cluster.ExampleJoinToken)
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 = cluster.ExampleJoinToken
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"}`, cluster.ExampleJoinToken)
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 = cluster.ExampleJoinToken
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"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
cleanupRegisterProvisioning(t, conf, r)
// http is allowed for localhost
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-04b","AdvertiseUrl":"http://localhost:2342"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
cleanupRegisterProvisioning(t, conf, r)
})
t.Run("SiteUrlValidation", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = cluster.ExampleJoinToken
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"}`, cluster.ExampleJoinToken)
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"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
cleanupRegisterProvisioning(t, conf, r)
})
t.Run("NormalizeName", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = cluster.ExampleJoinToken
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, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
cleanupRegisterProvisioning(t, conf, r)
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 = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Empty nodeName → 400
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":""}`, cluster.ExampleJoinToken)
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 = cluster.ExampleJoinToken
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 := &reg.Node{Node: cluster.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}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusOK, r.Code)
cleanupRegisterProvisioning(t, conf, r)
// 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 = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Pre-create node in registry so handler goes through existing-node path.
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{Node: cluster.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"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusOK, r.Code)
cleanupRegisterProvisioning(t, conf, r)
// 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 = cluster.ExampleJoinToken
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"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
cleanupRegisterProvisioning(t, conf, r)
// Response must include Node.UUID
body := r.Body.String()
assert.NotEmpty(t, gjson.Get(body, "Node.UUID").String())
// 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)
}
})
t.Run("ThemeHintProvided", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
themeDir := conf.PortalThemePath()
assert.NoError(t, os.MkdirAll(themeDir, fs.ModeDir))
assert.NoError(t, os.WriteFile(filepath.Join(themeDir, fs.AppJsFile), []byte("// app\n"), fs.ModeFile))
assert.NoError(t, os.WriteFile(filepath.Join(themeDir, fs.VersionTxtFile), []byte(" 2.0.0\n"), fs.ModeFile))
t.Cleanup(func() { _ = os.RemoveAll(themeDir) })
body := `{"NodeName":"pp-node-theme","Theme":"1.0.0"}`
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
assert.Equal(t, "2.0.0", gjson.Get(r.Body.String(), "Theme").String())
cleanupRegisterProvisioning(t, conf, r)
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
node, err := regy.FindByName("pp-node-theme")
assert.NoError(t, err)
if assert.NotNil(t, node) {
assert.Equal(t, "1.0.0", node.Theme)
}
body = `{"NodeName":"pp-node-theme","Theme":"2.0.0"}`
r2 := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusOK, r2.Code)
assert.False(t, gjson.Get(r2.Body.String(), "Theme").Exists())
cleanupRegisterProvisioning(t, conf, r2)
})
}
func cleanupRegisterProvisioning(t *testing.T, conf *config.Config, r *httptest.ResponseRecorder) {
t.Helper()
if r.Code != http.StatusOK && r.Code != http.StatusCreated {
return
}
var resp cluster.RegisterResponse
if err := json.Unmarshal(r.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal register response: %v", err)
}
if !resp.AlreadyProvisioned {
return
}
name := resp.Database.Name
user := resp.Database.User
if conf != nil && (name == "" || user == "") && resp.Node.Name != "" && resp.Node.UUID != "" {
genName, genUser, _ := provisioner.GenerateCredentials(conf, resp.Node.UUID, resp.Node.Name)
if name == "" {
name = genName
}
if user == "" {
user = genUser
}
}
if name == "" && user == "" {
return
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := provisioner.DropCredentials(ctx, name, user); err != nil {
t.Fatalf("drop credentials for %s/%s: %v", name, user, err)
}
})
}