package licensing import ( "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" "errors" "testing" "time" "softbox.local/core/internal/canonicaljson" ) func TestRevocationVerifierAndStateBoundaries(t *testing.T) { publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } verifier, err := NewRevocationVerifier(publicKey) if err != nil { t.Fatal(err) } generated := time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC) document := signedRevocationDocument(t, privateKey, generated, generated.Add(24*time.Hour), []string{"lic-revoked"}) list, err := verifier.Verify(document) if err != nil { t.Fatalf("Verify() error = %v", err) } for _, test := range []struct { name string licenseID string now time.Time want RevocationState }{ {"current", "lic-active", generated.Add(time.Hour), RevocationStateCurrent}, {"revoked", "lic-revoked", generated.Add(time.Hour), RevocationStateRevoked}, {"at expiry", "lic-active", generated.Add(24 * time.Hour), RevocationStateCurrent}, {"at grace end", "lic-active", generated.Add(24*time.Hour + RevocationGrace), RevocationStateGrace}, {"after grace", "lic-active", generated.Add(24*time.Hour + RevocationGrace + time.Second), RevocationStateUnavailable}, {"before generated", "lic-active", generated.Add(-time.Second), RevocationStateUnavailable}, } { t.Run(test.name, func(t *testing.T) { if got := list.StateFor(test.licenseID, test.now); got != test.want { t.Fatalf("StateFor() = %q, want %q", got, test.want) } }) } list.RevokedLicenseIDs[0] = "lic-mutated" if again, err := verifier.Verify(document); err != nil || again.RevokedLicenseIDs[0] != "lic-revoked" { t.Fatalf("Verify() after output mutation = %#v, %v", again, err) } } func TestRevocationVerifierFailsClosed(t *testing.T) { publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } verifier, err := NewRevocationVerifier(publicKey) if err != nil { t.Fatal(err) } generated := time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC) valid := signedRevocationDocument(t, privateKey, generated, generated.Add(time.Hour), nil) tampered := append([]byte(nil), valid...) tampered[5] = 'x' tooLong := signedRevocationDocument(t, privateKey, generated, generated.Add(MaxRevocationValidity+time.Second), nil) for _, test := range []struct { name string document []byte want error }{ {"empty", nil, ErrInvalidRevocation}, {"tampered", tampered, ErrRevocationSignatureInvalid}, {"interval too long", tooLong, ErrInvalidRevocation}, {"duplicate", []byte(`{"schema_version":1,"schema_version":1}`), ErrDuplicateField}, {"missing signature", []byte(`{"schema_version":1,"generated_at":"2026-07-20T00:00:00Z","expires_at":"2026-07-20T01:00:00Z","revoked_license_ids":[]}`), ErrRevocationSignatureMissing}, } { t.Run(test.name, func(t *testing.T) { if _, err := verifier.Verify(test.document); !errors.Is(err, test.want) { t.Fatalf("Verify() error = %v, want %v", err, test.want) } }) } if _, err := NewRevocationVerifier(make([]byte, ed25519.PublicKeySize-1)); !errors.Is(err, ErrPublicKeyInvalid) { t.Fatalf("NewRevocationVerifier(short) error = %v", err) } } func signedRevocationDocument( t *testing.T, privateKey ed25519.PrivateKey, generated time.Time, expires time.Time, revoked []string, ) []byte { t.Helper() payload := map[string]any{ "schema_version": json.Number("1"), "generated_at": generated.Format(timestampLayout), "expires_at": expires.Format(timestampLayout), "revoked_license_ids": stringSliceAsAny(revoked), } canonical, err := canonicaljson.Marshal(payload) if err != nil { t.Fatal(err) } payload["signature"] = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) document, err := canonicaljson.Marshal(payload) if err != nil { t.Fatal(err) } return document } func stringSliceAsAny(values []string) []any { items := make([]any, len(values)) for index, value := range values { items[index] = value } return items }