206 lines
7.7 KiB
Go
206 lines
7.7 KiB
Go
package platform
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/b1tsblog/license-platform/pkg/licensekit"
|
|
)
|
|
|
|
func testServer(t *testing.T, store *Store) *Server {
|
|
t.Helper()
|
|
master := base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
|
|
server, err := New(Config{Brand: "Test Platform", PublicURL: "https://licenses.example.test", MasterKey: master, BootstrapUsername: "admin", BootstrapPassword: "a-very-long-test-password", AdminAPIToken: "api-secret-0123456789-0123456789-ab", SessionTTL: time.Hour}, store, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return server
|
|
}
|
|
|
|
func TestPasswordHash(t *testing.T) {
|
|
hash, err := HashPassword("a-very-long-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !VerifyPassword(hash, "a-very-long-password") || VerifyPassword(hash, "wrong-password") {
|
|
t.Fatal("password verification mismatch")
|
|
}
|
|
}
|
|
|
|
func TestKeysAreWriteOnceAndEncrypted(t *testing.T) {
|
|
path := t.TempDir() + "/platform.json"
|
|
store, err := OpenStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := testServer(t, store)
|
|
if err := server.generateKeys("issuer", "lease"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := server.generateKeys("issuer-2", "lease-2"); err != ErrKeysLocked {
|
|
t.Fatalf("expected ErrKeysLocked, got %v", err)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(data), "private") && strings.Contains(string(data), "MC4CAQ") {
|
|
t.Fatal("data store appears to contain a plaintext private key")
|
|
}
|
|
keys, ok := store.KeySet()
|
|
if !ok || !strings.HasPrefix(keys.IssuerPrivateCipher, "v1.") || !strings.HasPrefix(keys.LeasePrivateCipher, "v1.") {
|
|
t.Fatal("private keys are not encrypted")
|
|
}
|
|
}
|
|
|
|
func TestIssueValidateAndRevoke(t *testing.T) {
|
|
store, _ := OpenStore("")
|
|
server := testServer(t, store)
|
|
if err := server.generateKeys("issuer", "lease"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
admin, ok := store.FindUserByUsername("admin")
|
|
if !ok {
|
|
t.Fatal("bootstrap admin missing")
|
|
}
|
|
record, token, err := server.issueLicense(admin, IssueInput{Customer: "ACME", Product: "product", Edition: "pro", Features: []string{"feature"}, Domains: []string{"*"}, Mode: "hybrid", Days: 30, LeaseMinutes: 60, GraceHours: 72})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
trust, _ := server.trustStore()
|
|
verified, err := licensekit.VerifyLicense(trust, token, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if verified.Claims.Verification.ServerURL != "https://licenses.example.test" {
|
|
t.Fatalf("unexpected embedded server URL %q", verified.Claims.Verification.ServerURL)
|
|
}
|
|
response, err := server.validateLicense(validateRequest{Token: token, Product: "product", BaseURL: "https://app.example.test"})
|
|
if err != nil || !response.Valid || response.LeaseToken == "" {
|
|
t.Fatalf("validation failed: %#v %v", response, err)
|
|
}
|
|
if err := store.SetRevoked(record.LicenseID, true, "payment"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := server.validateLicense(validateRequest{Token: token, Product: "product", BaseURL: "https://app.example.test"}); err == nil {
|
|
t.Fatal("revoked license was accepted")
|
|
}
|
|
}
|
|
|
|
func TestManagementAndValidationAPI(t *testing.T) {
|
|
store, _ := OpenStore("")
|
|
server := testServer(t, store)
|
|
if err := server.generateKeys("issuer", "lease"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ts := httptest.NewServer(server.Handler())
|
|
defer ts.Close()
|
|
issue := IssueInput{Customer: "API Customer", Product: "api-product", Edition: "team", Domains: []string{"*"}, Mode: "hybrid", Days: 10, LeaseMinutes: 30, GraceHours: 24}
|
|
body, _ := json.Marshal(issue)
|
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, ts.URL+"/api/v1/licenses", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer api-secret-0123456789-0123456789-ab")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var created LicenseView
|
|
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated || created.Token == "" {
|
|
t.Fatalf("issue API failed: %d %#v", resp.StatusCode, created)
|
|
}
|
|
validation, _ := json.Marshal(validateRequest{Token: created.Token, Product: "api-product", BaseURL: "https://customer.example.test"})
|
|
resp, err = http.Post(ts.URL+"/api/v1/licenses/validate", "application/json", bytes.NewReader(validation))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
payload, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("validation API failed: %d %s", resp.StatusCode, payload)
|
|
}
|
|
}
|
|
|
|
func TestResellerCustomerIsolation(t *testing.T) {
|
|
store, _ := OpenStore("")
|
|
server := testServer(t, store)
|
|
if err := server.generateKeys("issuer", "lease"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash, _ := HashPassword("a-very-long-customer-password")
|
|
resellerA := User{ID: "reseller_a", Username: "reseller.a", DisplayName: "Reseller A", Role: RoleReseller, PasswordHash: hash, Active: true}
|
|
resellerB := User{ID: "reseller_b", Username: "reseller.b", DisplayName: "Reseller B", Role: RoleReseller, PasswordHash: hash, Active: true}
|
|
customer := User{ID: "customer_a", Username: "customer.a", DisplayName: "Customer A", Role: RoleCustomer, ParentUserID: resellerA.ID, PasswordHash: hash, Active: true}
|
|
for _, user := range []User{resellerA, resellerB, customer} {
|
|
if err := store.CreateUser(user); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
input := IssueInput{CustomerUserID: customer.ID, Customer: customer.DisplayName, Product: "product", Edition: "pro", Domains: []string{"*"}, Mode: "hybrid", Days: 30, LeaseMinutes: 60, GraceHours: 24}
|
|
if _, _, err := server.issueLicense(resellerB, input); err == nil {
|
|
t.Fatal("unassigned reseller issued a license for another reseller's customer")
|
|
}
|
|
record, _, err := server.issueLicense(resellerA, input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := store.ListLicensesFor(resellerB); len(got) != 0 {
|
|
t.Fatalf("reseller B can see %d foreign licenses", len(got))
|
|
}
|
|
if got := store.ListLicensesFor(customer); len(got) != 1 || got[0].LicenseID != record.LicenseID {
|
|
t.Fatalf("customer portal did not receive its license: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestLegacyRegistrationCompatibility(t *testing.T) {
|
|
store, _ := OpenStore("")
|
|
server := testServer(t, store)
|
|
if err := server.generateKeys("issuer", "lease"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys, _ := store.KeySet()
|
|
encoded, err := server.vault.Decrypt(keys.IssuerPrivateCipher)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
privateKey, err := licensekit.DecodePrivateKey(encoded)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now().UTC()
|
|
token, err := licensekit.SignLicense(privateKey, keys.IssuerKeyID, licensekit.Claims{Version: 1, LicenseID: "legacy-license", Issuer: "legacy", Customer: "Legacy Customer", Product: "legacy-product", Edition: "pro", Domains: []string{"*"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(24 * time.Hour).Unix(), Verification: licensekit.VerificationPolicy{Mode: licensekit.ModeHybrid, ServerURL: "https://licenses.example.test"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ts := httptest.NewServer(server.Handler())
|
|
defer ts.Close()
|
|
body, _ := json.Marshal(map[string]string{"token": token})
|
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/admin/licenses", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer api-secret-0123456789-0123456789-ab")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("legacy register status %d", resp.StatusCode)
|
|
}
|
|
if _, ok := store.GetLicense("legacy-license"); !ok {
|
|
t.Fatal("legacy license was not imported")
|
|
}
|
|
}
|