Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ jobs:
func main() {
c, _ := keymint.New("key", "")
_, _ = c.ActivateKey(keymint.ActivateKeyParams{HostID: keymint.String("host")})
_, _ = legacy.New("key", "")
_, _ = c.DeleteKey(keymint.DeleteKeyParams{})
lc, _ := legacy.New("key", "")
_, _ = lc.DeleteKey(legacy.DeleteKeyParams{})
_, _ = keymint.VerifyOfflineLicense("", keymint.OfflineVerifyOptions{})
_, _ = legacy.VerifyOfflineLicense("", legacy.OfflineVerifyOptions{})
}
Expand All @@ -62,7 +64,7 @@ jobs:
go build ./...

- name: Run contract tests
run: go test ./... -run 'TestSignKey|TestGetKey|TestActivateKey|TestCreateKey|TestFloating|TestApiErrors|TestVerifyOfflineLicense' -v
run: go test ./... -run 'TestSignKey|TestGetKey|TestActivateKey|TestCreateKey|TestDeleteKey|TestFloating|TestApiErrors|TestVerifyOfflineLicense' -v

live:
name: Live API Validation
Expand Down
16 changes: 13 additions & 3 deletions Index.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ func parseApiError(body []byte, statusCode int) *ApiError {
}
}

// handleRequest is a generic method to handle POST/PUT requests.
// method: HTTP method (POST/PUT).
// handleRequest is a generic method to handle requests with a JSON body.
// method: HTTP method (POST/PUT/DELETE).
// endpoint: API endpoint.
// params: Request body parameters.
// result: Pointer to the result struct to unmarshal response into.
Expand Down Expand Up @@ -437,6 +437,16 @@ func (c *Client) UnblockKey(params UnblockKeyParams, opts ...*RequestOptions) (*
return &result, err
}

// DeleteKey permanently deletes a license key (use BlockKey to stop it reversibly).
// params: Parameters for deleting the key.
// opts: Optional request configurations (e.g. idempotency keys).
// Returns the delete confirmation or an error.
func (c *Client) DeleteKey(params DeleteKeyParams, opts ...*RequestOptions) (*DeleteKeyResponse, error) {
var result DeleteKeyResponse
err := c.handleRequest("DELETE", "/key", params, &result, opts...)
return &result, err
}

// UpdateKey updates an existing license key via PATCH /api/key.
func (c *Client) UpdateKey(params UpdateKeyParams, opts ...*RequestOptions) (*UpdateKeyResponse, error) {
var result UpdateKeyResponse
Expand Down Expand Up @@ -502,7 +512,7 @@ func (c *Client) UpdateCustomer(params UpdateCustomerParams, opts ...*RequestOpt
return &result, err
}

// DeleteCustomer deletes a customer and all associated license keys permanently.
// DeleteCustomer deletes a customer. Their license keys are kept; use DeleteKey to remove them.
// params: Parameters containing the customer ID.
// opts: Optional request configurations (e.g. idempotency keys).
// Returns the deletion confirmation or an error.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
| `UpdateKey` | Updates an existing license key. |
| `BlockKey` | Blocks a license key. |
| `UnblockKey` | Unblocks a previously blocked license key. |
| `DeleteKey` | Permanently deletes a key (`BlockKey` is reversible). |
| `SignKey` | Signs a key for offline (air-gapped) validation.|
| `FloatingCheckout` | Checks out a floating license seat. |
| `FloatingHeartbeat`| Sends a heartbeat to keep a session alive. |
Expand All @@ -204,7 +205,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
| `GetCustomerWithKeys` | Gets a customer along with their license keys. |
| `UpdateCustomer` | Updates customer information. |
| `ToggleCustomerStatus` | Toggles customer active status. |
| `DeleteCustomer` | Permanently deletes a customer and their keys. |
| `DeleteCustomer` | Deletes a customer. Their license keys are kept; use `DeleteKey` to remove them. |

### Webhook Verification

Expand Down
16 changes: 16 additions & 0 deletions Types.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,22 @@ type UnblockKeyResponse struct {
Code int `json:"code"`
}

// DeleteKeyParams represents parameters for the deleteKey API endpoint.
type DeleteKeyParams struct {
// ProductID is the unique identifier of the product.
ProductID string `json:"productId"`
// LicenseKey is the license key to delete permanently.
LicenseKey string `json:"licenseKey"`
}

// DeleteKeyResponse represents response structure for a successful deleteKey API call.
type DeleteKeyResponse struct {
// Message is the confirmation message (e.g., "Key deleted").
Message string `json:"message"`
// Code is the API response code (e.g., 0 for success).
Code int `json:"code"`
}

// CreateCustomerParams represents parameters for the createCustomer API endpoint.
type CreateCustomerParams struct {
// Name is the required customer name.
Expand Down
64 changes: 64 additions & 0 deletions contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,67 @@ func TestFloatingCheckoutDeserializesExpirationDate(t *testing.T) {
})
}
}

func TestDeleteKeySendsLicenseInBodyInsteadOfURL(t *testing.T) {
var gotMethod, gotPath, gotRawQuery, gotURI, gotAuth, gotContentType string
var body map[string]interface{}
c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotRawQuery = r.URL.RawQuery
gotURI = r.RequestURI
gotAuth = r.Header.Get("Authorization")
gotContentType = r.Header.Get("Content-Type")
_ = json.NewDecoder(r.Body).Decode(&body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":"Key deleted","code":0}`))
})
defer done()

