From 9a5a5db2c4aa8a7d780d2c57f67a3713a6da0895 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Thu, 11 Jun 2026 01:00:08 +0530 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20operate-verb=20parity=20with=20MCP?= =?UTF-8?q?=20=E2=80=94=20vault=20write/rotate,=20env=20patch,=20presign,?= =?UTF-8?q?=20pause/resume,=20wake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP PR #41 shipped 9 operate tools; the SDK covered only 3 of the 11 operate-verb surfaces (RotateCredentials, Capabilities, DeploymentEvents). This adds the missing 8 methods, each verified against the MCP client and api openapi.go/handlers for exact endpoint, payload, and envelope: - SetVaultKey / RotateVaultKey PUT /api/v1/vault/:env/:key (+/rotate) - UpdateDeployEnv PATCH /deploy/:id/env - UpdateStackEnv PATCH /stacks/:slug/env - PresignStorage POST /storage/:token/presign (broker mode, token-as-credential, no bearer required) - PauseResource / ResumeResource POST /api/v1/resources/:id/{pause,resume} - WakeDeployment POST /deploy/:id/wake (surfaces the 501 scale_to_zero_disabled flag gate) All 8 are quick mutations, so they run on the 30s read-path client (none are synchronous provisions — the 120s provisioning deadline from #21 stays scoped to the /*/new + deploy/stack create endpoints). Path segments are url.PathEscape'd so vault keys with unusual characters round-trip. New putJSON/patchJSON client helpers carry the same retry + APIError-envelope semantics as the existing verbs. Tests: httptest-based per-method suites asserting method, path, body wire shape, response decode, validation branches, and one APIError branch each (402 tier gate, 409 not_paused, 410 resource_inactive, 501 flag gate). 100% statement coverage on every new function; package total 98.5%. README method tables + CHANGELOG updated; vault/env-patch removed from the "not covered" list (also added the missing ProvisionStorage / ProvisionWebhook rows). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 23 ++++ README.md | 29 ++++- instant/client.go | 21 ++++ instant/client_helpers_test.go | 30 +++++ instant/deploy_ops.go | 127 +++++++++++++++++++++ instant/deploy_ops_test.go | 124 +++++++++++++++++++++ instant/resource_lifecycle.go | 105 ++++++++++++++++++ instant/resource_lifecycle_test.go | 106 ++++++++++++++++++ instant/stack_env.go | 71 ++++++++++++ instant/stack_env_test.go | 79 +++++++++++++ instant/storage_presign.go | 102 +++++++++++++++++ instant/storage_presign_test.go | 102 +++++++++++++++++ instant/vault.go | 128 +++++++++++++++++++++ instant/vault_test.go | 171 +++++++++++++++++++++++++++++ 14 files changed, 1214 insertions(+), 4 deletions(-) create mode 100644 instant/client_helpers_test.go create mode 100644 instant/deploy_ops.go create mode 100644 instant/deploy_ops_test.go create mode 100644 instant/resource_lifecycle.go create mode 100644 instant/resource_lifecycle_test.go create mode 100644 instant/stack_env.go create mode 100644 instant/stack_env_test.go create mode 100644 instant/storage_presign.go create mode 100644 instant/storage_presign_test.go create mode 100644 instant/vault.go create mode 100644 instant/vault_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e248d86..3d94c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,29 @@ existing callers. ### Added +- **Operate-verb parity with the MCP server (MCP PR #41).** Eight new methods + drive the full bundle lifecycle after provisioning, all on the 30 s read + client (none are synchronous provisions) and all surfacing `*APIError` with + the full agent-native envelope: + - `SetVaultKey` / `RotateVaultKey` (`PUT /api/v1/vault/:env/:key`, + `POST /api/v1/vault/:env/:key/rotate`) — write/rotate an encrypted secret; + every write mints a new version and the plaintext is never echoed back. + Reference stored secrets from deploys as `vault:///`. + - `UpdateDeployEnv` (`PATCH /deploy/:id/env`) — merge env vars into an + existing deployment; returns the merged (redacted) map plus the + redeploy-required note. + - `UpdateStackEnv` (`PATCH /stacks/:slug/env`) — same merge semantics for + stacks; an empty-string value deletes that key. + - `PresignStorage` (`POST /storage/:token/presign`) — mint a short-lived + (≤1 h) presigned S3 URL scoped to the resource's tenant prefix. Broker + mode: the token in the URL is the credential, no API key needed. + - `PauseResource` / `ResumeResource` + (`POST /api/v1/resources/:id/{pause,resume}`) — suspend a resource without + deleting it (Pro+ to pause; resume is never tier-gated) and bring it back + with the connection URL unchanged. + - `WakeDeployment` (`POST /deploy/:id/wake`) — explicitly wake a + scaled-to-zero deployment; surfaces the server's 501 + `scale_to_zero_disabled` when the platform flag is off. - **`CreateStack` + `GetStack` — the anonymous deploy path.** `Deploy` (`POST /deploy/new`) requires an API key, so an unauthenticated agent could not deploy through the SDK at all. `CreateStack` wraps `POST /stacks/new`, diff --git a/README.md b/README.md index 27f36f2..1e4daf5 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ func main() { | `ProvisionMongoDB` | `(ctx, *ProvisionOpts) (*ProvisionResult, error)` | MongoDB database + scoped user | | `ProvisionQueue` | `(ctx, *ProvisionOpts) (*ProvisionResult, error)` | NATS JetStream stream | | `ProvisionVector` | `(ctx, *VectorOpts) (*VectorResult, error)` | pgvector-enabled Postgres (POST /vector/new) | +| `ProvisionStorage` | `(ctx, *ProvisionOpts) (*StorageResult, error)` | S3-compatible object-storage prefix (POST /storage/new) | +| `ProvisionWebhook` | `(ctx, *ProvisionOpts) (*WebhookResult, error)` | Webhook receiver URL (POST /webhook/new) | ### Deployment @@ -67,6 +69,22 @@ func main() { | `CreateStack` | `(ctx, CreateStackOpts) (*Stack, error)` | Deploy a multi-service stack (POST /stacks/new — the **anonymous** deploy path; works without an API key) | | `GetStack` | `(ctx, slug string) (*Stack, error)` | Poll a stack's status + per-service URLs (GET /stacks/:slug) | | `DeploymentEvents` | `(ctx, id string, limit int) (*DeploymentEventList, error)` | Failure-autopsy timeline for a deploy (GET /api/v1/deployments/:id/events) | +| `UpdateDeployEnv` | `(ctx, id string, env map[string]string) (*DeployEnvUpdate, error)` | Merge env vars into a deployment; redeploy to apply (PATCH /deploy/:id/env) | +| `UpdateStackEnv` | `(ctx, slug string, env map[string]string) (*StackEnvUpdate, error)` | Merge env vars into a stack; empty value deletes the key (PATCH /stacks/:slug/env) | +| `WakeDeployment` | `(ctx, id string) (*WakeResult, error)` | Wake a scaled-to-zero deployment (POST /deploy/:id/wake; 501 when the platform flag is off) | + +### Vault (requires API key, paid tier) + +| Method | Signature | Description | +|---|---|---| +| `SetVaultKey` | `(ctx, env, key, value string) (*VaultWriteResult, error)` | Store an encrypted secret as a new version (PUT /api/v1/vault/:env/:key) | +| `RotateVaultKey` | `(ctx, env, key, value string) (*VaultWriteResult, error)` | Rotate a secret — new value, version+1, distinct audit action (POST /api/v1/vault/:env/:key/rotate) | + +### Storage access + +| Method | Signature | Description | +|---|---|---| +| `PresignStorage` | `(ctx, token string, PresignOpts) (*PresignResult, error)` | Mint a ≤1h presigned S3 URL (POST /storage/:token/presign — the token is the credential, no API key needed) | ### Resource Management (requires API key) @@ -76,6 +94,8 @@ func main() { | `GetResource` | `(ctx, token string) (*Resource, error)` | Get a resource by token | | `DeleteResource` | `(ctx, token string) error` | Soft-delete a resource | | `RotateCredentials` | `(ctx, token string) (*RotateResult, error)` | New password → return updated connection URL | +| `PauseResource` | `(ctx, token string) (*PauseResumeResult, error)` | Suspend without deleting — storage kept, connections refused (POST /api/v1/resources/:id/pause, Pro+) | +| `ResumeResource` | `(ctx, token string) (*PauseResumeResult, error)` | Un-pause — connection URL unchanged (POST /api/v1/resources/:id/resume) | ### Account & Claiming @@ -222,10 +242,11 @@ for _, e := range evs.Events { This SDK exposes a focused slice of the platform surface. The full agent API documents ~90+ additional endpoints across deployments management (`GET /deploy/:id`, -`PATCH /deploy/:id/env`, `POST /deploy/:id/redeploy`, `DELETE /deploy/:id`, logs SSE), -stack mutation (`PATCH /stacks/:slug/env`, `POST /stacks/:slug/redeploy`, -`DELETE /stacks/:slug`), billing (`POST /api/v1/billing/checkout`, -`/api/v1/billing/usage`), team management, env-twin / promotion, vault, audit, webhook +`POST /deploy/:id/redeploy`, `DELETE /deploy/:id`), +stack mutation (`POST /stacks/:slug/redeploy`, +`DELETE /stacks/:slug`), vault reads (`GET /api/v1/vault/:env[/:key]`, +`POST /api/v1/vault/copy`), billing (`POST /api/v1/billing/checkout`, +`/api/v1/billing/usage`), team management, env-twin / promotion, audit, webhook receivers, custom domains, GitHub App connections, and more. See the [full OpenAPI](https://api.instanode.dev/openapi.json) for the canonical list. diff --git a/instant/client.go b/instant/client.go index cfb103d..acefa11 100644 --- a/instant/client.go +++ b/instant/client.go @@ -300,6 +300,27 @@ func (c *Client) postJSONWithHeaders(ctx context.Context, path string, body any, return c.doWithHeaders(ctx, http.MethodPost, path, r, headers, out) } +// putJSON executes a PUT request with a JSON-encoded body and decodes the +// response. It runs on the read-path client (defaultTimeout) — every PUT on +// the API surface is a quick mutation, not a synchronous provision. +func (c *Client) putJSON(ctx context.Context, path string, body any, out any) error { + r, err := jsonBodyReader(body) + if err != nil { + return err + } + return c.doWithHeaders(ctx, http.MethodPut, path, r, nil, out) +} + +// patchJSON executes a PATCH request with a JSON-encoded body and decodes the +// response. Read-path timeout class, same rationale as putJSON. +func (c *Client) patchJSON(ctx context.Context, path string, body any, out any) error { + r, err := jsonBodyReader(body) + if err != nil { + return err + } + return c.doWithHeaders(ctx, http.MethodPatch, path, r, nil, out) +} + // provisionJSONWithHeaders POSTs a JSON body for a synchronous provisioning // call. It routes through provisionClient (no client-wide Timeout cap) and // applies the longer provisioning deadline via the request context, so a slow diff --git a/instant/client_helpers_test.go b/instant/client_helpers_test.go new file mode 100644 index 0000000..68eb5ca --- /dev/null +++ b/instant/client_helpers_test.go @@ -0,0 +1,30 @@ +package instant + +// client_helpers_test.go — pins the marshal-error branches of the putJSON / +// patchJSON helpers introduced for the operate verbs. No public method can +// reach them (every operate body is a plain struct), so they are exercised +// directly with an unmarshalable body. + +import ( + "context" + "strings" + "testing" +) + +func TestPutJSON_MarshalError(t *testing.T) { + c := New(WithBaseURL("http://127.0.0.1:0")) + // A func value is not JSON-serializable — json.Marshal must fail before + // any network I/O happens. + err := c.putJSON(context.Background(), "/x", func() {}, nil) + if err == nil || !strings.Contains(err.Error(), "marshalling request body") { + t.Errorf("err = %v, want marshalling error", err) + } +} + +func TestPatchJSON_MarshalError(t *testing.T) { + c := New(WithBaseURL("http://127.0.0.1:0")) + err := c.patchJSON(context.Background(), "/x", func() {}, nil) + if err == nil || !strings.Contains(err.Error(), "marshalling request body") { + t.Errorf("err = %v, want marshalling error", err) + } +} diff --git a/instant/deploy_ops.go b/instant/deploy_ops.go new file mode 100644 index 0000000..1d9bc14 --- /dev/null +++ b/instant/deploy_ops.go @@ -0,0 +1,127 @@ +package instant + +// deploy_ops.go — operate verbs on an existing deployment: env-var mutation +// (PATCH /deploy/:id/env) and the scale-to-zero explicit wake +// (POST /deploy/:id/wake). Both live on the /deploy group alongside +// /deploy/new — NOT under /api/v1/deployments — mirroring the API's routing +// (api/internal/handlers/deploy.go). + +import ( + "context" + "fmt" + "net/url" +) + +const ( + // deployPathPrefix is the deployment operate-verb endpoint family. The + // deployment's public app_id is appended as a path-escaped segment. + deployPathPrefix = "/deploy/" + + // deployEnvSuffix is the PATCH env-merge sub-resource. + deployEnvSuffix = "/env" + + // deployWakeSuffix is the scale-to-zero explicit-wake sub-resource. + deployWakeSuffix = "/wake" +) + +// DeployEnvUpdate is returned by [Client.UpdateDeployEnv]. +type DeployEnvUpdate struct { + // OK is always true on success. + OK bool `json:"ok"` + + // Env is the FULL merged env map after the update, with secret values + // redacted (consistent with GET /deploy/:id). The stored server-side map + // is unredacted; only the response JSON is masked. + Env map[string]string `json:"env"` + + // Note reminds the caller that a redeploy is required to apply the + // change (e.g. "Run POST /deploy//redeploy to apply changes."). + Note string `json:"note,omitempty"` +} + +// WakeResult is returned by [Client.WakeDeployment]. +type WakeResult struct { + // OK is always true on success. + OK bool `json:"ok"` + + // Message is the server's human-readable confirmation. + Message string `json:"message,omitempty"` + + // Deployment is the refreshed deployment record (sleeping state cleared). + // Nil if the server omitted it. + Deployment *Deployment `json:"deployment,omitempty"` +} + +// envUpdateBody is the JSON body for PATCH /deploy/:id/env and +// PATCH /stacks/:slug/env — both take {"env": {...}}. +type envUpdateBody struct { + Env map[string]string `json:"env"` +} + +// UpdateDeployEnv merges env vars into an existing deployment via +// PATCH /deploy/:id/env. +// +// id is the deployment's public app id ([Deployment.AppID]). The API MERGES +// the supplied keys into the deployment's existing env vars (incoming wins on +// collision) and returns the full merged map with secret values redacted. +// Values prefixed with "vault://" are stored verbatim and resolved at the +// next redeploy. Requires a valid API key; a missing or other-team deployment +// returns a 404 *APIError. +// +// The change is persisted but NOT applied until the deployment is redeployed +// — the returned [DeployEnvUpdate.Note] says so. +// +// Example: +// +// res, err := client.UpdateDeployEnv(ctx, d.AppID, map[string]string{ +// "FEATURE_X": "on", +// "API_KEY": "vault://production/API_KEY", +// }) +// if err != nil { log.Fatal(err) } +// fmt.Println(res.Note) +func (c *Client) UpdateDeployEnv(ctx context.Context, id string, env map[string]string) (*DeployEnvUpdate, error) { + if id == "" { + return nil, fmt.Errorf("UpdateDeployEnv: id is required") + } + if len(env) == 0 { + return nil, fmt.Errorf("UpdateDeployEnv: env must be a non-empty map") + } + var out DeployEnvUpdate + path := deployPathPrefix + url.PathEscape(id) + deployEnvSuffix + if err := c.patchJSON(ctx, path, envUpdateBody{Env: env}, &out); err != nil { + return nil, fmt.Errorf("UpdateDeployEnv: %w", err) + } + return &out, nil +} + +// WakeDeployment explicitly wakes a scaled-to-zero (sleeping) deployment via +// POST /deploy/:id/wake. +// +// id is the deployment's public app id ([Deployment.AppID]). On success the +// API scales the app back to one replica and refreshes its last-activity +// marker; the app becomes reachable once its pod is Ready (a one-time cold +// start — a request racing the wake gets the ingress's upstream-down response +// until then). Idempotent: waking an already-awake app just refreshes the +// activity marker. +// +// The wake surface is FLAG-GATED server-side: when scale-to-zero is not +// enabled on the platform (the default) the API returns a 501 *APIError with +// code "scale_to_zero_disabled". A transient scaling failure returns 503 +// ("wake_failed") — safe to retry. Requires a valid API key; cross-tenant ids +// return 404. +// +// Example: +// +// res, err := client.WakeDeployment(ctx, d.AppID) +// if err != nil { log.Fatal(err) } +// fmt.Println(res.Message) +func (c *Client) WakeDeployment(ctx context.Context, id string) (*WakeResult, error) { + if id == "" { + return nil, fmt.Errorf("WakeDeployment: id is required") + } + var out WakeResult + if err := c.post(ctx, deployPathPrefix+url.PathEscape(id)+deployWakeSuffix, &out); err != nil { + return nil, fmt.Errorf("WakeDeployment: %w", err) + } + return &out, nil +} diff --git a/instant/deploy_ops_test.go b/instant/deploy_ops_test.go new file mode 100644 index 0000000..a858f8a --- /dev/null +++ b/instant/deploy_ops_test.go @@ -0,0 +1,124 @@ +package instant + +// deploy_ops_test.go — exercises UpdateDeployEnv (PATCH /deploy/:id/env) and +// WakeDeployment (POST /deploy/:id/wake) against httptest servers mirroring +// the api deploy handler envelopes. + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" +) + +func TestUpdateDeployEnv_HappyPath(t *testing.T) { + srv, call := newOperateServer(t, http.StatusOK, + `{"ok":true,"env":{"FEATURE_X":"on","SECRET":"se****et"},"note":"Run POST /deploy/abc12345/redeploy to apply changes."}`) + + c := New(WithBaseURL(srv.URL), WithAPIKey("k")) + res, err := c.UpdateDeployEnv(context.Background(), "abc12345", map[string]string{"FEATURE_X": "on"}) + if err != nil { + t.Fatalf("UpdateDeployEnv: %v", err) + } + + if call.Method != http.MethodPatch { + t.Errorf("method = %q, want PATCH", call.Method) + } + if call.Path != "/deploy/abc12345/env" { + t.Errorf("path = %q", call.Path) + } + var body struct { + Env map[string]string `json:"env"` + } + if err := json.Unmarshal([]byte(call.Body), &body); err != nil { + t.Fatalf("body decode: %v (raw %q)", err, call.Body) + } + if body.Env["FEATURE_X"] != "on" { + t.Errorf("body env = %v", body.Env) + } + if !res.OK || res.Env["FEATURE_X"] != "on" || res.Env["SECRET"] != "se****et" { + t.Errorf("result = %+v", res) + } + if !strings.Contains(res.Note, "redeploy") { + t.Errorf("Note = %q, want redeploy reminder", res.Note) + } +} + +func TestUpdateDeployEnv_ValidationErrors(t *testing.T) { + c := New(WithBaseURL("http://127.0.0.1:0")) + + if _, err := c.UpdateDeployEnv(context.Background(), "", map[string]string{"K": "v"}); err == nil || + !strings.Contains(err.Error(), "id is required") { + t.Errorf("empty id: err = %v", err) + } + if _, err := c.UpdateDeployEnv(context.Background(), "abc12345", nil); err == nil || + !strings.Contains(err.Error(), "non-empty map") { + t.Errorf("empty env: err = %v", err) + } +} + +func TestUpdateDeployEnv_APIError(t *testing.T) { + srv, _ := newOperateServer(t, http.StatusNotFound, + `{"ok":false,"error":"not_found","message":"Deployment not found"}`) + + _, err := New(WithBaseURL(srv.URL)).UpdateDeployEnv( + context.Background(), "abc12345", map[string]string{"K": "v"}) + if err == nil || !strings.Contains(err.Error(), "UpdateDeployEnv") { + t.Fatalf("expected UpdateDeployEnv-prefixed error, got %v", err) + } + if !IsNotFound(err) { + t.Errorf("expected 404 APIError, got %v", err) + } +} + +func TestWakeDeployment_HappyPath(t *testing.T) { + srv, call := newOperateServer(t, http.StatusOK, + `{"ok":true,"message":"Deployment woken — the app will be reachable once its pod is Ready (cold start).","deployment":{"id":"3f4a","app_id":"abc12345","status":"deploying","url":"https://abc12345.deployment.instanode.dev"}}`) + + c := New(WithBaseURL(srv.URL), WithAPIKey("k")) + res, err := c.WakeDeployment(context.Background(), "abc12345") + if err != nil { + t.Fatalf("WakeDeployment: %v", err) + } + + if call.Method != http.MethodPost { + t.Errorf("method = %q, want POST", call.Method) + } + if call.Path != "/deploy/abc12345/wake" { + t.Errorf("path = %q", call.Path) + } + if call.Body != "" { + t.Errorf("body = %q, want empty", call.Body) + } + if !res.OK || !strings.Contains(res.Message, "woken") { + t.Errorf("result = %+v", res) + } + if res.Deployment == nil || res.Deployment.AppID != "abc12345" || res.Deployment.Status != "deploying" { + t.Errorf("Deployment = %+v", res.Deployment) + } +} + +func TestWakeDeployment_ValidationError(t *testing.T) { + c := New(WithBaseURL("http://127.0.0.1:0")) + if _, err := c.WakeDeployment(context.Background(), ""); err == nil || + !strings.Contains(err.Error(), "id is required") { + t.Errorf("empty id: err = %v", err) + } +} + +func TestWakeDeployment_FlagGated501(t *testing.T) { + srv, _ := newOperateServer(t, http.StatusNotImplemented, + `{"ok":false,"error":"scale_to_zero_disabled","message":"Scale-to-zero is not enabled on this platform."}`) + + _, err := New(WithBaseURL(srv.URL)).WakeDeployment(context.Background(), "abc12345") + if err == nil || !strings.Contains(err.Error(), "WakeDeployment") { + t.Fatalf("expected WakeDeployment-prefixed error, got %v", err) + } + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotImplemented || + apiErr.CanonicalCode() != "scale_to_zero_disabled" { + t.Errorf("expected 501 scale_to_zero_disabled APIError, got %v", err) + } +} diff --git a/instant/resource_lifecycle.go b/instant/resource_lifecycle.go new file mode 100644 index 0000000..29f3149 --- /dev/null +++ b/instant/resource_lifecycle.go @@ -0,0 +1,105 @@ +package instant + +// resource_lifecycle.go — pause / resume operate verbs on a provisioned +// resource (POST /api/v1/resources/:id/{pause,resume}, +// api/internal/handlers/resource.go Pause/Resume). + +import ( + "context" + "fmt" + "net/url" +) + +const ( + // resourcePathPrefix is the authenticated resource-management endpoint + // family. The resource token is appended as a path-escaped segment. + resourcePathPrefix = "/api/v1/resources/" + + // resourcePauseSuffix is the suspend sub-resource. + resourcePauseSuffix = "/pause" + + // resourceResumeSuffix is the un-pause sub-resource. + resourceResumeSuffix = "/resume" +) + +// PauseResumeResult is returned by [Client.PauseResource] and +// [Client.ResumeResource]. +type PauseResumeResult struct { + // OK is always true on success. + OK bool `json:"ok"` + + // ID is the internal resource UUID. + ID string `json:"id"` + + // Token is the resource token (echo of the value passed in). + Token string `json:"token"` + + // Status is the resulting lifecycle state: "paused" after a pause, + // "active" after a resume. + Status string `json:"status"` + + // Message is the server's human-readable confirmation. + Message string `json:"message,omitempty"` + + // Resource is the refreshed structured resource record. Nil if the + // server omitted it. + Resource *Resource `json:"resource,omitempty"` +} + +// PauseResource suspends a resource WITHOUT deleting it via +// POST /api/v1/resources/:id/pause. +// +// token is the resource token (the same value the Provision* methods return). +// Storage is preserved and the connection URL is unchanged; the provider-side +// credential is revoked so new connections are refused until +// [Client.ResumeResource]. Paused resources stop counting against the +// per-type resource quota, but their storage still counts toward the storage +// cap. +// +// Tier-gated to Pro+ — lower tiers receive a 402 *APIError with an +// agent_action / upgrade_url. Pausing an already-paused resource returns a +// 409 ("already_paused"). Requires a valid API key; a missing or other-team +// token returns 404. +// +// Example: +// +// res, err := client.PauseResource(ctx, db.Token) +// if err != nil { log.Fatal(err) } +// fmt.Println(res.Status) // "paused" +func (c *Client) PauseResource(ctx context.Context, token string) (*PauseResumeResult, error) { + if token == "" { + return nil, fmt.Errorf("PauseResource: token is required") + } + var out PauseResumeResult + if err := c.post(ctx, resourcePathPrefix+url.PathEscape(token)+resourcePauseSuffix, &out); err != nil { + return nil, fmt.Errorf("PauseResource: %w", err) + } + return &out, nil +} + +// ResumeResource flips a paused resource back to "active" via +// POST /api/v1/resources/:id/resume. +// +// The connection URL is preserved unchanged — same password, same host, same +// database name — so any existing client config keeps working. There is no +// tier gate on resume: a team that owns a paused resource can always un-pause +// it regardless of its current plan tier (the Pro+ gate is enforced at pause +// time). Resuming a resource that is not paused returns a 409 *APIError +// ("not_paused"). Requires a valid API key; a missing or other-team token +// returns 404. +// +// Example: +// +// res, err := client.ResumeResource(ctx, db.Token) +// if err != nil { log.Fatal(err) } +// fmt.Println(res.Status) // "active" +func (c *Client) ResumeResource(ctx context.Context, token string) (*PauseResumeResult, error) { + if token == "" { + return nil, fmt.Errorf("ResumeResource: token is required") + } + var out PauseResumeResult + if err := c.post(ctx, resourcePathPrefix+url.PathEscape(token)+resourceResumeSuffix, &out); err != nil { + return nil, fmt.Errorf("ResumeResource: %w", err) + } + return &out, nil +} diff --git a/instant/resource_lifecycle_test.go b/instant/resource_lifecycle_test.go new file mode 100644 index 0000000..bcb5cdb --- /dev/null +++ b/instant/resource_lifecycle_test.go @@ -0,0 +1,106 @@ +package instant + +// resource_lifecycle_test.go — exercises PauseResource / ResumeResource +// (POST /api/v1/resources/:id/{pause,resume}) against httptest servers +// mirroring the api resource handler envelope +// ({ok, id, token, status, message, resource}). + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" +) + +func TestPauseResource_HappyPath(t *testing.T) { + srv, call := newOperateServer(t, http.StatusOK, + `{"ok":true,"id":"3f4a7b2c","token":"tok-123","status":"paused","message":"Resource paused.","resource":{"id":"3f4a7b2c","token":"tok-123","resource_type":"postgres","tier":"pro","status":"paused"}}`) + + c := New(WithBaseURL(srv.URL), WithAPIKey("k")) + res, err := c.PauseResource(context.Background(), "tok-123") + if err != nil { + t.Fatalf("PauseResource: %v", err) + } + + if call.Method != http.MethodPost { + t.Errorf("method = %q, want POST", call.Method) + } + if call.Path != "/api/v1/resources/tok-123/pause" { + t.Errorf("path = %q", call.Path) + } + if !res.OK || res.Status != "paused" || res.Token != "tok-123" { + t.Errorf("result = %+v", res) + } + if res.Resource == nil || res.Resource.ResourceType != "postgres" || res.Resource.Status != "paused" { + t.Errorf("Resource = %+v", res.Resource) + } +} + +func TestPauseResource_ValidationError(t *testing.T) { + c := New(WithBaseURL("http://127.0.0.1:0")) + if _, err := c.PauseResource(context.Background(), ""); err == nil || + !strings.Contains(err.Error(), "token is required") { + t.Errorf("empty token: err = %v", err) + } +} + +func TestPauseResource_TierGate402(t *testing.T) { + srv, _ := newOperateServer(t, http.StatusPaymentRequired, + `{"ok":false,"error":"upgrade_required","upgrade_url":"https://instanode.dev/pricing","agent_action":"Pause/resume requires Pro+."}`) + + _, err := New(WithBaseURL(srv.URL)).PauseResource(context.Background(), "tok-123") + if err == nil || !strings.Contains(err.Error(), "PauseResource") { + t.Fatalf("expected PauseResource-prefixed error, got %v", err) + } + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusPaymentRequired || + apiErr.UpgradeURL == "" { + t.Errorf("expected 402 APIError with upgrade_url, got %v", err) + } +} + +func TestResumeResource_HappyPath(t *testing.T) { + srv, call := newOperateServer(t, http.StatusOK, + `{"ok":true,"id":"3f4a7b2c","token":"tok-123","status":"active","message":"Resource resumed."}`) + + c := New(WithBaseURL(srv.URL), WithAPIKey("k")) + res, err := c.ResumeResource(context.Background(), "tok-123") + if err != nil { + t.Fatalf("ResumeResource: %v", err) + } + + if call.Method != http.MethodPost { + t.Errorf("method = %q, want POST", call.Method) + } + if call.Path != "/api/v1/resources/tok-123/resume" { + t.Errorf("path = %q", call.Path) + } + if !res.OK || res.Status != "active" { + t.Errorf("result = %+v", res) + } + if res.Resource != nil { + t.Errorf("Resource = %+v, want nil when server omits it", res.Resource) + } +} + +func TestResumeResource_ValidationError(t *testing.T) { + c := New(WithBaseURL("http://127.0.0.1:0")) + if _, err := c.ResumeResource(context.Background(), ""); err == nil || + !strings.Contains(err.Error(), "token is required") { + t.Errorf("empty token: err = %v", err) + } +} + +func TestResumeResource_NotPaused409(t *testing.T) { + srv, _ := newOperateServer(t, http.StatusConflict, + `{"ok":false,"error":"not_paused","message":"Resource is not paused (current status: active)."}`) + + _, err := New(WithBaseURL(srv.URL)).ResumeResource(context.Background(), "tok-123") + if err == nil || !strings.Contains(err.Error(), "ResumeResource") { + t.Fatalf("expected ResumeResource-prefixed error, got %v", err) + } + if !IsConflict(err) { + t.Errorf("expected 409 APIError, got %v", err) + } +} diff --git a/instant/stack_env.go b/instant/stack_env.go new file mode 100644 index 0000000..cf8693d --- /dev/null +++ b/instant/stack_env.go @@ -0,0 +1,71 @@ +package instant + +// stack_env.go — env-var mutation on an existing stack +// (PATCH /stacks/:slug/env, api/internal/handlers/stack.go UpdateEnv, +// migration 062: stacks.env_vars JSONB). + +import ( + "context" + "fmt" + "net/url" +) + +const ( + // stackPathPrefix is the stack operate-verb endpoint family. The stack + // slug is appended as a path-escaped segment. + stackPathPrefix = "/stacks/" + + // stackEnvSuffix is the PATCH env-merge sub-resource. + stackEnvSuffix = "/env" +) + +// StackEnvUpdate is returned by [Client.UpdateStackEnv]. +type StackEnvUpdate struct { + // OK is always true on success. + OK bool `json:"ok"` + + // Env is the FULL env set on the stack AFTER the merge (values redacted) + // — the caller does not need to re-GET the stack. + Env map[string]string `json:"env"` + + // Message reminds the caller that a redeploy is required to apply the + // change. + Message string `json:"message,omitempty"` +} + +// UpdateStackEnv merges env vars into an existing stack via +// PATCH /stacks/:slug/env. +// +// slug is the stack identifier from [Stack.Slug]. PATCH semantics: the +// incoming map is merged into the stack's existing env_vars under a +// server-side row lock (concurrent PATCHes don't lose updates). Setting a key +// to the EMPTY STRING deletes it. Keys must match the POSIX shape +// [A-Z_][A-Z0-9_]* — the same rule /stacks/new enforces — and the merged +// payload is capped at 64 KiB (413 *APIError beyond that). +// +// Requires a valid API key: anonymous stacks cannot be mutated after +// creation. The change is persisted to the stack row and applied on the next +// stack redeploy. +// +// Example: +// +// res, err := client.UpdateStackEnv(ctx, stack.Slug, map[string]string{ +// "LOG_LEVEL": "debug", +// "OLD_FLAG": "", // empty string deletes the key +// }) +// if err != nil { log.Fatal(err) } +// fmt.Println(res.Message) +func (c *Client) UpdateStackEnv(ctx context.Context, slug string, env map[string]string) (*StackEnvUpdate, error) { + if slug == "" { + return nil, fmt.Errorf("UpdateStackEnv: slug is required") + } + if len(env) == 0 { + return nil, fmt.Errorf("UpdateStackEnv: env must be a non-empty map") + } + var out StackEnvUpdate + path := stackPathPrefix + url.PathEscape(slug) + stackEnvSuffix + if err := c.patchJSON(ctx, path, envUpdateBody{Env: env}, &out); err != nil { + return nil, fmt.Errorf("UpdateStackEnv: %w", err) + } + return &out, nil +} diff --git a/instant/stack_env_test.go b/instant/stack_env_test.go new file mode 100644 index 0000000..a9ea4e5 --- /dev/null +++ b/instant/stack_env_test.go @@ -0,0 +1,79 @@ +package instant + +// stack_env_test.go — exercises UpdateStackEnv (PATCH /stacks/:slug/env) +// against an httptest server mirroring the api stack handler envelope +// ({ok, env, message}). + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" +) + +func TestUpdateStackEnv_HappyPath(t *testing.T) { + srv, call := newOperateServer(t, http.StatusOK, + `{"ok":true,"env":{"LOG_LEVEL":"debug"},"message":"Env vars persisted. Redeploy the stack to apply."}`) + + c := New(WithBaseURL(srv.URL), WithAPIKey("k")) + res, err := c.UpdateStackEnv(context.Background(), "stk-abc123", map[string]string{ + "LOG_LEVEL": "debug", + "OLD_FLAG": "", // empty value = delete the key server-side + }) + if err != nil { + t.Fatalf("UpdateStackEnv: %v", err) + } + + if call.Method != http.MethodPatch { + t.Errorf("method = %q, want PATCH", call.Method) + } + if call.Path != "/stacks/stk-abc123/env" { + t.Errorf("path = %q", call.Path) + } + var body struct { + Env map[string]string `json:"env"` + } + if err := json.Unmarshal([]byte(call.Body), &body); err != nil { + t.Fatalf("body decode: %v (raw %q)", err, call.Body) + } + if body.Env["LOG_LEVEL"] != "debug" { + t.Errorf("body env = %v", body.Env) + } + if v, present := body.Env["OLD_FLAG"]; !present || v != "" { + t.Errorf("empty-string delete marker must survive serialization; body env = %v", body.Env) + } + if !res.OK || res.Env["LOG_LEVEL"] != "debug" { + t.Errorf("result = %+v", res) + } + if !strings.Contains(res.Message, "Redeploy") { + t.Errorf("Message = %q, want redeploy reminder", res.Message) + } +} + +func TestUpdateStackEnv_ValidationErrors(t *testing.T) { + c := New(WithBaseURL("http://127.0.0.1:0")) + + if _, err := c.UpdateStackEnv(context.Background(), "", map[string]string{"K": "v"}); err == nil || + !strings.Contains(err.Error(), "slug is required") { + t.Errorf("empty slug: err = %v", err) + } + if _, err := c.UpdateStackEnv(context.Background(), "stk-abc123", map[string]string{}); err == nil || + !strings.Contains(err.Error(), "non-empty map") { + t.Errorf("empty env: err = %v", err) + } +} + +func TestUpdateStackEnv_APIError(t *testing.T) { + srv, _ := newOperateServer(t, http.StatusConflict, + `{"ok":false,"error":"stack_deleting","message":"Stack is mid-teardown and cannot be modified"}`) + + _, err := New(WithBaseURL(srv.URL)).UpdateStackEnv( + context.Background(), "stk-abc123", map[string]string{"K": "v"}) + if err == nil || !strings.Contains(err.Error(), "UpdateStackEnv") { + t.Fatalf("expected UpdateStackEnv-prefixed error, got %v", err) + } + if !IsConflict(err) { + t.Errorf("expected 409 APIError, got %v", err) + } +} diff --git a/instant/storage_presign.go b/instant/storage_presign.go new file mode 100644 index 0000000..f533f25 --- /dev/null +++ b/instant/storage_presign.go @@ -0,0 +1,102 @@ +package instant + +// storage_presign.go — broker-mode presigned URL minting +// (POST /storage/:token/presign, api/internal/handlers/storage_presign.go). +// +// When the configured object-store backend cannot enforce per-tenant +// prefix-scoping at the IAM layer (DO Spaces today), /storage/new returns no +// long-lived credential (mode "broker") and the caller fetches one signed URL +// per object operation here instead. + +import ( + "context" + "fmt" + "net/url" +) + +const ( + // storagePathPrefix is the storage operate-verb endpoint family. The + // storage resource token is appended as a path-escaped segment. + storagePathPrefix = "/storage/" + + // storagePresignSuffix is the signed-URL minting sub-resource. + storagePresignSuffix = "/presign" +) + +// PresignOpts are the parameters for [Client.PresignStorage]. +type PresignOpts struct { + // Operation is the S3 verb the signed URL authorises: "GET" or "PUT" + // (REQUIRED). DELETE is intentionally not permitted server-side — a + // leaked URL must not be able to wipe a prefix. + Operation string `json:"operation"` + + // Key is the object key, relative to the resource's tenant prefix + // (REQUIRED). Leading slashes and "../" path-traversal components are + // stripped server-side. + Key string `json:"key"` + + // ExpiresIn is the lifetime of the signed URL in seconds. 0 means the + // server default (600); the server clamps values above 3600 (1h). + ExpiresIn int `json:"expires_in,omitempty"` +} + +// PresignResult is returned by [Client.PresignStorage]. +type PresignResult struct { + // OK is always true on success. + OK bool `json:"ok"` + + // URL is the signed S3 URL — usable with any plain HTTP client for the + // given Method until ExpiresAt. + URL string `json:"url"` + + // Method is the echo of the resolved HTTP verb the URL authorises. + Method string `json:"method"` + + // Key is the object key relative to the tenant prefix, as resolved. + Key string `json:"key"` + + // ObjectKey is the fully-qualified object key (prefix + key) the URL + // signs. + ObjectKey string `json:"object_key"` + + // ExpiresAt is the RFC3339 UTC expiry — the URL is invalid after this. + ExpiresAt string `json:"expires_at"` +} + +// PresignStorage mints a short-lived presigned S3 URL via +// POST /storage/:token/presign. +// +// token is the storage resource token from [Client.ProvisionStorage] — the +// token in the URL IS the credential (broker mode), so no API key is required +// and an anonymous caller can presign against a prefix it just provisioned. +// +// Error surfaces (all *APIError): 400 for an invalid token / operation / key, +// 404 when the resource doesn't exist, 410 when it is paused / expired / +// deleted, and 503 when object storage is not configured or signing failed. +// +// Example: +// +// signed, err := client.PresignStorage(ctx, store.Token, instant.PresignOpts{ +// Operation: "PUT", +// Key: "uploads/avatar.png", +// ExpiresIn: 900, +// }) +// if err != nil { log.Fatal(err) } +// // http.NewRequest(signed.Method, signed.URL, body) — valid until signed.ExpiresAt +func (c *Client) PresignStorage(ctx context.Context, token string, opts PresignOpts) (*PresignResult, error) { + if token == "" { + return nil, fmt.Errorf("PresignStorage: token is required") + } + if opts.Operation == "" { + return nil, fmt.Errorf("PresignStorage: Operation is required") + } + if opts.Key == "" { + return nil, fmt.Errorf("PresignStorage: Key is required") + } + var out PresignResult + path := storagePathPrefix + url.PathEscape(token) + storagePresignSuffix + if err := c.postJSON(ctx, path, opts, &out); err != nil { + return nil, fmt.Errorf("PresignStorage: %w", err) + } + return &out, nil +} diff --git a/instant/storage_presign_test.go b/instant/storage_presign_test.go new file mode 100644 index 0000000..ca849ef --- /dev/null +++ b/instant/storage_presign_test.go @@ -0,0 +1,102 @@ +package instant + +// storage_presign_test.go — exercises PresignStorage +// (POST /storage/:token/presign) against an httptest server mirroring the api +// presign envelope ({ok, url, method, key, object_key, expires_at}). + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" +) + +func TestPresignStorage_HappyPath(t *testing.T) { + srv, call := newOperateServer(t, http.StatusOK, + `{"ok":true,"url":"https://s3.instanode.dev/instant-shared/t-1/uploads/a.png?X-Amz-Signature=x","method":"PUT","key":"uploads/a.png","object_key":"t-1/uploads/a.png","expires_at":"2026-06-11T12:00:00Z"}`) + + // No API key: presign is broker-mode — the token in the URL IS the credential. + c := New(WithBaseURL(srv.URL)) + res, err := c.PresignStorage(context.Background(), "tok-123", PresignOpts{ + Operation: "PUT", + Key: "uploads/a.png", + ExpiresIn: 900, + }) + if err != nil { + t.Fatalf("PresignStorage: %v", err) + } + + if call.Method != http.MethodPost { + t.Errorf("method = %q, want POST", call.Method) + } + if call.Path != "/storage/tok-123/presign" { + t.Errorf("path = %q", call.Path) + } + var body map[string]any + if err := json.Unmarshal([]byte(call.Body), &body); err != nil { + t.Fatalf("body decode: %v (raw %q)", err, call.Body) + } + if body["operation"] != "PUT" || body["key"] != "uploads/a.png" || body["expires_in"] != float64(900) { + t.Errorf("body = %v", body) + } + if !res.OK || res.Method != "PUT" || res.ObjectKey != "t-1/uploads/a.png" || + res.ExpiresAt != "2026-06-11T12:00:00Z" || !strings.Contains(res.URL, "X-Amz-Signature") { + t.Errorf("result = %+v", res) + } +} + +func TestPresignStorage_OmitsZeroExpiresIn(t *testing.T) { + srv, call := newOperateServer(t, http.StatusOK, + `{"ok":true,"url":"https://x","method":"GET","key":"k","object_key":"p/k","expires_at":"2026-06-11T12:00:00Z"}`) + + c := New(WithBaseURL(srv.URL)) + if _, err := c.PresignStorage(context.Background(), "tok-123", PresignOpts{ + Operation: "GET", + Key: "k", + }); err != nil { + t.Fatalf("PresignStorage: %v", err) + } + // ExpiresIn=0 must be omitted so the server default (600s) applies. + if strings.Contains(call.Body, "expires_in") { + t.Errorf("body = %q, want expires_in omitted", call.Body) + } +} + +func TestPresignStorage_ValidationErrors(t *testing.T) { + c := New(WithBaseURL("http://127.0.0.1:0")) + cases := []struct { + name string + token string + opts PresignOpts + wantSubstr string + }{ + {"empty token", "", PresignOpts{Operation: "GET", Key: "k"}, "token is required"}, + {"empty operation", "tok", PresignOpts{Key: "k"}, "Operation is required"}, + {"empty key", "tok", PresignOpts{Operation: "GET"}, "Key is required"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := c.PresignStorage(context.Background(), tc.token, tc.opts) + if err == nil || !strings.Contains(err.Error(), tc.wantSubstr) { + t.Errorf("err = %v, want substring %q", err, tc.wantSubstr) + } + }) + } +} + +func TestPresignStorage_APIError(t *testing.T) { + srv, _ := newOperateServer(t, http.StatusGone, + `{"ok":false,"error":"resource_inactive","message":"paused, expired, or deleted"}`) + + _, err := New(WithBaseURL(srv.URL)).PresignStorage( + context.Background(), "tok-123", PresignOpts{Operation: "GET", Key: "k"}) + if err == nil || !strings.Contains(err.Error(), "PresignStorage") { + t.Fatalf("expected PresignStorage-prefixed error, got %v", err) + } + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusGone { + t.Errorf("expected 410 APIError, got %v", err) + } +} diff --git a/instant/vault.go b/instant/vault.go new file mode 100644 index 0000000..eb4be0f --- /dev/null +++ b/instant/vault.go @@ -0,0 +1,128 @@ +package instant + +// vault.go — write-side client for the encrypted secret vault +// (api/internal/handlers/vault.go). Secrets stored here are referenced from +// deploys as `vault:///` values in env_vars; the API decrypts them +// at deploy time so plaintext never lands in a deployment row. +// +// The vault is a paid-tier feature: anonymous/free callers receive a 403 +// (vault_not_available). Both write verbs below require a valid API key. + +import ( + "context" + "fmt" + "net/url" +) + +const ( + // vaultPathPrefix is the authenticated vault endpoint family. Secret + // coordinates (env, key) are appended as path-escaped segments. + vaultPathPrefix = "/api/v1/vault/" + + // vaultRotateSuffix distinguishes POST .../rotate from the plain PUT — + // functionally identical (both mint a new version) but recorded under a + // distinct audit action so an intentional rotation is distinguishable + // from an ordinary write in the vault audit log. + vaultRotateSuffix = "/rotate" +) + +// VaultWriteResult is returned by [Client.SetVaultKey] and +// [Client.RotateVaultKey]. The plaintext secret value is NEVER echoed back — +// only its coordinates and the freshly minted version. +type VaultWriteResult struct { + // OK is always true on success. + OK bool `json:"ok"` + + // Key is the secret key name as stored (e.g. "DATABASE_URL"). + Key string `json:"key"` + + // Env is the environment scope the secret lives under + // (production / staging / development / ...). + Env string `json:"env"` + + // Version is the version minted by this write. Every write creates a NEW + // version: 1 on first create, 2+ on subsequent writes / rotates. Existing + // deployments keep reading the version they resolved at deploy time until + // they redeploy. + Version int `json:"version"` +} + +// vaultWriteBody is the JSON body for PUT /api/v1/vault/:env/:key and +// POST /api/v1/vault/:env/:key/rotate. +type vaultWriteBody struct { + Value string `json:"value"` +} + +// vaultSecretPath assembles /api/v1/vault// with each user-supplied +// segment path-escaped so keys containing '.' (or any future characters) +// round-trip cleanly. +func vaultSecretPath(env, key string) string { + return vaultPathPrefix + url.PathEscape(env) + "/" + url.PathEscape(key) +} + +// SetVaultKey stores an encrypted secret via PUT /api/v1/vault/:env/:key. +// +// The API encrypts value with AES-256-GCM and stores it as a NEW version — +// v1 on first create, v2+ on subsequent writes. Old versions remain queryable +// until the key is deleted. Requires a valid API key; the vault is a +// paid-tier feature (anonymous/free callers receive a 403 *APIError). +// +// Store a secret here, then reference it as "vault:///" in +// [DeployOpts.EnvVars] (or a stack service's Env) and the API resolves the +// plaintext at deploy time. +// +// Example: +// +// res, err := client.SetVaultKey(ctx, "production", "STRIPE_KEY", "sk_live_...") +// if err != nil { log.Fatal(err) } +// fmt.Println("stored version:", res.Version) +func (c *Client) SetVaultKey(ctx context.Context, env, key, value string) (*VaultWriteResult, error) { + if err := validateVaultWriteArgs(env, key, value); err != nil { + return nil, fmt.Errorf("SetVaultKey: %w", err) + } + var out VaultWriteResult + if err := c.putJSON(ctx, vaultSecretPath(env, key), vaultWriteBody{Value: value}, &out); err != nil { + return nil, fmt.Errorf("SetVaultKey: %w", err) + } + return &out, nil +} + +// RotateVaultKey rotates a secret's value via +// POST /api/v1/vault/:env/:key/rotate. +// +// Functionally identical to [Client.SetVaultKey] (every write mints a new +// version) but recorded under a distinct audit action so the vault audit log +// distinguishes an intentional rotation from an ordinary write. value is the +// NEW secret value. Existing deployments continue to read the previous +// version until they redeploy — follow up with a redeploy to apply. +// +// Example: +// +// res, err := client.RotateVaultKey(ctx, "production", "STRIPE_KEY", "sk_live_new") +// if err != nil { log.Fatal(err) } +// fmt.Println("rotated to version:", res.Version) +func (c *Client) RotateVaultKey(ctx context.Context, env, key, value string) (*VaultWriteResult, error) { + if err := validateVaultWriteArgs(env, key, value); err != nil { + return nil, fmt.Errorf("RotateVaultKey: %w", err) + } + var out VaultWriteResult + if err := c.postJSON(ctx, vaultSecretPath(env, key)+vaultRotateSuffix, vaultWriteBody{Value: value}, &out); err != nil { + return nil, fmt.Errorf("RotateVaultKey: %w", err) + } + return &out, nil +} + +// validateVaultWriteArgs rejects empty coordinates / values client-side so a +// malformed call fails fast with an actionable message instead of a 400. +func validateVaultWriteArgs(env, key, value string) error { + if env == "" { + return fmt.Errorf("env is required") + } + if key == "" { + return fmt.Errorf("key is required") + } + if value == "" { + return fmt.Errorf("value is required") + } + return nil +} diff --git a/instant/vault_test.go b/instant/vault_test.go new file mode 100644 index 0000000..ead7864 --- /dev/null +++ b/instant/vault_test.go @@ -0,0 +1,171 @@ +package instant + +// vault_test.go — exercises SetVaultKey / RotateVaultKey against httptest +// servers mirroring the api vault handler envelope +// (api/internal/handlers/vault.go: {ok, key, env, version}). + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// operateCall records the single request an operate-verb test server saw. +type operateCall struct { + Method string + Path string + // EscapedPath preserves percent-encoding (e.g. %2F) that Path decodes. + EscapedPath string + Query string + Body string +} + +// newOperateServer returns an httptest server that records the request +// method/path/body into the returned *operateCall and serves the given +// status + JSON body. Shared by every operate-verb test file. +func newOperateServer(t *testing.T, status int, respBody string) (*httptest.Server, *operateCall) { + t.Helper() + call := &operateCall{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call.Method = r.Method + call.Path = r.URL.Path + call.EscapedPath = r.URL.EscapedPath() + call.Query = r.URL.RawQuery + b, _ := io.ReadAll(r.Body) + call.Body = string(b) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, respBody) + })) + t.Cleanup(srv.Close) + return srv, call +} + +func TestSetVaultKey_HappyPath(t *testing.T) { + srv, call := newOperateServer(t, http.StatusCreated, + `{"ok":true,"key":"STRIPE_KEY","env":"production","version":2}`) + + c := New(WithBaseURL(srv.URL), WithAPIKey("k")) + res, err := c.SetVaultKey(context.Background(), "production", "STRIPE_KEY", "sk_live_x") + if err != nil { + t.Fatalf("SetVaultKey: %v", err) + } + + if call.Method != http.MethodPut { + t.Errorf("method = %q, want PUT", call.Method) + } + if call.Path != "/api/v1/vault/production/STRIPE_KEY" { + t.Errorf("path = %q", call.Path) + } + var body map[string]string + if err := json.Unmarshal([]byte(call.Body), &body); err != nil { + t.Fatalf("body decode: %v (raw %q)", err, call.Body) + } + if body["value"] != "sk_live_x" { + t.Errorf("body value = %q, want sk_live_x", body["value"]) + } + if !res.OK || res.Key != "STRIPE_KEY" || res.Env != "production" || res.Version != 2 { + t.Errorf("result = %+v", res) + } +} + +func TestSetVaultKey_PathEscapesSegments(t *testing.T) { + srv, call := newOperateServer(t, http.StatusCreated, + `{"ok":true,"key":"a/b","env":"prod","version":1}`) + + c := New(WithBaseURL(srv.URL)) + if _, err := c.SetVaultKey(context.Background(), "prod", "a/b", "v"); err != nil { + t.Fatalf("SetVaultKey: %v", err) + } + // A key containing a slash must be escaped into ONE path segment, not two. + if call.EscapedPath != "/api/v1/vault/prod/a%2Fb" { + t.Errorf("escaped path = %q, want /api/v1/vault/prod/a%%2Fb", call.EscapedPath) + } +} + +func TestSetVaultKey_ValidationErrors(t *testing.T) { + c := New(WithBaseURL("http://127.0.0.1:0")) + cases := []struct { + name string + env, key, value string + wantSubstr string + }{ + {"empty env", "", "K", "v", "env is required"}, + {"empty key", "production", "", "v", "key is required"}, + {"empty value", "production", "K", "", "value is required"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := c.SetVaultKey(context.Background(), tc.env, tc.key, tc.value) + if err == nil || !strings.Contains(err.Error(), tc.wantSubstr) { + t.Errorf("err = %v, want substring %q", err, tc.wantSubstr) + } + }) + } +} + +func TestSetVaultKey_APIError(t *testing.T) { + srv, _ := newOperateServer(t, http.StatusForbidden, + `{"ok":false,"error":"vault_not_available"}`) + + _, err := New(WithBaseURL(srv.URL)).SetVaultKey(context.Background(), "production", "K", "v") + if err == nil || !strings.Contains(err.Error(), "SetVaultKey") { + t.Fatalf("expected SetVaultKey-prefixed error, got %v", err) + } + if !IsForbidden(err) { + t.Errorf("expected 403 APIError, got %v", err) + } +} + +func TestRotateVaultKey_HappyPath(t *testing.T) { + srv, call := newOperateServer(t, http.StatusOK, + `{"ok":true,"key":"STRIPE_KEY","env":"production","version":3}`) + + c := New(WithBaseURL(srv.URL), WithAPIKey("k")) + res, err := c.RotateVaultKey(context.Background(), "production", "STRIPE_KEY", "sk_live_new") + if err != nil { + t.Fatalf("RotateVaultKey: %v", err) + } + + if call.Method != http.MethodPost { + t.Errorf("method = %q, want POST", call.Method) + } + if call.Path != "/api/v1/vault/production/STRIPE_KEY/rotate" { + t.Errorf("path = %q", call.Path) + } + var body map[string]string + if err := json.Unmarshal([]byte(call.Body), &body); err != nil { + t.Fatalf("body decode: %v", err) + } + if body["value"] != "sk_live_new" { + t.Errorf("body value = %q", body["value"]) + } + if res.Version != 3 { + t.Errorf("Version = %d, want 3", res.Version) + } +} + +func TestRotateVaultKey_ValidationError(t *testing.T) { + c := New(WithBaseURL("http://127.0.0.1:0")) + _, err := c.RotateVaultKey(context.Background(), "", "K", "v") + if err == nil || !strings.Contains(err.Error(), "RotateVaultKey") { + t.Errorf("err = %v, want RotateVaultKey-prefixed validation error", err) + } +} + +func TestRotateVaultKey_APIError(t *testing.T) { + srv, _ := newOperateServer(t, http.StatusUnauthorized, + `{"ok":false,"error":"unauthorized"}`) + + _, err := New(WithBaseURL(srv.URL)).RotateVaultKey(context.Background(), "production", "K", "v") + if err == nil || !strings.Contains(err.Error(), "RotateVaultKey") { + t.Fatalf("expected RotateVaultKey-prefixed error, got %v", err) + } + if !IsUnauthorized(err) { + t.Errorf("expected 401 APIError, got %v", err) + } +}