erste module drin.
zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
1 parent
9d5789c5d7
commit
c8df9d7651
41 files changed
+6455
-36
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user