2026-07-31 15:41:57 +02:00
|
|
|
package admin_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
"github.com/J3vb/OwnCord/Server/admin"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/config"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/db"
|
2026-07-31 15:41:57 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// wizardRunningCfg mimics the config a fresh server boots with: file defaults
|
|
|
|
|
// plus the runtime-generated LiveKit credentials.
|
|
|
|
|
func wizardRunningCfg() *config.Config {
|
|
|
|
|
return &config.Config{
|
|
|
|
|
Server: config.ServerConfig{Port: 8443, Name: "OwnCord Server"},
|
|
|
|
|
TLS: config.TLSConfig{Mode: "self_signed"},
|
|
|
|
|
Upload: config.UploadConfig{MaxSizeMB: 100},
|
|
|
|
|
Voice: config.VoiceConfig{
|
|
|
|
|
LiveKitAPIKey: "key-generated123",
|
|
|
|
|
LiveKitAPISecret: "generated-secret-0123456789abcdef",
|
|
|
|
|
Quality: "medium",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// wizardHandler builds the admin API with wizard options and a restart stub
|
2026-08-16 08:25:40 +02:00
|
|
|
// that signals restarted (buffered) instead of restarting the process. A
|
|
|
|
|
// wizard run that triggers a restart leaves the process-global
|
|
|
|
|
// restart-serialization guard in restart-pending, so it is reset after every
|
|
|
|
|
// wizard test.
|
2026-07-31 15:41:57 +02:00
|
|
|
func wizardHandler(t *testing.T, database *db.DB, cfgPath string, restarted chan string) http.Handler {
|
|
|
|
|
t.Helper()
|
2026-08-16 08:25:40 +02:00
|
|
|
t.Cleanup(admin.ResetRestartState)
|
2026-08-01 22:06:14 +02:00
|
|
|
return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database),
|
2026-07-31 15:41:57 +02:00
|
|
|
admin.SetupOptions{
|
|
|
|
|
ConfigPath: cfgPath,
|
|
|
|
|
RunningCfg: wizardRunningCfg(),
|
|
|
|
|
Restart: func(reason string) { restarted <- reason },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getSetting(t *testing.T, database *db.DB, key string) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
v, err := database.GetSetting(context.Background(), key)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetSetting(%q): %v", key, err)
|
|
|
|
|
}
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSetupWizard_FullFlow(t *testing.T) {
|
|
|
|
|
database := openAdminTestDB(t)
|
|
|
|
|
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
|
|
|
restarted := make(chan string, 1)
|
|
|
|
|
handler := wizardHandler(t, database, cfgPath, restarted)
|
|
|
|
|
|
|
|
|
|
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
|
|
|
|
|
"username": "owner",
|
|
|
|
|
"password": "SecurePass123!",
|
|
|
|
|
"wizard": map[string]any{
|
|
|
|
|
"server_name": "My Cool Server",
|
|
|
|
|
"motd": "Welcome friends!",
|
|
|
|
|
"registration_open": true,
|
|
|
|
|
"port": 9000,
|
|
|
|
|
"tls_mode": "off",
|
|
|
|
|
"upload_max_size_mb": 250,
|
|
|
|
|
"voice_quality": "high",
|
|
|
|
|
"voice_auto_download": true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
|
|
|
t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var resp struct {
|
|
|
|
|
Token string `json:"token"`
|
|
|
|
|
InviteCode string `json:"invite_code"`
|
|
|
|
|
RestartRequired bool `json:"restart_required"`
|
|
|
|
|
RestartURL string `json:"restart_url"`
|
|
|
|
|
Warnings []string `json:"warnings"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.Token == "" || resp.InviteCode == "" {
|
|
|
|
|
t.Error("token/invite_code missing — account creation should be unchanged")
|
|
|
|
|
}
|
|
|
|
|
if len(resp.Warnings) != 0 {
|
|
|
|
|
t.Errorf("warnings = %v, want none", resp.Warnings)
|
|
|
|
|
}
|
|
|
|
|
if !resp.RestartRequired {
|
|
|
|
|
t.Fatal("restart_required = false, want true (port and tls changed)")
|
|
|
|
|
}
|
|
|
|
|
// httptest requests carry Host "example.com"; tls off → http scheme.
|
|
|
|
|
if resp.RestartURL != "http://example.com:9000/admin" {
|
|
|
|
|
t.Errorf("restart_url = %q, want %q", resp.RestartURL, "http://example.com:9000/admin")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case reason := <-restarted:
|
|
|
|
|
if reason != "setup_wizard" {
|
|
|
|
|
t.Errorf("restart reason = %q, want setup_wizard", reason)
|
|
|
|
|
}
|
|
|
|
|
case <-time.After(5 * time.Second):
|
|
|
|
|
t.Fatal("restart hook was never invoked")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DB settings the app reads live.
|
|
|
|
|
if got := getSetting(t, database, "server_name"); got != "My Cool Server" {
|
|
|
|
|
t.Errorf("server_name = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := getSetting(t, database, "motd"); got != "Welcome friends!" {
|
|
|
|
|
t.Errorf("motd = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := getSetting(t, database, "registration_open"); got != "1" {
|
|
|
|
|
t.Errorf("registration_open = %q, want 1", got)
|
|
|
|
|
}
|
|
|
|
|
if got := getSetting(t, database, "max_upload_bytes"); got != "262144000" {
|
|
|
|
|
t.Errorf("max_upload_bytes = %q, want 262144000 (250 MB)", got)
|
|
|
|
|
}
|
|
|
|
|
if got := getSetting(t, database, "voice_quality"); got != "high" {
|
|
|
|
|
t.Errorf("voice_quality = %q, want high", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// config.yaml written with the wizard values + persisted voice creds.
|
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("loading wizard-written config: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Server.Port != 9000 {
|
|
|
|
|
t.Errorf("config port = %d, want 9000", cfg.Server.Port)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Server.Name != "My Cool Server" {
|
|
|
|
|
t.Errorf("config server name = %q", cfg.Server.Name)
|
|
|
|
|
}
|
|
|
|
|
if cfg.TLS.Mode != "off" {
|
|
|
|
|
t.Errorf("config tls mode = %q, want off", cfg.TLS.Mode)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Upload.MaxSizeMB != 250 {
|
|
|
|
|
t.Errorf("config upload max = %d, want 250", cfg.Upload.MaxSizeMB)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Voice.Quality != "high" {
|
|
|
|
|
t.Errorf("config voice quality = %q, want high", cfg.Voice.Quality)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Voice.LiveKitAPIKey != "key-generated123" {
|
|
|
|
|
t.Errorf("LiveKit key = %q — the running credentials were not persisted", cfg.Voice.LiveKitAPIKey)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Voice.LiveKitAPISecret != "generated-secret-0123456789abcdef" {
|
|
|
|
|
t.Errorf("LiveKit secret = %q — the running credentials were not persisted", cfg.Voice.LiveKitAPISecret)
|
|
|
|
|
}
|
|
|
|
|
if !cfg.Voice.AutoDownloadLiveKit {
|
|
|
|
|
t.Error("config voice.auto_download_livekit = false, want true from wizard toggle")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSetupWizard_NoRestartWhenValuesMatchRunning(t *testing.T) {
|
|
|
|
|
database := openAdminTestDB(t)
|
|
|
|
|
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
|
|
|
restarted := make(chan string, 1)
|
|
|
|
|
handler := wizardHandler(t, database, cfgPath, restarted)
|
|
|
|
|
|
|
|
|
|
// Same port/tls/upload/voice as the running config; only live-read
|
|
|
|
|
// values (name, motd) change.
|
|
|
|
|
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
|
|
|
|
|
"username": "owner",
|
|
|
|
|
"password": "SecurePass123!",
|
|
|
|
|
"wizard": map[string]any{
|
|
|
|
|
"server_name": "Renamed Server",
|
|
|
|
|
"motd": "hi",
|
|
|
|
|
"port": 8443,
|
|
|
|
|
"tls_mode": "self_signed",
|
|
|
|
|
"upload_max_size_mb": 100,
|
|
|
|
|
"voice_quality": "medium",
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
|
|
|
t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var resp struct {
|
|
|
|
|
RestartRequired bool `json:"restart_required"`
|
|
|
|
|
RestartURL string `json:"restart_url"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.RestartRequired {
|
|
|
|
|
t.Error("restart_required = true, want false (no startup-only value changed)")
|
|
|
|
|
}
|
|
|
|
|
if resp.RestartURL != "" {
|
|
|
|
|
t.Errorf("restart_url = %q, want empty", resp.RestartURL)
|
|
|
|
|
}
|
|
|
|
|
select {
|
|
|
|
|
case <-restarted:
|
|
|
|
|
t.Error("restart hook invoked though nothing needed a restart")
|
|
|
|
|
case <-time.After(100 * time.Millisecond):
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Config is still written (server.name changed on disk).
|
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("loading wizard-written config: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Server.Name != "Renamed Server" {
|
|
|
|
|
t.Errorf("config server name = %q, want Renamed Server", cfg.Server.Name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 16:32:07 +02:00
|
|
|
// TestSetupWizard_IdentityFieldsStoredRawNotEscaped pins OC-0173: the wizard
|
|
|
|
|
// must store server_name/motd the same way handlePatchSettings does later
|
|
|
|
|
// (raw survivors, not HTML-entity-escaped), so a name set at first run and
|
|
|
|
|
// the identical name set afterwards through the admin Settings page produce
|
|
|
|
|
// the same stored value. Before the fix, wizardValidateIdentity ran these
|
|
|
|
|
// fields through the bare bluemonday sanitizer, which HTML-escapes
|
|
|
|
|
// survivors (' -> ', " -> ", & -> &) — see service.SanitizeText's
|
|
|
|
|
// doc comment, which the setup_handler.go username path already follows for
|
|
|
|
|
// exactly this reason.
|
|
|
|
|
func TestSetupWizard_IdentityFieldsStoredRawNotEscaped(t *testing.T) {
|
|
|
|
|
database := openAdminTestDB(t)
|
|
|
|
|
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
|
|
|
restarted := make(chan string, 1)
|
|
|
|
|
handler := wizardHandler(t, database, cfgPath, restarted)
|
|
|
|
|
|
|
|
|
|
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
|
|
|
|
|
"username": "owner",
|
|
|
|
|
"password": "SecurePass123!",
|
|
|
|
|
"wizard": map[string]any{
|
|
|
|
|
"server_name": "Bob's Place",
|
|
|
|
|
"motd": `Say "hi" & relax`,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
|
|
|
t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if got, want := getSetting(t, database, "server_name"), "Bob's Place"; got != want {
|
|
|
|
|
t.Errorf("server_name = %q, want %q (stored HTML-escaped instead of raw)", got, want)
|
|
|
|
|
}
|
|
|
|
|
if got, want := getSetting(t, database, "motd"), `Say "hi" & relax`; got != want {
|
|
|
|
|
t.Errorf("motd = %q, want %q (stored HTML-escaped instead of raw)", got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-31 15:41:57 +02:00
|
|
|
func TestSetupWizard_InvalidValuesRejectBeforeAccountCreation(t *testing.T) {
|
|
|
|
|
cases := map[string]map[string]any{
|
|
|
|
|
"port too low": {"port": 0},
|
|
|
|
|
"port too high": {"port": 70000},
|
|
|
|
|
"bad tls mode": {"tls_mode": "quantum"},
|
|
|
|
|
"acme without domain": {"tls_mode": "acme"},
|
|
|
|
|
"bad domain chars": {"tls_mode": "acme", "tls_domain": "not a domain!"},
|
|
|
|
|
"single-label domain": {"tls_mode": "acme", "tls_domain": "localhost"},
|
|
|
|
|
"upload zero": {"upload_max_size_mb": 0},
|
|
|
|
|
"upload too large": {"upload_max_size_mb": 20000},
|
|
|
|
|
"bad voice quality": {"voice_quality": "ultra"},
|
|
|
|
|
"empty server name": {"server_name": " "},
|
|
|
|
|
"tag-only server name": {"server_name": "<b></b>"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for name, wizard := range cases {
|
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
|
|
|
database := openAdminTestDB(t)
|
|
|
|
|
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
|
|
|
restarted := make(chan string, 1)
|
|
|
|
|
handler := wizardHandler(t, database, cfgPath, restarted)
|
|
|
|
|
|
|
|
|
|
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
|
|
|
|
|
"username": "owner",
|
|
|
|
|
"password": "SecurePass123!",
|
|
|
|
|
"wizard": wizard,
|
|
|
|
|
})
|
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("status = %d, want 400; body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
count, err := database.UserCount(context.Background())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UserCount: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if count != 0 {
|
|
|
|
|
t.Errorf("user count = %d, want 0 — invalid wizard payload must reject before account creation", count)
|
|
|
|
|
}
|
|
|
|
|
if _, err := os.Stat(cfgPath); !os.IsNotExist(err) {
|
|
|
|
|
t.Error("config file written despite rejected payload")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSetupWizard_ConfigWriteFailureWarnsButCreatesAccount(t *testing.T) {
|
|
|
|
|
database := openAdminTestDB(t)
|
|
|
|
|
// Point at a directory that does not exist so the atomic write fails.
|
|
|
|
|
cfgPath := filepath.Join(t.TempDir(), "missing-dir", "config.yaml")
|
|
|
|
|
restarted := make(chan string, 1)
|
|
|
|
|
handler := wizardHandler(t, database, cfgPath, restarted)
|
|
|
|
|
|
|
|
|
|
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
|
|
|
|
|
"username": "owner",
|
|
|
|
|
"password": "SecurePass123!",
|
|
|
|
|
"wizard": map[string]any{"port": 9000},
|
|
|
|
|
})
|
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
|
|
|
t.Fatalf("POST /setup = %d, want 201 despite config failure; body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var resp struct {
|
|
|
|
|
Token string `json:"token"`
|
|
|
|
|
RestartRequired bool `json:"restart_required"`
|
|
|
|
|
Warnings []string `json:"warnings"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.Token == "" {
|
|
|
|
|
t.Error("token missing — the account must still be created")
|
|
|
|
|
}
|
|
|
|
|
if len(resp.Warnings) == 0 {
|
|
|
|
|
t.Error("warnings empty, want a config-write warning")
|
|
|
|
|
}
|
|
|
|
|
if resp.RestartRequired {
|
|
|
|
|
t.Error("restart_required = true, but the config was never written — restarting would change nothing")
|
|
|
|
|
}
|
|
|
|
|
select {
|
|
|
|
|
case <-restarted:
|
|
|
|
|
t.Error("restart hook invoked after a failed config write")
|
|
|
|
|
case <-time.After(100 * time.Millisecond):
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
count, err := database.UserCount(context.Background())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UserCount: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if count != 1 {
|
|
|
|
|
t.Errorf("user count = %d, want 1", count)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSetupWizard_LegacyPayloadUnchangedBehaviour(t *testing.T) {
|
|
|
|
|
database := openAdminTestDB(t)
|
|
|
|
|
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
|
|
|
restarted := make(chan string, 1)
|
|
|
|
|
handler := wizardHandler(t, database, cfgPath, restarted)
|
|
|
|
|
|
|
|
|
|
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
|
|
|
|
"username": "owner",
|
|
|
|
|
"password": "SecurePass123!",
|
|
|
|
|
})
|
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
|
|
|
t.Fatalf("legacy POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var resp struct {
|
|
|
|
|
RestartRequired bool `json:"restart_required"`
|
|
|
|
|
Warnings []string `json:"warnings"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.RestartRequired || len(resp.Warnings) != 0 {
|
|
|
|
|
t.Error("legacy payload must not trigger restarts or warnings")
|
|
|
|
|
}
|
|
|
|
|
if _, err := os.Stat(cfgPath); !os.IsNotExist(err) {
|
|
|
|
|
t.Error("legacy payload must not write config.yaml")
|
|
|
|
|
}
|
|
|
|
|
select {
|
|
|
|
|
case <-restarted:
|
|
|
|
|
t.Error("legacy payload must not restart the server")
|
|
|
|
|
case <-time.After(100 * time.Millisecond):
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSetupStatus_DefaultsOnlyPreSetupAndSecretFree(t *testing.T) {
|
|
|
|
|
database := openAdminTestDB(t)
|
|
|
|
|
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
|
|
|
restarted := make(chan string, 1)
|
|
|
|
|
handler := wizardHandler(t, database, cfgPath, restarted)
|
|
|
|
|
|
|
|
|
|
rr := doRequest(t, handler, "GET", "/setup/status", "", nil)
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("GET /setup/status = %d, want 200", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
var resp struct {
|
|
|
|
|
NeedsSetup bool `json:"needs_setup"`
|
|
|
|
|
Defaults *struct {
|
|
|
|
|
ServerName string `json:"server_name"`
|
|
|
|
|
Motd string `json:"motd"`
|
|
|
|
|
Port int `json:"port"`
|
|
|
|
|
TLSMode string `json:"tls_mode"`
|
|
|
|
|
UploadMaxSizeMB int `json:"upload_max_size_mb"`
|
|
|
|
|
VoiceQuality string `json:"voice_quality"`
|
|
|
|
|
} `json:"defaults"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !resp.NeedsSetup || resp.Defaults == nil {
|
|
|
|
|
t.Fatalf("pre-setup status should carry defaults; body=%s", rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
// server_name/motd come from the seeded settings table, the rest from the
|
|
|
|
|
// running config.
|
|
|
|
|
if resp.Defaults.ServerName != "Test Server" {
|
|
|
|
|
t.Errorf("defaults.server_name = %q, want Test Server (DB value)", resp.Defaults.ServerName)
|
|
|
|
|
}
|
|
|
|
|
if resp.Defaults.Motd != "Hello" {
|
|
|
|
|
t.Errorf("defaults.motd = %q, want Hello (DB value)", resp.Defaults.Motd)
|
|
|
|
|
}
|
|
|
|
|
if resp.Defaults.Port != 8443 || resp.Defaults.TLSMode != "self_signed" ||
|
|
|
|
|
resp.Defaults.UploadMaxSizeMB != 100 || resp.Defaults.VoiceQuality != "medium" {
|
|
|
|
|
t.Errorf("config-derived defaults wrong: %+v", resp.Defaults)
|
|
|
|
|
}
|
|
|
|
|
// Never leak credentials through the unauthenticated status endpoint.
|
|
|
|
|
lower := strings.ToLower(rr.Body.String())
|
|
|
|
|
for _, needle := range []string{"livekit", "secret", "api_key", "token", "cidr"} {
|
|
|
|
|
if strings.Contains(lower, needle) {
|
|
|
|
|
t.Errorf("status response leaks %q: %s", needle, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// After setup completes, defaults disappear along with needs_setup.
|
|
|
|
|
rr2 := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
|
|
|
|
"username": "owner", "password": "SecurePass123!",
|
|
|
|
|
})
|
|
|
|
|
if rr2.Code != http.StatusCreated {
|
|
|
|
|
t.Fatalf("setup = %d, want 201", rr2.Code)
|
|
|
|
|
}
|
|
|
|
|
rr3 := doRequest(t, handler, "GET", "/setup/status", "", nil)
|
|
|
|
|
if !strings.Contains(rr3.Body.String(), `"needs_setup":false`) {
|
|
|
|
|
t.Errorf("post-setup status = %s, want needs_setup false", rr3.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(rr3.Body.String(), "defaults") {
|
|
|
|
|
t.Errorf("post-setup status still exposes defaults: %s", rr3.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSetupWizard_ForeignOriginBlocked(t *testing.T) {
|
|
|
|
|
database := openAdminTestDB(t)
|
|
|
|
|
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
|
|
|
restarted := make(chan string, 1)
|
|
|
|
|
handler := wizardHandler(t, database, cfgPath, restarted)
|
|
|
|
|
|
|
|
|
|
body := map[string]any{
|
|
|
|
|
"username": "owner",
|
|
|
|
|
"password": "SecurePass123!",
|
|
|
|
|
"wizard": map[string]any{"port": 9000},
|
|
|
|
|
}
|
|
|
|
|
raw, err := json.Marshal(body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("marshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
req := httptest.NewRequest("POST", "/setup", bytes.NewReader(raw))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
req.Header.Set("Origin", "https://evil.example")
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
|
|
|
t.Fatalf("wizard POST from foreign origin = %d, want 403", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
if _, err := os.Stat(cfgPath); !os.IsNotExist(err) {
|
|
|
|
|
t.Error("config file written from a cross-origin request")
|
|
|
|
|
}
|
|
|
|
|
}
|