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>
664 lines
26 KiB
Go
664 lines
26 KiB
Go
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)
|
||
}
|
||
}
|