package cli import ( "bytes" "context" "os" "path/filepath" "strings" "testing" "time" ) func run(ctx context.Context, args ...string) (code int, stdout, stderr string) { var out, errb bytes.Buffer code = Run(ctx, args, &out, &errb) return code, out.String(), errb.String() } func TestRun_Usage(t *testing.T) { code, _, stderr := run(context.Background()) if code != 2 || !strings.Contains(stderr, "sultan server") { t.Errorf("ohne Befehl: code=%d stderr=%q", code, stderr) } code, stdout, _ := run(context.Background(), "help") if code != 0 || !strings.Contains(stdout, "sultan server") || !strings.Contains(stdout, "sultan agent") { t.Errorf("help: code=%d stdout=%q", code, stdout) } code, _, stderr = run(context.Background(), "zaubern") if code != 2 || !strings.Contains(stderr, "zaubern") { t.Errorf("unbekannter Befehl: code=%d stderr=%q", code, stderr) } } func TestRun_Version(t *testing.T) { code, stdout, _ := run(context.Background(), "version") lines := strings.Split(strings.TrimSpace(stdout), "\n") if code != 0 || lines[0] != "sultan "+Version { t.Errorf("version: code=%d stdout=%q", code, stdout) } // Die aktive Zeitzone sichtbar machen (Distroless-Images ohne tzdata fallen still auf UTC). if len(lines) < 2 || lines[1] != "Zeitzone: "+time.Local.String() { t.Errorf("Zeitzone fehlt: %q", stdout) } } func TestServerFlags(t *testing.T) { cfg, err := parseServerFlags(nil, &bytes.Buffer{}, noEnv) if err != nil { t.Fatal(err) } if cfg.DataDir != "./suldata" || cfg.AgentAddr != ":8443" || cfg.ProviderAddr != "127.0.0.1:8080" || cfg.LanderAddr != "127.0.0.1:8081" || cfg.InterceptorURL != "" || cfg.RefreshInterval != time.Minute { t.Errorf("Defaults: %+v", cfg) } cfg, err = parseServerFlags([]string{ "--data-dir=/srv/sul", "--agent-addr", ":9443", "--provider-addr=:8080", "--interceptor-url=http://lander:8081", "--refresh=30s", "--lander-addr=none", "--hoster-name=Beispiel-Hosting", "--hoster-url=https://hoster.example", "--pro-url=https://hoster.example/pro", "--public-url=https://ingress.example:8443", "--public-url=https://100.64.0.1:8443", "--lease-duration=1h", "--notify-slack=https://hooks.example/a", "--notify-slack=https://hooks.example/b", "--notify-ntfy=https://ntfy.example/sultan", "--notify-ntfy-token=tk_x", "--offline-alert-after=10m", }, &bytes.Buffer{}, noEnv) if err != nil { t.Fatal(err) } if cfg.DataDir != "/srv/sul" || cfg.AgentAddr != ":9443" || cfg.ProviderAddr != ":8080" || cfg.InterceptorURL != "http://lander:8081" || cfg.RefreshInterval != 30*time.Second || cfg.LanderAddr != "none" || cfg.HosterName != "Beispiel-Hosting" || cfg.HosterURL != "https://hoster.example" || cfg.ProURL != "https://hoster.example/pro" || strings.Join(cfg.PublicURLs, ",") != "https://ingress.example:8443,https://100.64.0.1:8443" || cfg.LeaseDuration != time.Hour || strings.Join(cfg.SlackWebhooks, ",") != "https://hooks.example/a,https://hooks.example/b" || strings.Join(cfg.NtfyURLs, ",") != "https://ntfy.example/sultan" || cfg.NtfyToken != "tk_x" || cfg.OfflineAlertAfter != 10*time.Minute { t.Errorf("Overrides: %+v", cfg) } if _, err := parseServerFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}, noEnv); err == nil { t.Error("unbekanntes Flag akzeptiert") } if _, err := parseServerFlags([]string{"überzählig"}, &bytes.Buffer{}, noEnv); err == nil { t.Error("überzähliges Argument akzeptiert") } } func TestRun_ServerHelpAndBadFlags(t *testing.T) { code, stdout, stderr := run(context.Background(), "server", "--help") if code != 0 || !strings.Contains(stdout+stderr, "data-dir") { t.Errorf("server --help: code=%d out=%q err=%q", code, stdout, stderr) } code, _, _ = run(context.Background(), "server", "--bogus") if code != 2 { t.Errorf("server --bogus: code=%d", code) } } // Startet den echten Server über die CLI und beendet ihn per Kontext // (wie SIGTERM in main). func TestRun_ServerStartsAndStops(t *testing.T) { dir := filepath.Join(t.TempDir(), "suldata") ctx, cancel := context.WithCancel(context.Background()) type result struct { code int stderr string } done := make(chan result, 1) go func() { code, _, stderr := run(ctx, "server", "--data-dir="+dir, "--agent-addr=127.0.0.1:0", "--provider-addr=127.0.0.1:0", "--lander-addr=127.0.0.1:0") done <- result{code, stderr} }() deadline := time.Now().Add(5 * time.Second) for { if _, err := os.Stat(filepath.Join(dir, "server.crt")); err == nil { break } if time.Now().After(deadline) { t.Fatal("Server hat das Datenverzeichnis nicht angelegt") } time.Sleep(10 * time.Millisecond) } time.Sleep(100 * time.Millisecond) cancel() select { case r := <-done: if r.code != 0 { t.Errorf("Exit-Code %d, stderr:\n%s", r.code, r.stderr) } if !strings.Contains(r.stderr, "Fingerprint") { t.Errorf("Start-Log ohne Fingerprint:\n%s", r.stderr) } case <-time.After(10 * time.Second): t.Fatal("Server endet nicht") } } func TestRun_ServerStartError(t *testing.T) { file := filepath.Join(t.TempDir(), "datei") os.WriteFile(file, nil, 0o600) code, _, stderr := run(context.Background(), "server", "--data-dir="+filepath.Join(file, "sub")) if code != 1 || stderr == "" { t.Errorf("unbrauchbares Datenverzeichnis: code=%d stderr=%q", code, stderr) } } func TestRun_DumpLanding(t *testing.T) { dir := filepath.Join(t.TempDir(), "landing") code, stdout, stderr := run(context.Background(), "dump-landing", "--out="+dir) if code != 0 { t.Fatalf("code=%d stderr=%s", code, stderr) } if !strings.Contains(stdout, "maintenance.html") { t.Errorf("Ausgabe ohne Dateiliste: %q", stdout) } if _, err := os.Stat(filepath.Join(dir, "README.txt")); err != nil { t.Error("README.txt fehlt") } if code, _, stderr := run(context.Background(), "dump-landing", "--out="+dir); code != 1 || !strings.Contains(stderr, "--force") { t.Errorf("ohne --force: code=%d stderr=%q", code, stderr) } if code, _, _ := run(context.Background(), "dump-landing", "--out="+dir, "--force"); code != 0 { t.Errorf("mit --force: code=%d", code) } if code, _, _ := run(context.Background(), "dump-landing", "überzählig"); code != 2 { t.Errorf("überzähliges Argument: code=%d", code) } } func TestDumpLandingDefaultDir(t *testing.T) { out, err := parseDumpFlags(nil, &bytes.Buffer{}) if err != nil || out.dir != "./suldata/landing" || out.force { t.Errorf("Defaults: %+v %v", out, err) } } func envMap(m map[string]string) func(string) string { return func(k string) string { return m[k] } } // Jedes Flag ist auch als SULTAN_ setzbar; Flags gewinnen, Listen // sind kommagetrennt und werden durch ein Flag ersetzt, nicht ergänzt. func TestServerFlags_Environment(t *testing.T) { env := envMap(map[string]string{ "SULTAN_DATA_DIR": "/srv/sul", "SULTAN_PUBLIC_URL": "https://a.example:8443, https://100.64.0.1:8443", "SULTAN_NOTIFY_NTFY_TOKEN": "tk_geheim", "SULTAN_REFRESH": "30s", "SULTAN_ENROLL_LIMIT": "5", }) cfg, err := parseServerFlags(nil, &bytes.Buffer{}, env) if err != nil { t.Fatal(err) } if cfg.DataDir != "/srv/sul" || strings.Join(cfg.PublicURLs, ",") != "https://a.example:8443,https://100.64.0.1:8443" || cfg.NtfyToken != "tk_geheim" || cfg.RefreshInterval != 30*time.Second || cfg.EnrollLimit != 5 { t.Errorf("aus Umgebung: %+v", cfg) } cfg, err = parseServerFlags([]string{"--public-url=https://c.example:8443", "--data-dir=/anders"}, &bytes.Buffer{}, env) if err != nil { t.Fatal(err) } if strings.Join(cfg.PublicURLs, ",") != "https://c.example:8443" || cfg.DataDir != "/anders" { t.Errorf("Flags müssen gewinnen (Listen ersetzen): %v %q", cfg.PublicURLs, cfg.DataDir) } if _, err := parseServerFlags(nil, &bytes.Buffer{}, envMap(map[string]string{"SULTAN_REFRESH": "bald"})); err == nil || !strings.Contains(err.Error(), "SULTAN_REFRESH") { t.Errorf("fehlerhafte Variable nicht benannt: %v", err) } var help bytes.Buffer parseServerFlags([]string{"--help"}, &help, noEnv) if !strings.Contains(help.String(), "SULTAN_PUBLIC_URL") || !strings.Contains(help.String(), "SULTAN_NOTIFY_NTFY_TOKEN") { t.Errorf("Hilfe ohne Variablennamen:\n%s", help.String()) } } func TestServerFlags_EnrollLimit(t *testing.T) { cfg, _ := parseServerFlags(nil, &bytes.Buffer{}, noEnv) if cfg.EnrollLimit != 10 { t.Errorf("Default %d", cfg.EnrollLimit) } cfg, _ = parseServerFlags([]string{"--enroll-limit=-1"}, &bytes.Buffer{}, noEnv) // -1 = unbegrenzt if cfg.EnrollLimit != -1 { t.Errorf("--enroll-limit=-1: %d", cfg.EnrollLimit) } } // Leere Werte (z. B. --public-url=${SULTAN_PUBLIC_URL} mit leerer Variable in // Compose) dürfen den Server nicht am Start hindern. func TestServerFlags_EmptyPublicURLIgnored(t *testing.T) { cfg, err := parseServerFlags([]string{"--public-url=", "--public-url= ", "--notify-slack="}, &bytes.Buffer{}, noEnv) if err != nil { t.Fatal(err) } if len(cfg.PublicURLs) != 0 || len(cfg.SlackWebhooks) != 0 { t.Errorf("leere Werte übernommen: %q %q", cfg.PublicURLs, cfg.SlackWebhooks) } } // Accountant-Anbindung (Spec 5.3): Einladung und Adresse, auch per Umgebung. func TestServerFlags_Accountant(t *testing.T) { cfg, err := parseServerFlags([]string{"--accountant=https://acc.example:8444", "--accountant-token=sltn_enroll_x.y"}, &bytes.Buffer{}, noEnv) if err != nil { t.Fatal(err) } if cfg.AccountantURL != "https://acc.example:8444" || cfg.AccountantToken != "sltn_enroll_x.y" { t.Errorf("Flags: %+v", cfg) } env := envMap(map[string]string{"SULTAN_ACCOUNTANT_TOKEN": "sltn_enroll_env", "SULTAN_ACCOUNTANT": "https://env:8444"}) cfg, err = parseServerFlags(nil, &bytes.Buffer{}, env) if err != nil { t.Fatal(err) } if cfg.AccountantURL != "https://env:8444" || cfg.AccountantToken != "sltn_enroll_env" { t.Errorf("Umgebung: %+v", cfg) } }