Files
sultan/pkg/cli/token_test.go
T
brandClaude Opus 5.5 c2d6dd694f sultan token tenant update: Mandanten nachträglich ändern
Weitere Compose-Stacks eines Kunden sind der Regelfall; bisher ließen sich
Domains und Routenlimit nur per delete + neu anlegen (und damit ohne Knoten)
ändern. Die Änderung liegt bewusst nur auf dem Server: Ein Agent darf sich
keine Domains selbst freigeben.

- Domains und Features werden hinzugefügt/entfernt statt ersetzt; unbekannter
  Eintrag beim Entfernen ist ein Fehler, die letzte Domain bleibt
- Verlängerung in die Zukunft setzt expired auf active (der Ablauf-Job setzt
  das nie zurück); Quarantäne und Zahlungsverzug bleiben
- Wechsel auf kommerziell mit vergangenem valid_until verlangt --valid-until

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:32:28 +01:00

664 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}