diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fb5b3c..2e6dc71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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{}) } @@ -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 diff --git a/Index.go b/Index.go index fd9f301..94850fa 100644 --- a/Index.go +++ b/Index.go @@ -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. @@ -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 @@ -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. diff --git a/README.md b/README.md index 62334dc..00c1f13 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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 diff --git a/Types.go b/Types.go index e496065..a788e29 100644 --- a/Types.go +++ b/Types.go @@ -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. diff --git a/contract_test.go b/contract_test.go index 1374e2c..92be84c 100644 --- a/contract_test.go +++ b/contract_test.go @@ -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) + } +} diff --git a/live_test.go b/live_test.go index d07abcd..e45ec09 100644 --- a/live_test.go +++ b/live_test.go @@ -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) { @@ -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}) + }) } }() @@ -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 = "" } diff --git a/src/compat.go b/src/compat.go index a250f96..6b2fffc 100644 --- a/src/compat.go +++ b/src/compat.go @@ -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