package cli import ( "context" "crypto/ed25519" "encoding/json" "log/slog" "os" "path/filepath" "strings" "testing" "time" "sultan/pkg/auth" "sultan/pkg/crypto" "sultan/pkg/license" "sultan/pkg/server" "sultan/pkg/store" ) // initDataDir initialisiert ein Datenverzeichnis so, wie es der erste // Serverstart tut. func initDataDir(t *testing.T) string { t.Helper() dir := filepath.Join(t.TempDir(), "suldata") s, err := server.New(context.Background(), server.Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)}) if err != nil { t.Fatal(err) } s.Shutdown(context.Background()) return dir } func openStore(t *testing.T, dir string) *store.Store { t.Helper() st, err := store.Open(context.Background(), filepath.Join(dir, "sultan.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) return st } // tok ruft `sultan token …` mit --data-dir auf. func tok(t *testing.T, dir string, args ...string) (int, string, string) { t.Helper() full := append([]string{"token"}, args...) // --data-dir hinter den Unterbefehl hängen (z. B. "tenant add --data-dir=…"). full = append(full, "--data-dir="+dir) return run(context.Background(), full...) } func mustTok(t *testing.T, dir string, args ...string) string { t.Helper() code, stdout, stderr := tok(t, dir, args...) if code != 0 { t.Fatalf("sultan token %s: code=%d stderr=%s", strings.Join(args, " "), code, stderr) } return stdout } func addTenant(t *testing.T, dir, id string, extra ...string) { t.Helper() mustTok(t, dir, append([]string{"tenant", "add", "--id=" + id, "--name=Kunde " + id, "--email=" + id + "@example.com", "--domains=" + id + ".de,*." + id + ".de"}, extra...)...) } func TestToken_Usage(t *testing.T) { code, _, stderr := run(context.Background(), "token") if code != 2 { t.Errorf("ohne Unterbefehl: code=%d", code) } for _, want := range []string{"tenant add", "tenant update", "invite", "node list", "node maintenance", "node resume", "license issue", "purge"} { if !strings.Contains(stderr, want) { t.Errorf("Hilfe ohne %q:\n%s", want, stderr) } } if code, _, _ := run(context.Background(), "token", "zaubern"); code != 2 { t.Errorf("unbekannter Unterbefehl: code=%d", code) } if code, _, _ := run(context.Background(), "token", "tenant"); code != 2 { t.Errorf("tenant ohne Aktion: code=%d", code) } } // Ein Tippfehler bei --data-dir darf keine neue Server-Identität erzeugen. func TestToken_RequiresInitializedDataDir(t *testing.T) { dir := filepath.Join(t.TempDir(), "vertippt") code, _, stderr := tok(t, dir, "tenant", "list") if code != 1 || !strings.Contains(stderr, "nicht initialisiert") { t.Errorf("code=%d stderr=%q", code, stderr) } if _, err := os.Stat(dir); err == nil { t.Error("Datenverzeichnis wurde angelegt") } } func TestToken_TenantAddAndList(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde", "--tier=pro", "--max-routes=20", "--valid-until=2027-12-31") st := openStore(t, dir) tn, err := st.GetTenant(context.Background(), "kunde") if err != nil { t.Fatal(err) } if tn.Tier != store.TierPro || tn.MaxRoutes != 20 || tn.Status != store.TenantActive || !tn.ValidUntil.Equal(time.Date(2027, 12, 31, 0, 0, 0, 0, time.UTC)) || strings.Join(tn.AllowedDomains, ",") != "kunde.de,*.kunde.de" { t.Errorf("gespeichert: %+v", tn) } out := mustTok(t, dir, "tenant", "list") for _, want := range []string{"ID", "kunde", "pro", "active", "2027-12-31", "*.kunde.de"} { if !strings.Contains(out, want) { t.Errorf("Liste ohne %q:\n%s", want, out) } } } func TestToken_TenantAddDefaults(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "homelab") tn, _ := openStore(t, dir).GetTenant(context.Background(), "homelab") if tn.Tier != store.TierCommunity || tn.MaxRoutes != 5 { t.Errorf("Defaults: tier=%s max_routes=%d", tn.Tier, tn.MaxRoutes) } if d := time.Until(tn.ValidUntil); d < 364*24*time.Hour || d > 366*24*time.Hour { t.Errorf("Default-Laufzeit: %v", d) } } func TestToken_TenantAddDomainsNormalized(t *testing.T) { dir := initDataDir(t) mustTok(t, dir, "tenant", "add", "--id=k", "--name=K", "--email=k@x.de", "--domains= Shop.DE. , *.Firma.de") tn, _ := openStore(t, dir).GetTenant(context.Background(), "k") if strings.Join(tn.AllowedDomains, ",") != "shop.de,*.firma.de" { t.Errorf("Domains: %v", tn.AllowedDomains) } } func TestToken_TenantAddErrors(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde") tests := []struct { name string args []string code int }{ {"doppelt", []string{"tenant", "add", "--id=kunde", "--name=K", "--email=k@x.de", "--domains=k.de"}, 1}, {"ID fehlt", []string{"tenant", "add", "--name=K", "--email=k@x.de", "--domains=k.de"}, 2}, {"Domains fehlen", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de"}, 2}, {"Domain ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=*.de"}, 1}, {"ID ungültig", []string{"tenant", "add", "--id=a__b", "--name=K", "--email=k@x.de", "--domains=k.de"}, 1}, {"Tier ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=k.de", "--tier=gold"}, 1}, {"Datum ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=k.de", "--valid-until=morgen"}, 2}, } for _, tt := range tests { if code, _, stderr := tok(t, dir, tt.args...); code != tt.code { t.Errorf("%s: code=%d (erwartet %d) stderr=%q", tt.name, code, tt.code, stderr) } } } func TestToken_TenantSetStatusAndDelete(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde") mustTok(t, dir, "tenant", "set-status", "--id=kunde", "--status=suspended_payment") tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") if tn.Status != store.TenantSuspendedPayment { t.Errorf("Status = %s", tn.Status) } if code, _, _ := tok(t, dir, "tenant", "set-status", "--id=kunde", "--status=pausiert"); code != 1 { t.Errorf("ungültiger Status: code=%d", code) } if code, _, _ := tok(t, dir, "tenant", "set-status", "--id=fehlt", "--status=active"); code != 1 { t.Errorf("unbekannter Mandant: code=%d", code) } mustTok(t, dir, "tenant", "delete", "--id=kunde") if _, err := openStore(t, dir).GetTenant(context.Background(), "kunde"); err == nil { t.Error("Mandant nicht gelöscht") } if code, _, _ := tok(t, dir, "tenant", "delete", "--id=kunde"); code != 1 { t.Errorf("zweites Löschen: code=%d", code) } } // Die Einladung muss den Fingerprint des echten Server-Zertifikats tragen // und beim Server einlösbar sein. func TestToken_Invite(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde") code, stdout, stderr := tok(t, dir, "invite", "--tenant=kunde") if code != 0 { t.Fatalf("invite: code=%d stderr=%s", code, stderr) } lines := strings.Split(strings.TrimSpace(stdout), "\n") if len(lines) != 1 { t.Fatalf("stdout muss genau den Token enthalten: %q", stdout) } inv, err := crypto.ParseEnrollmentToken(lines[0]) if err != nil { t.Fatalf("Token nicht lesbar: %v", err) } cert, _ := crypto.LoadOrCreateServerCert(dir) if inv.Fingerprint != cert.Fingerprint { t.Error("Token trägt nicht den Fingerprint des Server-Zertifikats") } if !strings.Contains(stderr, "kunde") { t.Errorf("Hinweis auf stderr fehlt: %q", stderr) } st := openStore(t, dir) if _, err := st.Enroll(context.Background(), inv.Secret, store.Node{ID: "n1", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}); err != nil { t.Errorf("Token nicht einlösbar: %v", err) } } func TestToken_InviteErrors(t *testing.T) { dir := initDataDir(t) if code, _, _ := tok(t, dir, "invite", "--tenant=fehlt"); code != 1 { t.Errorf("unbekannter Mandant: code=%d", code) } if code, _, _ := tok(t, dir, "invite"); code != 2 { t.Errorf("ohne --tenant: code=%d", code) } addTenant(t, dir, "kunde") for _, ttl := range []string{"--ttl=0s", "--ttl=-1h", "--ttl=bald"} { if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", ttl); code != 2 { t.Errorf("%s: code=%d", ttl, code) } } if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", "--ttl=7d"); code != 0 { t.Errorf("--ttl=7d (Tage) nicht akzeptiert: code=%d", code) } } func TestToken_Nodes(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde") st := openStore(t, dir) if _, err := st.CreateNode(context.Background(), store.Node{ID: "node-1", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "192.168.1.20"}); err != nil { t.Fatal(err) } out := mustTok(t, dir, "node", "list") for _, want := range []string{"ID", "node-1", "kunde", "pi", "online", "192.168.1.20"} { if !strings.Contains(out, want) { t.Errorf("Liste ohne %q:\n%s", want, out) } } if out := mustTok(t, dir, "node", "list", "--tenant=andere"); strings.Contains(out, "node-1") { t.Errorf("Filter nach Mandant greift nicht:\n%s", out) } mustTok(t, dir, "node", "drain", "--id=node-1") if n, _ := st.GetNode(context.Background(), "node-1"); n.Status != store.NodeDrained { t.Errorf("drain: %s", n.Status) } mustTok(t, dir, "node", "resume", "--id=node-1") if n, _ := st.GetNode(context.Background(), "node-1"); n.Status != store.NodeOffline { t.Errorf("resume nach drain: %s (erwartet offline; online setzt erst der Stream)", n.Status) } mustTok(t, dir, "node", "delete", "--id=node-1") if _, err := st.GetNode(context.Background(), "node-1"); err == nil { t.Error("Knoten nicht gelöscht") } if code, _, _ := tok(t, dir, "node", "drain", "--id=fehlt"); code != 1 { t.Errorf("unbekannter Knoten: code=%d", code) } } func TestToken_LicenseIssue(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde", "--tier=enterprise", "--max-routes=-1", "--valid-until=2027-06-30") stdout := mustTok(t, dir, "license", "issue", "--tenant=kunde") master, err := auth.ReadPrivateKey(filepath.Join(dir, "master.key")) if err != nil { t.Fatal(err) } l, err := license.Parse([]byte(stdout), master.Public().(ed25519.PublicKey)) if err != nil { t.Fatalf("Lizenz ungültig: %v\n%s", err, stdout) } if l.TenantID != "kunde" || l.Tier != license.TierEnterprise || l.MaxRoutes != -1 || !l.AllowsDomain("www.kunde.de") { t.Errorf("Lizenz: %+v", l) } if !json.Valid([]byte(stdout)) { t.Error("stdout ist kein reines JSON") } if code, _, _ := tok(t, dir, "license", "issue", "--tenant=fehlt"); code != 1 { t.Errorf("unbekannter Mandant: code=%d", code) } } func TestToken_Purge(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde") inv := strings.TrimSpace(mustTok(t, dir, "invite", "--tenant=kunde")) parsed, _ := crypto.ParseEnrollmentToken(inv) st := openStore(t, dir) st.Enroll(context.Background(), parsed.Secret, store.Node{ID: "n1", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}) out := mustTok(t, dir, "purge") if !strings.Contains(out, "1") { t.Errorf("purge-Ausgabe: %q", out) } } // Funktioniert auch, während der Server läuft (gleiche SQLite-DB, WAL). func TestToken_WhileServerRuns(t *testing.T) { dir := initDataDir(t) s, err := server.New(context.Background(), server.Config{ DataDir: dir, AgentAddr: "127.0.0.1:0", ProviderAddr: "127.0.0.1:0", LanderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler), }) if err != nil { t.Fatal(err) } if err := s.Start(); err != nil { t.Fatal(err) } defer s.Shutdown(context.Background()) addTenant(t, dir, "kunde") inv := strings.TrimSpace(mustTok(t, dir, "invite", "--tenant=kunde")) parsed, err := crypto.ParseEnrollmentToken(inv) if err != nil || parsed.Fingerprint != s.Fingerprint() { t.Errorf("Token passt nicht zum laufenden Server: %v", err) } } func setPublicURL(t *testing.T, dir, url string) { t.Helper() s, err := server.New(context.Background(), server.Config{DataDir: dir, PublicURLs: []string{url, "https://100.64.0.1:8443"}, Logger: slog.New(slog.DiscardHandler)}) if err != nil { t.Fatal(err) } s.Shutdown(context.Background()) } func TestToken_InviteEmbedsPublicURL(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde") // Ohne öffentliche Adresse: Token ohne URL, Hinweis auf --public-url. code, stdout, stderr := tok(t, dir, "invite", "--tenant=kunde") inv, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)) if code != 0 || inv.ServerURL != "" || !strings.Contains(stderr, "--public-url") { t.Errorf("ohne Adresse: code=%d url=%q stderr=%q", code, inv.ServerURL, stderr) } setPublicURL(t, dir, "https://ingress.example:8443") code, stdout, stderr = tok(t, dir, "invite", "--tenant=kunde") inv, err := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)) if code != 0 || err != nil || inv.ServerURL != "https://ingress.example:8443" { t.Errorf("mit gemerkter Adresse: code=%d %+v %v", code, inv, err) } if !strings.Contains(stderr, "SULTAN_TOKEN=") { t.Errorf("Hinweis ohne fertige Befehlszeile: %q", stderr) } // Pro Einladung überschreibbar, z. B. NetBird-Adresse für diesen Knoten. _, stdout, _ = tok(t, dir, "invite", "--tenant=kunde", "--server-url=https://100.64.0.1:8443") if inv, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)); inv.ServerURL != "https://100.64.0.1:8443" { t.Errorf("--server-url: %+v", inv) } if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", "--server-url=http://x:8443"); code != 2 { t.Errorf("ungültige --server-url: code=%d", code) } } func TestToken_NodeMaintenance(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde") st := openStore(t, dir) st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"}) out := mustTok(t, dir, "node", "maintenance", "--id=pi", "--for=2h", "--reason=Kernel-Update", "--public-reason=Sicherheitsupdate") if !strings.Contains(out, "pi") { t.Errorf("Ausgabe: %q", out) } n, _ := st.GetNode(context.Background(), "pi") if m := n.Maintenance; m == nil || m.Reason != "Kernel-Update" || m.PublicReason != "Sicherheitsupdate" || time.Until(m.Until) < 119*time.Minute || time.Until(m.Until) > 121*time.Minute { t.Errorf("gespeichert: %+v", n.Maintenance) } if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "MAINTENANCE") || !strings.Contains(list, "bis ") { t.Errorf("Liste ohne Wartung:\n%s", list) } mustTok(t, dir, "node", "maintenance", "--id=pi") // Default 4h if n, _ := st.GetNode(context.Background(), "pi"); time.Until(n.Maintenance.Until) < 239*time.Minute { t.Errorf("Default-Dauer: %v", time.Until(n.Maintenance.Until)) } mustTok(t, dir, "node", "maintenance", "--id=pi", "--for=0") if n, _ := st.GetNode(context.Background(), "pi"); !n.Maintenance.Until.IsZero() { t.Error("--for=0 nicht unbefristet") } if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "unbefristet") { t.Errorf("Liste:\n%s", list) } mustTok(t, dir, "node", "resume", "--id=pi") if n, _ := st.GetNode(context.Background(), "pi"); n.Maintenance != nil { t.Error("resume beendet die Wartung nicht") } for _, args := range [][]string{ {"node", "maintenance", "--id=pi", "--for=bald"}, {"node", "maintenance", "--id=pi", "--for=-1h"}, {"node", "maintenance"}, } { if code, _, _ := tok(t, dir, args...); code != 2 { t.Errorf("%v: code=%d", args, code) } } if code, _, _ := tok(t, dir, "node", "maintenance", "--id=fehlt"); code != 1 { t.Errorf("unbekannter Knoten: code=%d", code) } } func TestToken_NodeCommand(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde") st := openStore(t, dir) st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"}) mustTok(t, dir, "node", "command", "--id=pi", "--cmd=rotate_key", "--reason=Routine") mustTok(t, dir, "node", "drain", "--id=pi") // erzeugt zusätzlich einen Hinweis an den Agent cmds, _ := st.PendingNodeCommands(context.Background(), "pi") if len(cmds) != 2 || cmds[0].Command != "rotate_key" || cmds[0].Reason != "Routine" || cmds[1].Command != "drain" { t.Errorf("Warteschlange: %+v", cmds) } if code, _, _ := tok(t, dir, "node", "command", "--id=pi", "--cmd=rm -rf /"); code != 2 { t.Errorf("beliebiger Befehl: code=%d", code) } if code, _, _ := tok(t, dir, "node", "command", "--id=fehlt", "--cmd=resync"); code != 1 { t.Errorf("unbekannter Knoten: code=%d", code) } } func TestToken_NodeListShowsLease(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde") st := openStore(t, dir) st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"}) if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "LEASE") { t.Errorf("Spalte LEASE fehlt:\n%s", list) } st.PutLease(context.Background(), store.Lease{NodeID: "pi", IssuedAt: time.Now(), RenewAfter: time.Now().Add(time.Hour), ValidUntil: time.Date(2030, 1, 2, 3, 4, 0, 0, time.Local), Document: "{}"}) if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "2030-01-02 03:04") { t.Errorf("Lease-Ende fehlt:\n%s", list) } } func TestToken_TenantAddFeatures(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde", "--features=custom_middleware,websockets") tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") if strings.Join(tn.Features, ",") != "custom_middleware,websockets" { t.Errorf("Features: %v", tn.Features) } if code, _, _ := tok(t, dir, "tenant", "add", "--id=x", "--name=x", "--email=x@x.de", "--domains=x.de", "--features=teleport"); code != 1 { t.Errorf("unbekanntes Feature: code=%d", code) } } func TestToken_TenantPayment(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde", "--tier=pro") st := openStore(t, dir) mustTok(t, dir, "tenant", "payment", "--id=kunde", "--overdue-since=2026-09-01") if tn, _ := st.GetTenant(context.Background(), "kunde"); !tn.PaymentOverdueSince.Equal(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) { t.Errorf("offen seit: %v", tn.PaymentOverdueSince) } if list := mustTok(t, dir, "tenant", "list"); !strings.Contains(list, "PAYMENT") || !strings.Contains(list, "offen seit 2026-09-01") { t.Errorf("Liste ohne Zahlungsstatus:\n%s", list) } mustTok(t, dir, "tenant", "payment", "--id=kunde", "--paid") if tn, _ := st.GetTenant(context.Background(), "kunde"); !tn.PaymentOverdueSince.IsZero() { t.Error("--paid setzt nicht zurück") } for _, args := range [][]string{ {"tenant", "payment", "--id=kunde"}, {"tenant", "payment", "--id=kunde", "--paid", "--overdue-since=2026-09-01"}, {"tenant", "payment", "--id=kunde", "--overdue-since=gestern"}, } { if code, _, _ := tok(t, dir, args...); code != 2 { t.Errorf("%v: code=%d", args, code) } } if code, _, _ := tok(t, dir, "tenant", "payment", "--id=fehlt", "--paid"); code != 1 { t.Errorf("unbekannter Mandant: code=%d", code) } } func TestToken_DataDirFromEnvironment(t *testing.T) { dir := initDataDir(t) t.Setenv("SULTAN_DATA_DIR", dir) if code, _, stderr := run(context.Background(), "token", "tenant", "list"); code != 0 { t.Errorf("code=%d stderr=%s", code, stderr) } } func TestToken_TenantUpdateDomains(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde") // Normalisiert anfügen, Vorhandenes nicht doppelt, Reihenfolge bleibt. mustTok(t, dir, "tenant", "update", "--id=kunde", "--add-domains= Neu.DE. ,kunde.de,*.neu.de") tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") if got := strings.Join(tn.AllowedDomains, ","); got != "kunde.de,*.kunde.de,neu.de,*.neu.de" { t.Errorf("nach add: %s", got) } out := mustTok(t, dir, "tenant", "update", "--id=kunde", "--remove-domains=NEU.de,*.neu.de") tn, _ = openStore(t, dir).GetTenant(context.Background(), "kunde") if got := strings.Join(tn.AllowedDomains, ","); got != "kunde.de,*.kunde.de" { t.Errorf("nach remove: %s", got) } if !strings.Contains(out, "Routen") { t.Errorf("kein Hinweis auf wegfallende Routen:\n%s", out) } // Tausch in einem Aufruf: erst entfernen, dann hinzufügen. mustTok(t, dir, "tenant", "update", "--id=kunde", "--remove-domains=kunde.de,*.kunde.de", "--add-domains=anders.de") tn, _ = openStore(t, dir).GetTenant(context.Background(), "kunde") if got := strings.Join(tn.AllowedDomains, ","); got != "anders.de" { t.Errorf("nach Tausch: %s", got) } } // Was nicht angegeben ist, bleibt – auch Status, offene Zahlung und Features. func TestToken_TenantUpdateKeepsOtherFields(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde", "--tier=pro", "--max-routes=20", "--valid-until=2099-12-31", "--features=websockets") mustTok(t, dir, "tenant", "payment", "--id=kunde", "--overdue-since=2026-01-15") mustTok(t, dir, "tenant", "set-status", "--id=kunde", "--status=suspended_payment") mustTok(t, dir, "tenant", "update", "--id=kunde", "--add-domains=neu.de") tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") if tn.Name != "Kunde kunde" || tn.Email != "kunde@example.com" || tn.Tier != store.TierPro || tn.MaxRoutes != 20 || tn.Status != store.TenantSuspendedPayment || tn.PaymentOverdueSince.IsZero() || !tn.ValidUntil.Equal(time.Date(2099, 12, 31, 0, 0, 0, 0, time.UTC)) || strings.Join(tn.Features, ",") != "websockets" { t.Errorf("Felder verändert: %+v", tn) } } func TestToken_TenantUpdateFields(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde", "--features=websockets") out := mustTok(t, dir, "tenant", "update", "--id=kunde", "--name=Neu GmbH", "--email=neu@x.de", "--tier=pro", "--max-routes=-1", "--valid-until=2099-06-30", "--add-features=tcp_stream,websockets", "--remove-features=websockets") tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") if tn.Name != "Neu GmbH" || tn.Email != "neu@x.de" || tn.Tier != store.TierPro || tn.MaxRoutes != -1 || !tn.ValidUntil.Equal(time.Date(2099, 6, 30, 0, 0, 0, 0, time.UTC)) { t.Errorf("gespeichert: %+v", tn) } if got := strings.Join(tn.Features, ","); got != "tcp_stream,websockets" { t.Errorf("Features: %s", got) } // Die Ausgabe zeigt den neuen Stand, auch das, was tenant list nicht zeigt. for _, want := range []string{"kunde", "pro", "2099-06-30", "unbegrenzt", "tcp_stream", "kunde.de"} { if !strings.Contains(out, want) { t.Errorf("Ausgabe ohne %q:\n%s", want, out) } } // max-routes=0 ist ein gültiger Wert und nicht „nicht angegeben“. mustTok(t, dir, "tenant", "update", "--id=kunde", "--max-routes=0") tn, _ = openStore(t, dir).GetTenant(context.Background(), "kunde") if tn.MaxRoutes != 0 { t.Errorf("max_routes = %d", tn.MaxRoutes) } } // Verlängern nach Vertragsende: Sonst bliebe die Seite trotz neuem Datum auf // „Hosting abgelaufen“, denn der Job setzt expired nie zurück. func TestToken_TenantUpdateExtensionReactivates(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde", "--tier=pro", "--valid-until=2020-01-01") mustTok(t, dir, "tenant", "set-status", "--id=kunde", "--status=expired") // Andere Änderungen und ein Datum in der Vergangenheit reaktivieren nicht. mustTok(t, dir, "tenant", "update", "--id=kunde", "--add-domains=neu.de") mustTok(t, dir, "tenant", "update", "--id=kunde", "--valid-until=2021-01-01") tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") if tn.Status != store.TenantExpired { t.Errorf("Status = %s, erwartet expired", tn.Status) } out := mustTok(t, dir, "tenant", "update", "--id=kunde", "--valid-until=2099-01-01") tn, _ = openStore(t, dir).GetTenant(context.Background(), "kunde") if tn.Status != store.TenantActive { t.Errorf("Status = %s, erwartet active", tn.Status) } if !strings.Contains(out, "active") { t.Errorf("Reaktivierung nicht gemeldet:\n%s", out) } // Quarantäne ist eine bewusste Sperre und bleibt. mustTok(t, dir, "tenant", "set-status", "--id=kunde", "--status=quarantined") mustTok(t, dir, "tenant", "update", "--id=kunde", "--valid-until=2099-02-01") tn, _ = openStore(t, dir).GetTenant(context.Background(), "kunde") if tn.Status != store.TenantQuarantined { t.Errorf("Status = %s, erwartet quarantined", tn.Status) } } // Community prüft valid_until nicht; ein altes Datum würde beim Wechsel auf // ein kommerzielles Paket die Seiten sofort abschalten. func TestToken_TenantUpdateCommercialNeedsFutureDate(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde", "--valid-until=2020-01-01") if code, _, stderr := tok(t, dir, "tenant", "update", "--id=kunde", "--tier=pro"); code != 1 || !strings.Contains(stderr, "--valid-until") { t.Errorf("code=%d stderr=%q", code, stderr) } tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") if tn.Tier != store.TierCommunity { t.Errorf("Tier trotz Fehler geändert: %s", tn.Tier) } mustTok(t, dir, "tenant", "update", "--id=kunde", "--tier=pro", "--valid-until=2099-01-01") } func TestToken_TenantUpdateErrors(t *testing.T) { dir := initDataDir(t) addTenant(t, dir, "kunde") tests := []struct { name string args []string code int }{ {"ID fehlt", []string{"--add-domains=a.de"}, 2}, {"keine Änderung", []string{"--id=kunde"}, 2}, {"unbekannter Mandant", []string{"--id=fehlt", "--add-domains=a.de"}, 1}, {"Domain ungültig", []string{"--id=kunde", "--add-domains=*.de"}, 1}, {"Tippfehler beim Entfernen", []string{"--id=kunde", "--remove-domains=kunde.dee"}, 1}, {"letzte Domain", []string{"--id=kunde", "--remove-domains=kunde.de,*.kunde.de"}, 1}, {"Feature unbekannt", []string{"--id=kunde", "--add-features=teleport"}, 1}, {"Feature nicht vorhanden", []string{"--id=kunde", "--remove-features=websockets"}, 1}, {"Tier ungültig", []string{"--id=kunde", "--tier=gold"}, 1}, {"Routen keine Zahl", []string{"--id=kunde", "--max-routes=viele"}, 2}, {"Routen zu klein", []string{"--id=kunde", "--max-routes=-2"}, 1}, {"Datum ungültig", []string{"--id=kunde", "--valid-until=morgen"}, 2}, } before, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") for _, tt := range tests { args := append([]string{"tenant", "update"}, tt.args...) if code, _, stderr := tok(t, dir, args...); code != tt.code { t.Errorf("%s: code=%d (erwartet %d) stderr=%q", tt.name, code, tt.code, stderr) } } after, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") if strings.Join(after.AllowedDomains, ",") != strings.Join(before.AllowedDomains, ",") || after.MaxRoutes != before.MaxRoutes { t.Errorf("trotz Fehler geändert: %+v", after) } }