Files
photoprism/internal/commands/cluster_register_http_test.go
2025-09-16 23:30:23 +02:00

461 lines
18 KiB
Go

package commands
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/urfave/cli/v2"
cfg "github.com/photoprism/photoprism/internal/config"
)
func TestClusterRegister_HTTPHappyPath(t *testing.T) {
// Fake Portal register endpoint
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n1", "name": "pp-node-02", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd", "dsn": "user:pwd@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": false,
"alreadyProvisioned": false,
})
}))
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-02", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--json",
})
assert.NoError(t, err)
// Parse JSON
assert.Equal(t, "pp-node-02", gjson.Get(out, "node.name").String())
assert.Equal(t, "secret", gjson.Get(out, "secrets.nodeSecret").String())
assert.Equal(t, "pwd", gjson.Get(out, "db.password").String())
dsn := gjson.Get(out, "db.dsn").String()
parsed := cfg.NewDSN(dsn)
assert.Equal(t, "user", parsed.User)
assert.Equal(t, "pwd", parsed.Password)
assert.Equal(t, "tcp", parsed.Net)
assert.Equal(t, "db:3306", parsed.Server)
assert.Equal(t, "pp_db", parsed.Name)
}
func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
// Fake Portal register endpoint for rotation
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n1", "name": "pp-node-03", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd2", "dsn": "user:pwd2@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret2", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
defer os.Unsetenv("PHOTOPRISM_CLI")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--secret", "--yes", "pp-node-03",
})
assert.NoError(t, err)
assert.Contains(t, out, "pp-node-03")
assert.Contains(t, out, "Node Secret")
assert.Contains(t, out, "DB Password")
}
func TestClusterNodesRotate_HTTPJson(t *testing.T) {
// Fake Portal register endpoint for rotation in JSON mode
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n2", "name": "pp-node-04", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd3", "dsn": "user:pwd3@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret3", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
defer os.Unsetenv("PHOTOPRISM_CLI")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--db", "--secret", "--yes", "pp-node-04",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-04", gjson.Get(out, "node.name").String())
assert.Equal(t, "secret3", gjson.Get(out, "secrets.nodeSecret").String())
assert.Equal(t, "pwd3", gjson.Get(out, "db.password").String())
dsn := gjson.Get(out, "db.dsn").String()
parsed := cfg.NewDSN(dsn)
assert.Equal(t, "user", parsed.User)
assert.Equal(t, "pwd3", parsed.Password)
assert.Equal(t, "tcp", parsed.Net)
assert.Equal(t, "db:3306", parsed.Server)
assert.Equal(t, "pp_db", parsed.Name)
}
func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Read payload to assert rotate flags
b, _ := io.ReadAll(r.Body)
rotate := gjson.GetBytes(b, "rotate").Bool()
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
// Expect DB rotation only
if !rotate || rotateSecret {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n3", "name": "pp-node-05", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd4", "dsn": "pp_user:pwd4@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
// secrets omitted on DB-only rotate
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_YES", "true")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
defer os.Unsetenv("PHOTOPRISM_YES")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--db", "--yes", "pp-node-05",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-05", gjson.Get(out, "node.name").String())
assert.Equal(t, "pwd4", gjson.Get(out, "db.password").String())
dsn := gjson.Get(out, "db.dsn").String()
parsed := cfg.NewDSN(dsn)
assert.Equal(t, "pp_user", parsed.User)
assert.Equal(t, "pwd4", parsed.Password)
assert.Equal(t, "tcp", parsed.Net)
assert.Equal(t, "db:3306", parsed.Server)
assert.Equal(t, "pp_db", parsed.Name)
assert.Equal(t, "", gjson.Get(out, "secrets.nodeSecret").String())
}
func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
b, _ := io.ReadAll(r.Body)
rotate := gjson.GetBytes(b, "rotate").Bool()
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
// Expect secret-only rotation
if rotate || !rotateSecret {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n4", "name": "pp-node-06", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret4", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--secret", "--yes", "pp-node-06",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-06", gjson.Get(out, "node.name").String())
assert.Equal(t, "secret4", gjson.Get(out, "secrets.nodeSecret").String())
assert.Equal(t, "", gjson.Get(out, "db.password").String())
}
func TestClusterRegister_HTTPUnauthorized(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-unauth", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "wrong", "--json",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 4, ec.ExitCode())
} else {
t.Fatalf("expected ExitCoder, got %T", err)
}
}
func TestClusterRegister_HTTPConflict(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
}))
defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-conflict", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--json",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 5, ec.ExitCode())
} else {
t.Fatalf("expected ExitCoder, got %T", err)
}
}
func TestClusterRegister_HTTPBadRequest(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp node invalid", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--json",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 2, ec.ExitCode())
} else {
t.Fatalf("expected ExitCoder, got %T", err)
}
}
func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) {
calls := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n7", "name": "pp-node-rl", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl", "dsn": "pp_user:pwdrl@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-rl", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--rotate", "--json",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-rl", gjson.Get(out, "node.name").String())
}
func TestClusterNodesRotate_HTTPUnauthorized_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=wrong", "--db", "--yes", "pp-node-x",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 4, ec.ExitCode())
} else {
t.Fatalf("expected ExitCoder, got %T", err)
}
}
func TestClusterNodesRotate_HTTPConflict_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
}))
defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--yes", "pp-node-x",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 5, ec.ExitCode())
} else {
t.Fatalf("expected ExitCoder, got %T", err)
}
}
func TestClusterNodesRotate_HTTPBadRequest_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--yes", "pp node invalid",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 2, ec.ExitCode())
} else {
t.Fatalf("expected ExitCoder, got %T", err)
}
}
func TestClusterNodesRotate_HTTPRateLimitOnceThenOK_JSON(t *testing.T) {
calls := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n8", "name": "pp-node-rl2", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl2", "dsn": "pp_user:pwdrl2@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--yes", "pp-node-rl2",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-rl2", gjson.Get(out, "node.name").String())
}
func TestClusterRegister_RotateDB_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
b, _ := io.ReadAll(r.Body)
if !gjson.GetBytes(b, "rotate").Bool() || gjson.GetBytes(b, "rotateSecret").Bool() {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n5", "name": "pp-node-07", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd7", "dsn": "pp_user:pwd7@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-07", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--rotate", "--json",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-07", gjson.Get(out, "node.name").String())
assert.Equal(t, "pwd7", gjson.Get(out, "db.password").String())
dsn := gjson.Get(out, "db.dsn").String()
parsed := cfg.NewDSN(dsn)
assert.Equal(t, "pp_user", parsed.User)
assert.Equal(t, "pwd7", parsed.Password)
assert.Equal(t, "tcp", parsed.Net)
assert.Equal(t, "db:3306", parsed.Server)
assert.Equal(t, "pp_db", parsed.Name)
}
func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
b, _ := io.ReadAll(r.Body)
if gjson.GetBytes(b, "rotate").Bool() || !gjson.GetBytes(b, "rotateSecret").Bool() {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n6", "name": "pp-node-08", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "pwd8secret", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-08", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--rotate-secret", "--json",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-08", gjson.Get(out, "node.name").String())
assert.Equal(t, "pwd8secret", gjson.Get(out, "secrets.nodeSecret").String())
assert.Equal(t, "", gjson.Get(out, "db.password").String())
}