mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-23 02:24:24 +00:00
417 lines
17 KiB
Go
417 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/tidwall/gjson"
|
|
|
|
"github.com/photoprism/photoprism/internal/config"
|
|
"github.com/photoprism/photoprism/internal/entity"
|
|
"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.RoleApp
|
|
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)
|
|
rCreate := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-auth", "NodeRole":"`+cluster.RoleApp+`"}`, cluster.ExampleJoinToken)
|
|
cleanupRegisterProvisioning(t, conf, rCreate)
|
|
assert.Equal(t, http.StatusCreated, rCreate.Code)
|
|
assert.Contains(t, rCreate.Body.String(), `"AlreadyProvisioned":false`)
|
|
var resp cluster.RegisterResponse
|
|
json.Unmarshal(rCreate.Body.Bytes(), &resp)
|
|
n := resp.Node
|
|
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)
|
|
|
|
// Register the node to ensure that the database and registry is there
|
|
rCreate := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-lock", "NodeRole":"`+cluster.RoleApp+`"}`, cluster.ExampleJoinToken)
|
|
assert.Equal(t, http.StatusCreated, rCreate.Code)
|
|
assert.Contains(t, rCreate.Body.String(), `"AlreadyProvisioned":false`)
|
|
|
|
// 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)
|
|
cleanupRegisterProvisioning(t, conf, rCreate)
|
|
})
|
|
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)
|
|
// Register the node to ensure that the database and registry is there
|
|
rCreate := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-01", "NodeRole":"`+cluster.RoleApp+`"}`, cluster.ExampleJoinToken)
|
|
assert.Equal(t, http.StatusCreated, rCreate.Code)
|
|
assert.Contains(t, rCreate.Body.String(), `"AlreadyProvisioned":false`)
|
|
cleanupRegisterProvisioning(t, conf, rCreate)
|
|
|
|
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)
|
|
rCreate := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-02", "NodeRole":"`+cluster.RoleApp+`"}`, cluster.ExampleJoinToken)
|
|
assert.Equal(t, http.StatusCreated, rCreate.Code)
|
|
assert.Contains(t, rCreate.Body.String(), `"AlreadyProvisioned":false`)
|
|
|
|
// 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)
|
|
|
|
cleanupRegisterProvisioning(t, conf, rCreate)
|
|
|
|
})
|
|
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.Equal(t, "2.0.0", gjson.Get(r2.Body.String(), "Theme").String())
|
|
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)
|
|
}
|
|
|
|
// Why? This prevents cleanup in most cases, which means that some tests are failing because the item
|
|
// is still there after execution of a previous test. Which means that a test that expects it not to be
|
|
// there fails.
|
|
// Every unit test should be able to be run without depending on the results of a previous unit test,
|
|
// so you should clean up fully every time.
|
|
// 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 {
|
|
count1269 := strings.Count(err.Error(), "Error 1269")
|
|
countError := strings.Count(err.Error(), "Error")
|
|
if countError > count1269 { // Only abort if there was an issue other than Error 1269 (HY000): Can't revoke all privileges for one or more of the requested users
|
|
t.Fatalf("drop credentials for %s/%s: %v", name, user, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
if resp.Node.UUID != "" {
|
|
t.Cleanup(func() {
|
|
if err := entity.UnscopedDb().Where("node_uuid = ?", resp.Node.UUID).Delete(&entity.Client{}).Error; err != nil {
|
|
t.Fatalf("remove client for %s: %v", resp.Node.UUID, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
// TestValidateAdvertiseURL ensures the validator accepts HTTPS everywhere and allows
|
|
// HTTP only for loopback or cluster-internal service domains.
|
|
func TestValidateAdvertiseURL(t *testing.T) {
|
|
cases := []struct {
|
|
u string
|
|
ok bool
|
|
}{
|
|
{"https://example.com", true},
|
|
{"http://example.com", false},
|
|
{"http://localhost:2342", true},
|
|
{"http://photoprism.default.svc", true},
|
|
{"http://photoprism.default.svc.cluster.local", true},
|
|
{"http://photoprism.internal", true},
|
|
{"https://127.0.0.1", true},
|
|
{"ftp://example.com", false},
|
|
{"https://", false},
|
|
{"", false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := validateAdvertiseURL(c.u); got != c.ok {
|
|
t.Fatalf("validateAdvertiseURL(%q) = %v, want %v", c.u, got, c.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestValidateSiteURL mirrors the advertise URL rules for site URLs.
|
|
func TestValidateSiteURL(t *testing.T) {
|
|
cases := []struct {
|
|
u string
|
|
ok bool
|
|
}{
|
|
{"https://photos.example.com", true},
|
|
{"http://photos.example.com", false},
|
|
{"http://127.0.0.1:2342", true},
|
|
{"mailto:me@example.com", false},
|
|
{"://bad", false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := validateSiteURL(c.u); got != c.ok {
|
|
t.Fatalf("validateSiteURL(%q) = %v, want %v", c.u, got, c.ok)
|
|
}
|
|
}
|
|
}
|