erste module drin.

zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
br committed 2026-10-01 22:22:13 +02:00
1 parent 9d5789c5d7
commit c8df9d7651
41 files changed
+6455 -36

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// NodeStatus ist der Verbindungsstatus eines Agenten.
type NodeStatus string
const (
NodeOnline NodeStatus = "online"
NodeOffline NodeStatus = "offline"
NodeDrained NodeStatus = "drained"
)
func (s NodeStatus) valid() bool {
return s == NodeOnline || s == NodeOffline || s == NodeDrained
}
// Node ist ein registrierter Agent.
type Node struct {
ID string
TenantID string
Hostname string
PublicKey string // Ed25519 Public Key, vom Aufrufer kodiert (z. B. Base64)
LastSeen time.Time
IPAddress string
Status NodeStatus
}
func (n *Node) normalize(now time.Time) error {
if n.Status == "" {
n.Status = NodeOnline
}
if n.LastSeen.IsZero() {
n.LastSeen = now
}
n.LastSeen = normTime(n.LastSeen)
switch {
case blank(n.ID):
return invalid("Knoten-ID fehlt")
case blank(n.TenantID):
return invalid("Mandant fehlt")
case blank(n.Hostname):
return invalid("Hostname fehlt")
case blank(n.PublicKey):
return invalid("Public Key fehlt")
case blank(n.IPAddress):
return invalid("IP-Adresse fehlt")
case !n.Status.valid():
return invalid("unbekannter Status %q", n.Status)
}
return nil
}
// CreateNode legt einen Knoten an. Status ist ohne Angabe online, LastSeen
// ohne Angabe jetzt. ID und Public Key müssen eindeutig sein (ErrExists), der
// Mandant muss existieren (ErrNotFound).
func (s *Store) CreateNode(ctx context.Context, n Node) (Node, error) {
return s.insertNode(ctx, s.db, n)
}
func (s *Store) insertNode(ctx context.Context, db execer, n Node) (Node, error) {
if err := n.normalize(s.now()); err != nil {
return Node{}, err
}
_, err := db.ExecContext(ctx, `INSERT INTO nodes
(id, tenant_id, hostname, public_key, last_seen, ip_address, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
n.ID, n.TenantID, n.Hostname, n.PublicKey, formatTime(n.LastSeen), n.IPAddress, n.Status)
if err != nil {
return Node{}, mapConstraint(err, "Knoten "+n.ID)
}
return n, nil
}
const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status`
func scanNode(row interface{ Scan(...any) error }) (Node, error) {
var n Node
err := row.Scan(&n.ID, &n.TenantID, &n.Hostname, &n.PublicKey, dbTime{&n.LastSeen}, &n.IPAddress, &n.Status)
return n, err
}
func (s *Store) getNode(ctx context.Context, where, arg, what string) (Node, error) {
n, err := scanNode(s.db.QueryRowContext(ctx, `SELECT `+nodeColumns+` FROM nodes WHERE `+where+` = ?`, arg))
if errors.Is(err, sql.ErrNoRows) {
return Node{}, fmt.Errorf("%s: %w", what, ErrNotFound)
}
return n, err
}
// GetNode liest einen Knoten über seine ID.
func (s *Store) GetNode(ctx context.Context, id string) (Node, error) {
return s.getNode(ctx, "id", id, "Knoten "+id)
}
// GetNodeByPublicKey liest einen Knoten über seinen Public Key (für die
// Prüfung signierter Requests).
func (s *Store) GetNodeByPublicKey(ctx context.Context, publicKey string) (Node, error) {
return s.getNode(ctx, "public_key", publicKey, "Knoten mit diesem Public Key")
}
// ListNodes liefert die Knoten eines Mandanten (tenantID "" = alle), sortiert nach ID.
func (s *Store) ListNodes(ctx context.Context, tenantID string) ([]Node, error) {
q, args := `SELECT `+nodeColumns+` FROM nodes ORDER BY id`, []any{}
if tenantID != "" {
q, args = `SELECT `+nodeColumns+` FROM nodes WHERE tenant_id = ? ORDER BY id`, []any{tenantID}
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
nodes := []Node{}
for rows.Next() {
n, err := scanNode(rows)
if err != nil {
return nil, err
}
nodes = append(nodes, n)
}
return nodes, rows.Err()
}
// TouchNode vermerkt ein Lebenszeichen: last_seen = jetzt und die aktuelle
// IP. Der Status bleibt unverändert (ein gedrainter Knoten bleibt gedraint).
func (s *Store) TouchNode(ctx context.Context, id, ip string) error {
if blank(ip) {
return invalid("IP-Adresse fehlt")
}
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET last_seen = ?, ip_address = ? WHERE id = ?`,
formatTime(s.now()), ip, id)
return requireAffected(res, err, "Knoten "+id)
}
// SetNodeStatus ändert den Status eines Knotens.
func (s *Store) SetNodeStatus(ctx context.Context, id string, status NodeStatus) error {
if !status.valid() {
return invalid("unbekannter Status %q", status)
}
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET status = ? WHERE id = ?`, status, id)
return requireAffected(res, err, "Knoten "+id)
}
// DeleteNode löscht einen Knoten; sein Public Key wird damit wieder frei.
func (s *Store) DeleteNode(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id)
return requireAffected(res, err, "Knoten "+id)
}
+226
View File
@@ -0,0 +1,226 @@
package store
import (
"errors"
"reflect"
"testing"
"time"
)
func sampleNode(id, publicKey string) Node {
return Node{
ID: id,
Hostname: "host-" + id,
PublicKey: publicKey,
IPAddress: "100.64.0.42",
}
}
func mustNode(t *testing.T, s *Store, id, tenantID, publicKey string) Node {
t.Helper()
n := sampleNode(id, publicKey)
n.TenantID = tenantID
created, err := s.CreateNode(ctx, n)
if err != nil {
t.Fatalf("CreateNode(%s): %v", id, err)
}
return created
}
func TestCreateNode_RoundTrip(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
n := sampleNode("node-proxmox-vm-42", "MCowBQYDK2VwAyEAabc=")
n.TenantID = "t1"
created, err := s.CreateNode(ctx, n)
if err != nil {
t.Fatalf("CreateNode: %v", err)
}
if created.Status != NodeOnline {
t.Errorf("Default-Status = %q, erwartet online", created.Status)
}
if !created.LastSeen.Equal(clock.now()) {
t.Errorf("LastSeen = %v, erwartet jetzt", created.LastSeen)
}
byID, err := s.GetNode(ctx, "node-proxmox-vm-42")
if err != nil {
t.Fatal(err)
}
byKey, err := s.GetNodeByPublicKey(ctx, "MCowBQYDK2VwAyEAabc=")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(byID, created) || !reflect.DeepEqual(byKey, created) {
t.Errorf("gelesen weicht ab\n byID: %+v\nbyKey: %+v\n want: %+v", byID, byKey, created)
}
}
func TestCreateNode_KeepsExplicitValues(t *testing.T) {
s, _ := openTest(t)
mustTenant(t, s, "t1")
seen := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
n := sampleNode("n1", "k1")
n.TenantID, n.Status, n.LastSeen = "t1", NodeDrained, seen
created, err := s.CreateNode(ctx, n)
if err != nil {
t.Fatal(err)
}
if created.Status != NodeDrained || !created.LastSeen.Equal(seen) {
t.Errorf("explizite Werte überschrieben: %+v", created)
}
}
func TestCreateNode_Errors(t *testing.T) {
s, _ := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "key1")
unknownTenant := sampleNode("n2", "key2")
unknownTenant.TenantID = "fehlt"
if _, err := s.CreateNode(ctx, unknownTenant); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Mandant: erwartet ErrNotFound, bekommen %v", err)
}
dupID := sampleNode("n1", "key-neu")
dupID.TenantID = "t1"
if _, err := s.CreateNode(ctx, dupID); !errors.Is(err, ErrExists) {
t.Errorf("doppelte ID: erwartet ErrExists, bekommen %v", err)
}
dupKey := sampleNode("n-neu", "key1")
dupKey.TenantID = "t1"
if _, err := s.CreateNode(ctx, dupKey); !errors.Is(err, ErrExists) {
t.Errorf("doppelter Public Key: erwartet ErrExists, bekommen %v", err)
}
}
func TestCreateNode_Validation(t *testing.T) {
tests := map[string]func(*Node){
"ID leer": func(n *Node) { n.ID = "" },
"Mandant leer": func(n *Node) { n.TenantID = "" },
"Hostname leer": func(n *Node) { n.Hostname = "" },
"Public Key leer": func(n *Node) { n.PublicKey = " " },
"IP leer": func(n *Node) { n.IPAddress = "" },
"unbekannter Status": func(n *Node) { n.Status = "schläft" },
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
s, _ := openTest(t)
mustTenant(t, s, "t1")
n := sampleNode("n1", "k1")
n.TenantID = "t1"
mutate(&n)
if _, err := s.CreateNode(ctx, n); !errors.Is(err, ErrInvalid) {
t.Errorf("erwartet ErrInvalid, bekommen %v", err)
}
})
}
}
func TestGetNode_NotFound(t *testing.T) {
s, _ := openTest(t)
if _, err := s.GetNode(ctx, "fehlt"); !errors.Is(err, ErrNotFound) {
t.Errorf("GetNode: erwartet ErrNotFound, bekommen %v", err)
}
if _, err := s.GetNodeByPublicKey(ctx, "fehlt"); !errors.Is(err, ErrNotFound) {
t.Errorf("GetNodeByPublicKey: erwartet ErrNotFound, bekommen %v", err)
}
}
func TestListNodes(t *testing.T) {
s, _ := openTest(t)
mustTenant(t, s, "t1")
mustTenant(t, s, "t2")
mustNode(t, s, "c", "t1", "k-c")
mustNode(t, s, "a", "t1", "k-a")
mustNode(t, s, "b", "t2", "k-b")
ids := func(tenantID string) []string {
t.Helper()
nodes, err := s.ListNodes(ctx, tenantID)
if err != nil {
t.Fatal(err)
}
out := []string{}
for _, n := range nodes {
out = append(out, n.ID)
}
return out
}
if got := ids("t1"); !reflect.DeepEqual(got, []string{"a", "c"}) {
t.Errorf("t1: %v", got)
}
if got := ids(""); !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
t.Errorf("alle: %v", got)
}
if got := ids("leer"); len(got) != 0 {
t.Errorf("unbekannter Mandant: %v", got)
}
}
func TestTouchNode(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
if err := s.SetNodeStatus(ctx, "n1", NodeDrained); err != nil {
t.Fatal(err)
}
clock.advance(45 * time.Second)
if err := s.TouchNode(ctx, "n1", "192.168.1.5"); err != nil {
t.Fatalf("TouchNode: %v", err)
}
got, _ := s.GetNode(ctx, "n1")
if !got.LastSeen.Equal(clock.now()) || got.IPAddress != "192.168.1.5" {
t.Errorf("last_seen=%v ip=%q", got.LastSeen, got.IPAddress)
}
if got.Status != NodeDrained {
t.Errorf("TouchNode darf den Status nicht ändern, ist %q", got.Status)
}
if err := s.TouchNode(ctx, "fehlt", "1.2.3.4"); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannt: erwartet ErrNotFound, bekommen %v", err)
}
if err := s.TouchNode(ctx, "n1", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("leere IP: erwartet ErrInvalid, bekommen %v", err)
}
}
func TestSetNodeStatus(t *testing.T) {
s, _ := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
for _, st := range []NodeStatus{NodeOffline, NodeDrained, NodeOnline} {
if err := s.SetNodeStatus(ctx, "n1", st); err != nil {
t.Fatalf("%s: %v", st, err)
}
if got, _ := s.GetNode(ctx, "n1"); got.Status != st {
t.Errorf("Status = %q, erwartet %q", got.Status, st)
}
}
if err := s.SetNodeStatus(ctx, "n1", "kaputt"); !errors.Is(err, ErrInvalid) {
t.Errorf("ungültig: erwartet ErrInvalid, bekommen %v", err)
}
if err := s.SetNodeStatus(ctx, "fehlt", NodeOnline); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannt: erwartet ErrNotFound, bekommen %v", err)
}
}
func TestDeleteNode(t *testing.T) {
s, _ := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
if err := s.DeleteNode(ctx, "n1"); err != nil {
t.Fatal(err)
}
if _, err := s.GetNode(ctx, "n1"); !errors.Is(err, ErrNotFound) {
t.Errorf("Knoten noch da: %v", err)
}
if err := s.DeleteNode(ctx, "n1"); !errors.Is(err, ErrNotFound) {
t.Errorf("zweites Löschen: erwartet ErrNotFound, bekommen %v", err)
}
// Public Key ist nach dem Löschen wieder frei (z. B. für re_enroll).
mustNode(t, s, "n1-neu", "t1", "k1")
}
+214
View File
@@ -0,0 +1,214 @@
// Package store hält den persistenten Zustand des Sultan-Servers (Mandanten,
// Knoten, Enrollment-Tokens) in SQLite, Schema gemäß Spec Abschnitt 4.3.
//
// Verwendet wird der reine Go-Treiber modernc.org/sqlite (kein cgo), damit
// sultan ein statisch gelinktes Single Binary bleibt.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
var (
// ErrNotFound: Der Datensatz (oder ein referenzierter Datensatz) existiert nicht.
ErrNotFound = errors.New("store: nicht gefunden")
// ErrExists: Ein Datensatz mit derselben ID bzw. demselben eindeutigen Wert existiert bereits.
ErrExists = errors.New("store: existiert bereits")
// ErrInvalid: Die Eingabe verletzt eine Validierungsregel.
ErrInvalid = errors.New("store: ungültige Eingabe")
// ErrTokenUsed: Der Enrollment-Token wurde bereits eingelöst.
ErrTokenUsed = errors.New("store: Token bereits verwendet")
// ErrTokenExpired: Der Enrollment-Token ist abgelaufen.
ErrTokenExpired = errors.New("store: Token abgelaufen")
// ErrSchemaTooNew: Die Datenbank stammt von einer neueren sultan-Version.
ErrSchemaTooNew = errors.New("store: Datenbankschema neuer als diese Programmversion")
)
// migrations[i] hebt das Schema von Version i auf i+1. Bestehende Einträge
// dürfen nie verändert, nur neue angehängt werden.
var migrations = []string{
`CREATE TABLE tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'community',
max_routes INTEGER NOT NULL DEFAULT 5,
allowed_domains TEXT NOT NULL,
valid_until DATETIME NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE nodes (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
hostname TEXT NOT NULL,
public_key TEXT NOT NULL UNIQUE,
last_seen DATETIME NOT NULL,
ip_address TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'online'
);
CREATE INDEX nodes_tenant_id ON nodes(tenant_id);
CREATE TABLE enrollment_tokens (
token_hash TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
expires_at DATETIME NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX enrollment_tokens_tenant_id ON enrollment_tokens(tenant_id);`,
}
// Store ist der Zugriff auf die SQLite-Datenbank. Er ist für parallele
// Nutzung aus mehreren Goroutinen geeignet.
type Store struct {
db *sql.DB
now func() time.Time
}
// Open öffnet (bzw. erstellt) die Datenbank unter path und bringt das Schema
// auf den aktuellen Stand.
func Open(ctx context.Context, path string) (*Store, error) {
// foreign_keys: sonst greift ON DELETE CASCADE nicht (gilt pro Verbindung).
// _txlock=immediate: Schreib-Transaktionen holen die Sperre sofort und
// warten per busy_timeout, statt beim Upgrade mit SQLITE_BUSY abzubrechen.
dsn := "file:" + path +
"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_txlock=immediate"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
s := &Store{db: db, now: time.Now}
if err := s.migrate(ctx); err != nil {
db.Close()
return nil, err
}
return s, nil
}
// Close schließt die Datenbank.
func (s *Store) Close() error { return s.db.Close() }
// SchemaVersion liefert die aktuelle Schema-Version.
func (s *Store) SchemaVersion(ctx context.Context) (int, error) {
var v int
err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v)
return v, err
}
func (s *Store) migrate(ctx context.Context) error {
v, err := s.SchemaVersion(ctx)
if err != nil {
return fmt.Errorf("store: Schema-Version lesen: %w", err)
}
if v > len(migrations) {
return fmt.Errorf("%w (Datenbank v%d, bekannt bis v%d)", ErrSchemaTooNew, v, len(migrations))
}
for i := v; i < len(migrations); i++ {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
tx.Rollback()
return fmt.Errorf("store: Migration auf v%d: %w", i+1, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
// timeLayout hat feste Breite, damit Zeitstempel als Text korrekt sortieren
// und sich in SQL vergleichen lassen.
const timeLayout = "2006-01-02T15:04:05.000000000Z"
func formatTime(t time.Time) string { return t.UTC().Format(timeLayout) }
// normTime entfernt Monotonic-Clock und Zeitzone, damit gespeicherte und
// gelesene Werte identisch sind.
func normTime(t time.Time) time.Time { return t.Round(0).UTC() }
// dbTime liest einen Zeitstempel unabhängig davon, ob der Treiber ihn als
// Text oder bereits als time.Time liefert.
type dbTime struct{ t *time.Time }
func (d dbTime) Scan(src any) error {
switch v := src.(type) {
case time.Time:
*d.t = normTime(v)
return nil
case string:
return d.parse(v)
case []byte:
return d.parse(string(v))
default:
return fmt.Errorf("store: unerwarteter Zeittyp %T", src)
}
}
func (d dbTime) parse(s string) error {
t, err := time.Parse(timeLayout, s)
if err != nil {
return fmt.Errorf("store: Zeitstempel %q: %w", s, err)
}
*d.t = t
return nil
}
// execer ist die gemeinsame Teilmenge von *sql.DB und *sql.Tx.
type execer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
func sqliteCode(err error) int {
var se *sqlite.Error
if errors.As(err, &se) {
return se.Code()
}
return 0
}
// mapConstraint übersetzt SQLite-Constraint-Fehler in die Fehler des Packages.
func mapConstraint(err error, what string) error {
switch sqliteCode(err) {
case sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY, sqlite3.SQLITE_CONSTRAINT_UNIQUE:
return fmt.Errorf("%s: %w", what, ErrExists)
case sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY:
return fmt.Errorf("%s: Mandant: %w", what, ErrNotFound)
}
return err
}
func blank(s string) bool { return strings.TrimSpace(s) == "" }
func invalid(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, args...))
}
// requireAffected liefert ErrNotFound, wenn res keine Zeile betroffen hat.
func requireAffected(res sql.Result, err error, what string) error {
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("%s: %w", what, ErrNotFound)
}
return nil
}
+143
View File
@@ -0,0 +1,143 @@
package store
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
)
var ctx = context.Background()
// fakeClock ist eine steuerbare Uhr für deterministische Zeitstempel.
type fakeClock struct{ t time.Time }
func (c *fakeClock) now() time.Time { return c.t }
func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) }
// openTest öffnet einen frischen Store in einem temporären Verzeichnis mit
// steuerbarer Uhr.
func openTest(t *testing.T) (*Store, *fakeClock) {
t.Helper()
s, err := Open(ctx, filepath.Join(t.TempDir(), "sultan.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { s.Close() })
clock := &fakeClock{t: time.Date(2026, 10, 1, 12, 0, 0, 123456789, time.UTC)}
s.now = clock.now
return s, clock
}
func TestOpen_MigratesSchema(t *testing.T) {
s, _ := openTest(t)
v, err := s.SchemaVersion(ctx)
if err != nil {
t.Fatal(err)
}
if v != len(migrations) || v < 1 {
t.Errorf("Schema-Version = %d, erwartet %d", v, len(migrations))
}
}
func TestOpen_ReopenKeepsData(t *testing.T) {
path := filepath.Join(t.TempDir(), "sultan.db")
s, err := Open(ctx, path)
if err != nil {
t.Fatal(err)
}
if _, err := s.CreateTenant(ctx, sampleTenant("t1")); err != nil {
t.Fatal(err)
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
s, err = Open(ctx, path)
if err != nil {
t.Fatalf("erneutes Open: %v", err)
}
defer s.Close()
if _, err := s.GetTenant(ctx, "t1"); err != nil {
t.Errorf("Daten nach erneutem Öffnen weg: %v", err)
}
if v, _ := s.SchemaVersion(ctx); v != len(migrations) {
t.Errorf("Schema-Version nach erneutem Öffnen = %d", v)
}
}
func TestOpen_InvalidPath(t *testing.T) {
_, err := Open(ctx, filepath.Join(t.TempDir(), "gibt", "es", "nicht", "sultan.db"))
if err == nil {
t.Error("erwartet Fehler für nicht existierendes Verzeichnis")
}
}
func TestOpen_RejectsNewerSchema(t *testing.T) {
path := filepath.Join(t.TempDir(), "sultan.db")
s, err := Open(ctx, path)
if err != nil {
t.Fatal(err)
}
if _, err := s.db.ExecContext(ctx, "PRAGMA user_version = 999"); err != nil {
t.Fatal(err)
}
s.Close()
if _, err := Open(ctx, path); !errors.Is(err, ErrSchemaTooNew) {
t.Errorf("erwartet ErrSchemaTooNew, bekommen %v", err)
}
}
func TestOpen_ForeignKeysEnabled(t *testing.T) {
s, _ := openTest(t)
var on int
if err := s.db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&on); err != nil {
t.Fatal(err)
}
if on != 1 {
t.Error("foreign_keys ist nicht aktiv – ON DELETE CASCADE würde nicht greifen")
}
}
func TestDBTime_Scan(t *testing.T) {
want := time.Date(2026, 12, 15, 10, 30, 0, 987654321, time.UTC)
cet := want.In(time.FixedZone("CET", 3600))
for name, src := range map[string]any{
"Text": "2026-12-15T10:30:00.987654321Z",
"Bytes": []byte("2026-12-15T10:30:00.987654321Z"),
"time.Time (CET)": cet,
} {
t.Run(name, func(t *testing.T) {
var got time.Time
if err := (dbTime{&got}).Scan(src); err != nil {
t.Fatal(err)
}
if !got.Equal(want) || got.Location() != time.UTC {
t.Errorf("got %v, erwartet %v in UTC", got, want)
}
})
}
var got time.Time
if err := (dbTime{&got}).Scan("15.12.2026"); err == nil {
t.Error("ungültiger Text: erwartet Fehler")
}
if err := (dbTime{&got}).Scan(int64(42)); err == nil {
t.Error("unerwarteter Typ: erwartet Fehler")
}
}
// Zeitstempel müssen als Text fester Breite gespeichert werden, sonst
// funktionieren Vergleiche in SQL (z. B. PurgeEnrollmentTokens) nicht.
func TestTimesStoredAsSortableText(t *testing.T) {
s, _ := openTest(t)
mustTenant(t, s, "t1")
var typ, raw string
if err := s.db.QueryRowContext(ctx, "SELECT typeof(created_at), CAST(created_at AS TEXT) FROM tenants").Scan(&typ, &raw); err != nil {
t.Fatal(err)
}
if typ != "text" || raw != "2026-10-01T12:00:00.123456789Z" {
t.Errorf("gespeichert als %s %q", typ, raw)
}
}
+200
View File
@@ -0,0 +1,200 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
)
// Tier ist die Lizenzstufe eines Mandanten.
type Tier string
const (
TierCommunity Tier = "community"
TierPro Tier = "pro"
TierEnterprise Tier = "enterprise"
)
// TenantStatus ist der Abo-Status eines Mandanten.
type TenantStatus string
const (
TenantActive TenantStatus = "active"
TenantSuspendedPayment TenantStatus = "suspended_payment"
TenantQuarantined TenantStatus = "quarantined"
TenantExpired TenantStatus = "expired"
)
func (t Tier) valid() bool {
return t == TierCommunity || t == TierPro || t == TierEnterprise
}
func (s TenantStatus) valid() bool {
switch s {
case TenantActive, TenantSuspendedPayment, TenantQuarantined, TenantExpired:
return true
}
return false
}
// Tenant ist ein Mandant mit seinem Abonnement.
type Tenant struct {
ID string
Name string
Email string
Tier Tier
MaxRoutes int // -1 = unbegrenzt
AllowedDomains []string
ValidUntil time.Time
Status TenantStatus
CreatedAt time.Time
UpdatedAt time.Time
}
// normalize setzt Defaults (Tier community, Status active, leere
// Domainliste) und prüft die Eingabe.
func (t *Tenant) normalize() error {
if t.Tier == "" {
t.Tier = TierCommunity
}
if t.Status == "" {
t.Status = TenantActive
}
if t.AllowedDomains == nil {
t.AllowedDomains = []string{}
}
t.ValidUntil = normTime(t.ValidUntil)
switch {
case blank(t.ID):
return invalid("Mandanten-ID fehlt")
case blank(t.Name):
return invalid("Name fehlt")
case blank(t.Email):
return invalid("E-Mail fehlt")
case !t.Tier.valid():
return invalid("unbekannter Tier %q", t.Tier)
case !t.Status.valid():
return invalid("unbekannter Status %q", t.Status)
case t.MaxRoutes < -1:
return invalid("max_routes %d (erlaubt: -1 oder ≥ 0)", t.MaxRoutes)
case t.ValidUntil.IsZero():
return invalid("valid_until fehlt")
}
for _, d := range t.AllowedDomains {
if blank(d) {
return invalid("leerer Eintrag in allowed_domains")
}
}
return nil
}
// CreateTenant legt einen Mandanten an und liefert ihn mit Defaults und
// Zeitstempeln zurück.
func (s *Store) CreateTenant(ctx context.Context, t Tenant) (Tenant, error) {
if err := t.normalize(); err != nil {
return Tenant{}, err
}
domains, err := json.Marshal(t.AllowedDomains)
if err != nil {
return Tenant{}, err
}
now := normTime(s.now())
t.CreatedAt, t.UpdatedAt = now, now
_, err = s.db.ExecContext(ctx, `INSERT INTO tenants
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains),
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now))
if err != nil {
return Tenant{}, mapConstraint(err, "Mandant "+t.ID)
}
return t, nil
}
const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at`
func scanTenant(row interface{ Scan(...any) error }) (Tenant, error) {
var t Tenant
var domains string
err := row.Scan(&t.ID, &t.Name, &t.Email, &t.Tier, &t.MaxRoutes, &domains,
dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt})
if err != nil {
return Tenant{}, err
}
if err := json.Unmarshal([]byte(domains), &t.AllowedDomains); err != nil {
return Tenant{}, fmt.Errorf("store: allowed_domains von %s: %w", t.ID, err)
}
if t.AllowedDomains == nil {
t.AllowedDomains = []string{}
}
return t, nil
}
// GetTenant liest einen Mandanten.
func (s *Store) GetTenant(ctx context.Context, id string) (Tenant, error) {
t, err := scanTenant(s.db.QueryRowContext(ctx, `SELECT `+tenantColumns+` FROM tenants WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return Tenant{}, fmt.Errorf("Mandant %s: %w", id, ErrNotFound)
}
return t, err
}
// ListTenants liefert alle Mandanten, sortiert nach ID.
func (s *Store) ListTenants(ctx context.Context) ([]Tenant, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+tenantColumns+` FROM tenants ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
tenants := []Tenant{}
for rows.Next() {
t, err := scanTenant(rows)
if err != nil {
return nil, err
}
tenants = append(tenants, t)
}
return tenants, rows.Err()
}
// UpdateTenant überschreibt alle veränderlichen Felder des Mandanten t.ID.
// CreatedAt bleibt erhalten, UpdatedAt wird gesetzt.
func (s *Store) UpdateTenant(ctx context.Context, t Tenant) (Tenant, error) {
if err := t.normalize(); err != nil {
return Tenant{}, err
}
domains, err := json.Marshal(t.AllowedDomains)
if err != nil {
return Tenant{}, err
}
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?
WHERE id = ?`,
t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains), formatTime(t.ValidUntil), t.Status,
formatTime(s.now()), t.ID)
if err := requireAffected(res, err, "Mandant "+t.ID); err != nil {
return Tenant{}, err
}
return s.GetTenant(ctx, t.ID)
}
// SetTenantStatus ändert nur den Status, z. B. bei Zahlungsverzug.
func (s *Store) SetTenantStatus(ctx context.Context, id string, status TenantStatus) error {
if !status.valid() {
return invalid("unbekannter Status %q", status)
}
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET status = ?, updated_at = ? WHERE id = ?`,
status, formatTime(s.now()), id)
return requireAffected(res, err, "Mandant "+id)
}
// DeleteTenant löscht den Mandanten samt seiner Knoten und Tokens.
func (s *Store) DeleteTenant(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM tenants WHERE id = ?`, id)
return requireAffected(res, err, "Mandant "+id)
}
+253
View File
@@ -0,0 +1,253 @@
package store
import (
"errors"
"reflect"
"testing"
"time"
)
func sampleTenant(id string) Tenant {
return Tenant{
ID: id,
Name: "Kunde " + id,
Email: id + "@example.com",
Tier: TierPro,
MaxRoutes: 10,
AllowedDomains: []string{"*.firma.de", "shop.de"},
ValidUntil: time.Date(2026, 12, 15, 0, 0, 0, 0, time.UTC),
Status: TenantActive,
}
}
func TestCreateTenant_RoundTrip(t *testing.T) {
s, clock := openTest(t)
in := sampleTenant("kunde_schmidt")
in.ValidUntil = time.Date(2026, 12, 15, 10, 30, 0, 987654321, time.FixedZone("CET", 3600))
created, err := s.CreateTenant(ctx, in)
if err != nil {
t.Fatalf("CreateTenant: %v", err)
}
if !created.CreatedAt.Equal(clock.now()) || !created.UpdatedAt.Equal(clock.now()) {
t.Errorf("Zeitstempel: created=%v updated=%v, erwartet %v", created.CreatedAt, created.UpdatedAt, clock.now())
}
if !created.ValidUntil.Equal(in.ValidUntil) || created.ValidUntil.Location() != time.UTC {
t.Errorf("ValidUntil = %v, erwartet %v in UTC", created.ValidUntil, in.ValidUntil)
}
got, err := s.GetTenant(ctx, "kunde_schmidt")
if err != nil {
t.Fatalf("GetTenant: %v", err)
}
if !reflect.DeepEqual(got, created) {
t.Errorf("gelesen weicht ab\n got: %+v\nwant: %+v", got, created)
}
}
func TestCreateTenant_Defaults(t *testing.T) {
s, _ := openTest(t)
in := sampleTenant("t1")
in.Tier, in.Status, in.AllowedDomains = "", "", nil
created, err := s.CreateTenant(ctx, in)
if err != nil {
t.Fatal(err)
}
if created.Tier != TierCommunity || created.Status != TenantActive {
t.Errorf("Defaults: tier=%q status=%q", created.Tier, created.Status)
}
if created.AllowedDomains == nil || len(created.AllowedDomains) != 0 {
t.Errorf("AllowedDomains = %#v, erwartet leere, nicht-nil Liste", created.AllowedDomains)
}
got, _ := s.GetTenant(ctx, "t1")
if !reflect.DeepEqual(got, created) {
t.Errorf("gelesen weicht ab\n got: %+v\nwant: %+v", got, created)
}
}
func TestCreateTenant_Duplicate(t *testing.T) {
s, _ := openTest(t)
if _, err := s.CreateTenant(ctx, sampleTenant("t1")); err != nil {
t.Fatal(err)
}
if _, err := s.CreateTenant(ctx, sampleTenant("t1")); !errors.Is(err, ErrExists) {
t.Errorf("erwartet ErrExists, bekommen %v", err)
}
}
func TestCreateTenant_Validation(t *testing.T) {
tests := map[string]func(*Tenant){
"ID leer": func(t *Tenant) { t.ID = "" },
"Name leer": func(t *Tenant) { t.Name = " " },
"E-Mail leer": func(t *Tenant) { t.Email = "" },
"unbekannter Tier": func(t *Tenant) { t.Tier = "gold" },
"unbekannter Status": func(t *Tenant) { t.Status = "pausiert" },
"MaxRoutes < -1": func(t *Tenant) { t.MaxRoutes = -2 },
"leere Domain": func(t *Tenant) { t.AllowedDomains = []string{"a.de", ""} },
"ValidUntil fehlt": func(t *Tenant) { t.ValidUntil = time.Time{} },
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
s, _ := openTest(t)
in := sampleTenant("t1")
mutate(&in)
if _, err := s.CreateTenant(ctx, in); !errors.Is(err, ErrInvalid) {
t.Errorf("erwartet ErrInvalid, bekommen %v", err)
}
})
}
}
func TestCreateTenant_AllStatusesAndTiers(t *testing.T) {
s, _ := openTest(t)
i := 0
for _, tier := range []Tier{TierCommunity, TierPro, TierEnterprise} {
for _, st := range []TenantStatus{TenantActive, TenantSuspendedPayment, TenantQuarantined, TenantExpired} {
in := sampleTenant(string(rune('a' + i)))
in.Tier, in.Status = tier, st
if _, err := s.CreateTenant(ctx, in); err != nil {
t.Errorf("tier=%s status=%s: %v", tier, st, err)
}
i++
}
}
}
func TestGetTenant_NotFound(t *testing.T) {
s, _ := openTest(t)
if _, err := s.GetTenant(ctx, "fehlt"); !errors.Is(err, ErrNotFound) {
t.Errorf("erwartet ErrNotFound, bekommen %v", err)
}
}
func TestListTenants(t *testing.T) {
s, _ := openTest(t)
got, err := s.ListTenants(ctx)
if err != nil || len(got) != 0 {
t.Fatalf("leere DB: %v, %v", got, err)
}
for _, id := range []string{"c", "a", "b"} {
if _, err := s.CreateTenant(ctx, sampleTenant(id)); err != nil {
t.Fatal(err)
}
}
got, err = s.ListTenants(ctx)
if err != nil {
t.Fatal(err)
}
var ids []string
for _, tn := range got {
ids = append(ids, tn.ID)
}
if !reflect.DeepEqual(ids, []string{"a", "b", "c"}) {
t.Errorf("IDs = %v, erwartet sortiert", ids)
}
}
func TestUpdateTenant(t *testing.T) {
s, clock := openTest(t)
created, err := s.CreateTenant(ctx, sampleTenant("t1"))
if err != nil {
t.Fatal(err)
}
clock.advance(time.Hour)
upd := created
upd.Name = "Neuer Name"
upd.Tier = TierEnterprise
upd.MaxRoutes = -1
upd.AllowedDomains = []string{"neu.de"}
upd.ValidUntil = created.ValidUntil.AddDate(1, 0, 0)
upd.CreatedAt = time.Time{} // darf nicht überschrieben werden
got, err := s.UpdateTenant(ctx, upd)
if err != nil {
t.Fatalf("UpdateTenant: %v", err)
}
if !got.CreatedAt.Equal(created.CreatedAt) {
t.Errorf("CreatedAt verändert: %v", got.CreatedAt)
}
if !got.UpdatedAt.Equal(clock.now()) {
t.Errorf("UpdatedAt = %v, erwartet %v", got.UpdatedAt, clock.now())
}
read, _ := s.GetTenant(ctx, "t1")
if !reflect.DeepEqual(read, got) {
t.Errorf("gelesen weicht ab\n got: %+v\nwant: %+v", read, got)
}
if read.Name != "Neuer Name" || read.MaxRoutes != -1 || read.Tier != TierEnterprise {
t.Errorf("Änderungen nicht gespeichert: %+v", read)
}
}
func TestUpdateTenant_Errors(t *testing.T) {
s, _ := openTest(t)
if _, err := s.UpdateTenant(ctx, sampleTenant("fehlt")); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannt: erwartet ErrNotFound, bekommen %v", err)
}
created, _ := s.CreateTenant(ctx, sampleTenant("t1"))
created.Email = ""
if _, err := s.UpdateTenant(ctx, created); !errors.Is(err, ErrInvalid) {
t.Errorf("ungültig: erwartet ErrInvalid, bekommen %v", err)
}
}
func TestSetTenantStatus(t *testing.T) {
s, clock := openTest(t)
if _, err := s.CreateTenant(ctx, sampleTenant("t1")); err != nil {
t.Fatal(err)
}
clock.advance(time.Minute)
if err := s.SetTenantStatus(ctx, "t1", TenantSuspendedPayment); err != nil {
t.Fatal(err)
}
got, _ := s.GetTenant(ctx, "t1")
if got.Status != TenantSuspendedPayment || !got.UpdatedAt.Equal(clock.now()) {
t.Errorf("status=%q updated=%v", got.Status, got.UpdatedAt)
}
if err := s.SetTenantStatus(ctx, "t1", "kaputt"); !errors.Is(err, ErrInvalid) {
t.Errorf("ungültig: erwartet ErrInvalid, bekommen %v", err)
}
if err := s.SetTenantStatus(ctx, "fehlt", TenantActive); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannt: erwartet ErrNotFound, bekommen %v", err)
}
}
func TestDeleteTenant_Cascades(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustTenant(t, s, "t2")
mustNode(t, s, "n1", "t1", "key1")
mustNode(t, s, "n2", "t2", "key2")
if err := s.AddEnrollmentToken(ctx, "tok1", "t1", clock.now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
if err := s.DeleteTenant(ctx, "t1"); err != nil {
t.Fatalf("DeleteTenant: %v", err)
}
if _, err := s.GetTenant(ctx, "t1"); !errors.Is(err, ErrNotFound) {
t.Errorf("Mandant noch da: %v", err)
}
if _, err := s.GetNode(ctx, "n1"); !errors.Is(err, ErrNotFound) {
t.Errorf("Knoten des Mandanten nicht mitgelöscht: %v", err)
}
if _, err := s.Enroll(ctx, "tok1", sampleNode("n3", "key3")); !errors.Is(err, ErrNotFound) {
t.Errorf("Token des Mandanten nicht mitgelöscht: %v", err)
}
if _, err := s.GetNode(ctx, "n2"); err != nil {
t.Errorf("fremder Knoten gelöscht: %v", err)
}
if err := s.DeleteTenant(ctx, "t1"); !errors.Is(err, ErrNotFound) {
t.Errorf("zweites Löschen: erwartet ErrNotFound, bekommen %v", err)
}
}
func mustTenant(t *testing.T, s *Store, id string) Tenant {
t.Helper()
tn, err := s.CreateTenant(ctx, sampleTenant(id))
if err != nil {
t.Fatalf("CreateTenant(%s): %v", id, err)
}
return tn
}
+88
View File
@@ -0,0 +1,88 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
// hashToken: Tokens werden nur als SHA-256 gespeichert. Ein einfacher Hash
// reicht, weil Tokens zufällig mit hoher Entropie erzeugt werden.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// AddEnrollmentToken hinterlegt einen einmaligen Einladungs-Token für
// tenantID. Erzeugt wird der Token vom Aufrufer; gespeichert wird nur sein
// Hash.
func (s *Store) AddEnrollmentToken(ctx context.Context, token, tenantID string, expiresAt time.Time) error {
if token == "" {
return invalid("Token fehlt")
}
if !expiresAt.After(s.now()) {
return invalid("Ablaufzeitpunkt %v liegt nicht in der Zukunft", expiresAt)
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO enrollment_tokens (token_hash, tenant_id, expires_at, used) VALUES (?, ?, ?, 0)`,
hashToken(token), tenantID, formatTime(expiresAt))
return mapConstraint(err, "Enrollment-Token")
}
// Enroll löst token ein und legt in derselben Transaktion den Knoten n an.
// n.TenantID wird durch den Mandanten des Tokens ersetzt. Schlägt das Anlegen
// fehl, bleibt der Token unverbraucht. Fehler: ErrNotFound (unbekannter
// Token), ErrTokenUsed, ErrTokenExpired sowie die Fehler von CreateNode.
func (s *Store) Enroll(ctx context.Context, token string, n Node) (Node, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Node{}, err
}
defer tx.Rollback()
hash := hashToken(token)
var tenantID string
var expiresAt time.Time
var used bool
err = tx.QueryRowContext(ctx,
`SELECT tenant_id, expires_at, used FROM enrollment_tokens WHERE token_hash = ?`, hash).
Scan(&tenantID, dbTime{&expiresAt}, &used)
switch {
case errors.Is(err, sql.ErrNoRows):
return Node{}, fmt.Errorf("Enrollment-Token: %w", ErrNotFound)
case err != nil:
return Node{}, err
case used:
return Node{}, ErrTokenUsed
case !s.now().Before(expiresAt):
return Node{}, ErrTokenExpired
}
if _, err := tx.ExecContext(ctx, `UPDATE enrollment_tokens SET used = 1 WHERE token_hash = ?`, hash); err != nil {
return Node{}, err
}
n.TenantID = tenantID
node, err := s.insertNode(ctx, tx, n)
if err != nil {
return Node{}, err
}
if err := tx.Commit(); err != nil {
return Node{}, err
}
return node, nil
}
// PurgeEnrollmentTokens löscht verbrauchte und abgelaufene Tokens und
// liefert ihre Anzahl.
func (s *Store) PurgeEnrollmentTokens(ctx context.Context) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM enrollment_tokens WHERE used = 1 OR expires_at <= ?`, formatTime(s.now()))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
+188
View File
@@ -0,0 +1,188 @@
package store
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sync"
"testing"
"time"
)
func TestEnroll(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
if err := s.AddEnrollmentToken(ctx, "sltn_geheim", "t1", clock.now().Add(time.Hour)); err != nil {
t.Fatalf("AddEnrollmentToken: %v", err)
}
n := sampleNode("n1", "key1")
n.TenantID = "manipuliert" // muss durch den Mandanten des Tokens ersetzt werden
node, err := s.Enroll(ctx, "sltn_geheim", n)
if err != nil {
t.Fatalf("Enroll: %v", err)
}
if node.TenantID != "t1" {
t.Errorf("TenantID = %q, erwartet t1 (aus dem Token)", node.TenantID)
}
if got, err := s.GetNodeByPublicKey(ctx, "key1"); err != nil || got.ID != "n1" {
t.Errorf("Knoten nicht angelegt: %+v, %v", got, err)
}
}
func TestAddEnrollmentToken_StoresOnlyHash(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
if err := s.AddEnrollmentToken(ctx, "sltn_geheim", "t1", clock.now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
var stored string
if err := s.db.QueryRowContext(ctx, "SELECT token_hash FROM enrollment_tokens").Scan(&stored); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256([]byte("sltn_geheim"))
if stored != hex.EncodeToString(sum[:]) {
t.Errorf("gespeichert %q, erwartet SHA-256-Hex des Tokens", stored)
}
}
func TestAddEnrollmentToken_Errors(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
later := clock.now().Add(time.Hour)
if err := s.AddEnrollmentToken(ctx, "tok", "fehlt", later); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Mandant: erwartet ErrNotFound, bekommen %v", err)
}
if err := s.AddEnrollmentToken(ctx, "", "t1", later); !errors.Is(err, ErrInvalid) {
t.Errorf("leerer Token: erwartet ErrInvalid, bekommen %v", err)
}
if err := s.AddEnrollmentToken(ctx, "tok", "t1", clock.now()); !errors.Is(err, ErrInvalid) {
t.Errorf("Ablauf nicht in der Zukunft: erwartet ErrInvalid, bekommen %v", err)
}
if err := s.AddEnrollmentToken(ctx, "tok", "t1", later); err != nil {
t.Fatal(err)
}
if err := s.AddEnrollmentToken(ctx, "tok", "t1", later); !errors.Is(err, ErrExists) {
t.Errorf("doppelter Token: erwartet ErrExists, bekommen %v", err)
}
}
func TestEnroll_TokenOnlyOnce(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k1")); err != nil {
t.Fatal(err)
}
if _, err := s.Enroll(ctx, "tok", sampleNode("n2", "k2")); !errors.Is(err, ErrTokenUsed) {
t.Errorf("erwartet ErrTokenUsed, bekommen %v", err)
}
if _, err := s.GetNode(ctx, "n2"); !errors.Is(err, ErrNotFound) {
t.Errorf("zweiter Knoten wurde trotzdem angelegt")
}
}
func TestEnroll_Expired(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
clock.advance(time.Hour) // Ablaufzeitpunkt selbst gilt schon als abgelaufen
if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k1")); !errors.Is(err, ErrTokenExpired) {
t.Errorf("erwartet ErrTokenExpired, bekommen %v", err)
}
if _, err := s.GetNode(ctx, "n1"); !errors.Is(err, ErrNotFound) {
t.Errorf("Knoten trotz abgelaufenem Token angelegt")
}
}
func TestEnroll_UnknownToken(t *testing.T) {
s, _ := openTest(t)
if _, err := s.Enroll(ctx, "gibts-nicht", sampleNode("n1", "k1")); !errors.Is(err, ErrNotFound) {
t.Errorf("erwartet ErrNotFound, bekommen %v", err)
}
}
// Schlägt das Anlegen des Knotens fehl, darf der Token nicht verbraucht sein.
func TestEnroll_RollbackKeepsToken(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "vorhanden", "t1", "k-belegt")
s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k-belegt")); !errors.Is(err, ErrExists) {
t.Fatalf("doppelter Key: erwartet ErrExists, bekommen %v", err)
}
invalid := sampleNode("n1", "k-neu")
invalid.Hostname = ""
if _, err := s.Enroll(ctx, "tok", invalid); !errors.Is(err, ErrInvalid) {
t.Fatalf("ungültiger Knoten: erwartet ErrInvalid, bekommen %v", err)
}
if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k-neu")); err != nil {
t.Errorf("Token nach fehlgeschlagenem Enroll nicht mehr nutzbar: %v", err)
}
}
func TestEnroll_ConcurrentSingleUse(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
const workers = 10
errs := make([]error, workers)
var wg sync.WaitGroup
for i := range workers {
wg.Go(func() {
_, errs[i] = s.Enroll(ctx, "tok", sampleNode(fmt.Sprintf("n%d", i), fmt.Sprintf("k%d", i)))
})
}
wg.Wait()
ok, used := 0, 0
for _, err := range errs {
switch {
case err == nil:
ok++
case errors.Is(err, ErrTokenUsed):
used++
default:
t.Errorf("unerwarteter Fehler: %v", err)
}
}
if ok != 1 || used != workers-1 {
t.Errorf("%d erfolgreich, %d ErrTokenUsed – erwartet genau 1 Erfolg", ok, used)
}
nodes, _ := s.ListNodes(ctx, "t1")
if len(nodes) != 1 {
t.Errorf("%d Knoten angelegt, erwartet 1", len(nodes))
}
}
func TestPurgeEnrollmentTokens(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
s.AddEnrollmentToken(ctx, "benutzt", "t1", clock.now().Add(time.Hour))
s.AddEnrollmentToken(ctx, "läuft-ab", "t1", clock.now().Add(time.Minute))
s.AddEnrollmentToken(ctx, "gültig", "t1", clock.now().Add(2*time.Hour))
if _, err := s.Enroll(ctx, "benutzt", sampleNode("n1", "k1")); err != nil {
t.Fatal(err)
}
clock.advance(time.Minute)
n, err := s.PurgeEnrollmentTokens(ctx)
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("%d Tokens gelöscht, erwartet 2", n)
}
if _, err := s.Enroll(ctx, "gültig", sampleNode("n2", "k2")); err != nil {
t.Errorf("gültiger Token wurde gelöscht: %v", err)
}
if _, err := s.Enroll(ctx, "läuft-ab", sampleNode("n3", "k3")); !errors.Is(err, ErrNotFound) {
t.Errorf("abgelaufener Token noch vorhanden: %v", err)
}
}