res, err := c.DeleteKey(DeleteKeyParams{ProductID: "p", LicenseKey: "AAAA-BBBB"})
if err != nil {
t.Fatalf("DeleteKey failed: %v", err)
}
if gotMethod != http.MethodDelete {
t.Fatalf("expected DELETE, got %s", gotMethod)
}
if gotPath != "/key" {
t.Fatalf("unexpected path %s", gotPath)
}
if gotRawQuery != "" || strings.Contains(gotURI, "AAAA-BBBB") {
t.Fatalf("license key leaked into URL: %q", gotURI)
}
if gotAuth != "Bearer test-key" {
t.Fatalf("unexpected Authorization header %q", gotAuth)
}
if gotContentType != "application/json" {
t.Fatalf("unexpected Content-Type %q", gotContentType)
}
if body["productId"] != "p" || body["licenseKey"] != "AAAA-BBBB" {
t.Fatalf("unexpected body: %#v", body)
}
if res.Message != "Key deleted" || res.Code != 0 {
t.Fatalf("unexpected response: %#v", res)
}
}

func TestDeleteKeyReturnsNotFoundError(t *testing.T) {
c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"Invalid license key","code":1}`))
})
defer done()

_, err := c.DeleteKey(DeleteKeyParams{ProductID: "p", LicenseKey: "missing"})
apiErr, ok := err.(*ApiError)
if !ok {
t.Fatalf("expected *ApiError, got %T", err)
}
if apiErr.Message != "Invalid license key" || apiErr.Code != 1 {
t.Fatalf("unexpected error: %#v", apiErr)
}
if apiErr.Status == nil || *apiErr.Status != http.StatusNotFound {
t.Fatalf("status not captured: %#v", apiErr.Status)
}
}
59 changes: 53 additions & 6 deletions live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,23 @@ func live[T any](t *testing.T, what string, fn func() (T, error)) T {
return zero
}

// bestEffort is live() for cleanup: it retries 429s the same way but only logs
// a final failure (e.g. a 404 because the test already deleted the key).
func bestEffort[T any](t *testing.T, what string, fn func() (T, error)) {
t.Helper()
for attempt := 1; attempt <= 4; attempt++ {
_, err := fn()
if err == nil {
return
}
if !isRateLimited(err) || attempt == 4 {
t.Logf("%s (best effort) failed: %v", what, err)
return
}
time.Sleep(10 * time.Second)
}
}

func strptr(s string) *string { return &s }

func TestLiveLicenseWorkflowsEndToEnd(t *testing.T) {
Expand All @@ -64,15 +81,20 @@ func TestLiveLicenseWorkflowsEndToEnd(t *testing.T) {
nodeHost := "ci-go-node-" + runID
var nodeKey, floatingKey, customerID string

// Best-effort cleanup: delete (not just block) every key this run created so
// the shared test workspace does not fill up its plan's license limit.
defer func() {
if nodeKey != "" {
_, _ = admin.BlockKey(BlockKeyParams{ProductID: productID, LicenseKey: nodeKey})
}
if floatingKey != "" {
_, _ = admin.BlockKey(BlockKeyParams{ProductID: productID, LicenseKey: floatingKey})
for _, key := range []string{nodeKey, floatingKey} {
if key != "" {
bestEffort(t, "cleanup delete key", func() (*DeleteKeyResponse, error) {
return admin.DeleteKey(DeleteKeyParams{ProductID: productID, LicenseKey: key})
})
}
}
if customerID != "" {
_, _ = admin.DeleteCustomer(DeleteCustomerParams{CustomerID: customerID})
bestEffort(t, "cleanup delete customer", func() (*DeleteCustomerResponse, error) {
return admin.DeleteCustomer(DeleteCustomerParams{CustomerID: customerID})
})
}
}()

Expand Down Expand Up @@ -254,4 +276,29 @@ func TestLiveLicenseWorkflowsEndToEnd(t *testing.T) {
t.Fatal("delete reported failure")
}
customerID = ""

deletedKey := live(t, "delete node key", func() (*DeleteKeyResponse, error) {
return admin.DeleteKey(DeleteKeyParams{ProductID: productID, LicenseKey: nodeKey})
})
if deletedKey.Code != 0 {
t.Fatalf("delete key code %d (%q)", deletedKey.Code, deletedKey.Message)
}

// A deleted key must be gone: the lookup fails with a 404, not a 429.
var lookupErr error
for attempt := 1; attempt <= 4; attempt++ {
_, lookupErr = readOnly.GetKey(GetKeyParams{ProductID: productID, LicenseKey: nodeKey})
if !isRateLimited(lookupErr) {
break
}
time.Sleep(10 * time.Second)
}
if lookupErr == nil {
t.Fatal("lookup of a deleted key succeeded")
}
apiErr, ok := lookupErr.(*ApiError)
if !ok || apiErr.Status == nil || *apiErr.Status != 404 {
t.Fatalf("expected 404 looking up a deleted key, got %v", lookupErr)
}
nodeKey = ""
}
2 changes: 2 additions & 0 deletions src/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ type (
BlockKeyResponse = km.BlockKeyResponse
UnblockKeyParams = km.UnblockKeyParams
UnblockKeyResponse = km.UnblockKeyResponse
DeleteKeyParams = km.DeleteKeyParams
DeleteKeyResponse = km.DeleteKeyResponse
CreateCustomerParams = km.CreateCustomerParams
CreateCustomerResponse = km.CreateCustomerResponse
Customer = km.Customer
Expand Down
Loading