internal/license: Ed25519-signierte Lizenzschluessel (stdlib crypto/ed25519, keine neue Abhaengigkeit). Issuer haelt den privaten Schluessel (lebt beim Lizenzgeber), Validator nur den oeffentlichen (lebt im Core-Prozess) — klare Trennung Ausstellung/Pruefung nach Unleash-Vorbild (Flag-Verwaltung vs. Flag-Auswertung). Store.Install prueft NUR die Signatur und persistiert den Lizenzumfang (Plan, Modul-Liste, Laufzeit) in tenant_licenses (Registry-DB, 1:1 zu tenants). Eine bereits abgelaufene, aber korrekt signierte Lizenz laesst sich trotzdem einspielen — der Ablauf wird erst bei Store.RequireActive bewertet (liefert ErrLicenseExpired statt Panic/Absturz), waehrend Store.Status den Umfang unabhaengig vom Ablauf weiterhin liefert. Neu: scripts/run-checks.sh buendelt reset-test-env.sh + go build/vet/test (-p 1) zu einem Ein-Kommando-Check fuer den Testhost. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Manipulierter Lizenzschluessel zuverlaessig erkannt — TestParse_RejectsTamperedKey, TestParse_RejectsWrongKeyPair, TestStore_InstallRejectsInvalidSignature. PASS. 2. Ablauf loest definierten eingeschraenkten Zustand aus, kein harter Systemausfall — TestStore_RequireActive_DetectsExpiry (inkl. recover()- Absicherung im Test, dass kein Panic auftritt), ErrLicenseExpired statt Absturz; Status bleibt trotzdem abfragbar. PASS. 3. Signaturpruefung von zweiter Person gegen Dokumentation nachvollzogen — NICHT durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
5.0 KiB
Go
178 lines
5.0 KiB
Go
package license
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func setupStoreTest(t *testing.T) (*Store, *Issuer, string, func()) {
|
|
t.Helper()
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
if adminDSN == "" {
|
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
pool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
|
|
if _, err := pool.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS tenants (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
slug TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
db_name TEXT NOT NULL UNIQUE,
|
|
db_dsn TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS tenant_licenses (
|
|
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
|
plan TEXT NOT NULL,
|
|
modules TEXT[] NOT NULL,
|
|
issued_at TIMESTAMPTZ NOT NULL,
|
|
valid_until TIMESTAMPTZ NOT NULL,
|
|
raw_key TEXT NOT NULL,
|
|
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
|
|
var tenantID string
|
|
if err := pool.QueryRow(ctx, `
|
|
INSERT INTO tenants (slug, name, db_name, db_dsn)
|
|
VALUES ('lic_test_tenant', 'Lic Test', 'tenant_lic_test', 'unused')
|
|
RETURNING id
|
|
`).Scan(&tenantID); err != nil {
|
|
t.Fatalf("test-tenant anlegen: %v", err)
|
|
}
|
|
|
|
pub, priv, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatalf("schluesselpaar: %v", err)
|
|
}
|
|
issuer := NewIssuer(priv)
|
|
store := NewStore(pool, NewValidator(pub))
|
|
|
|
cleanup := func() {
|
|
_, _ = pool.Exec(ctx, `DELETE FROM tenant_licenses WHERE tenant_id = $1`, tenantID)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID)
|
|
pool.Close()
|
|
}
|
|
return store, issuer, tenantID, cleanup
|
|
}
|
|
|
|
// Akzeptanzkriterium 3: Lizenzumfang persistiert und abfragbar.
|
|
func TestStore_InstallAndStatus(t *testing.T) {
|
|
store, issuer, tenantID, cleanup := setupStoreTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
payload := Payload{
|
|
TenantSlug: "lic_test_tenant",
|
|
Plan: "enterprise",
|
|
Modules: []string{"dms", "mail", "archive"},
|
|
IssuedAt: time.Now().Truncate(time.Second),
|
|
ValidUntil: time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second),
|
|
}
|
|
key, err := issuer.Issue(payload)
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
|
|
if _, err := store.Install(ctx, tenantID, key); err != nil {
|
|
t.Fatalf("install: %v", err)
|
|
}
|
|
|
|
status, err := store.Status(ctx, tenantID)
|
|
if err != nil {
|
|
t.Fatalf("status: %v", err)
|
|
}
|
|
if status.Plan != "enterprise" || len(status.Modules) != 3 {
|
|
t.Fatalf("status unerwartet: %+v", status)
|
|
}
|
|
}
|
|
|
|
func TestStore_InstallRejectsInvalidSignature(t *testing.T) {
|
|
store, _, tenantID, cleanup := setupStoreTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
_, otherPriv, _ := ed25519.GenerateKey(nil)
|
|
foreignIssuer := NewIssuer(otherPriv) // signiert mit falschem schluessel
|
|
|
|
key, err := foreignIssuer.Issue(Payload{TenantSlug: "lic_test_tenant", Plan: "pro", ValidUntil: time.Now().Add(time.Hour)})
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
|
|
if _, err := store.Install(ctx, tenantID, key); !errors.Is(err, ErrInvalidSignature) {
|
|
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 2: abgelaufene Lizenz fuehrt zu definiertem
|
|
// eingeschraenktem Zustand (ErrLicenseExpired), nicht zu einem Absturz.
|
|
func TestStore_RequireActive_DetectsExpiry(t *testing.T) {
|
|
store, issuer, tenantID, cleanup := setupStoreTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
expired := Payload{
|
|
TenantSlug: "lic_test_tenant",
|
|
Plan: "pro",
|
|
Modules: []string{"dms"},
|
|
IssuedAt: time.Now().Add(-48 * time.Hour),
|
|
ValidUntil: time.Now().Add(-24 * time.Hour), // bereits abgelaufen
|
|
}
|
|
key, err := issuer.Issue(expired)
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
|
|
// Einspielen einer bereits abgelaufenen, aber korrekt signierten Lizenz
|
|
// muss funktionieren (Ablauf wird erst bei Nutzung bewertet).
|
|
if _, err := store.Install(ctx, tenantID, key); err != nil {
|
|
t.Fatalf("install sollte trotz ablauf funktionieren: %v", err)
|
|
}
|
|
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("RequireActive hat gepanict statt einen fehler zu liefern: %v", r)
|
|
}
|
|
}()
|
|
if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrLicenseExpired) {
|
|
t.Fatalf("erwartet ErrLicenseExpired, habe %v", err)
|
|
}
|
|
}()
|
|
|
|
// Aber der Umfang bleibt weiterhin abfragbar (Status, im Unterschied zu RequireActive).
|
|
status, err := store.Status(ctx, tenantID)
|
|
if err != nil {
|
|
t.Fatalf("status sollte trotz ablauf funktionieren: %v", err)
|
|
}
|
|
if status.Plan != "pro" {
|
|
t.Fatalf("status unerwartet: %+v", status)
|
|
}
|
|
}
|
|
|
|
func TestStore_RequireActive_NoLicense(t *testing.T) {
|
|
store, _, tenantID, cleanup := setupStoreTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrNoLicense) {
|
|
t.Fatalf("erwartet ErrNoLicense, habe %v", err)
|
|
}
|
|
}
|