diff --git a/CLAUDE.md b/CLAUDE.md index dba1270a..454190fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,12 +178,13 @@ When lstk's stdout and stderr are both terminals, `lstk aws` runs the child via - `lstk snapshot save [destination]` (`-s`/`--services`: comma-separated list to limit a save to a subset of services; applies uniformly to local files, `pod:` cloud snapshots, and S3-remote saves) / `lstk snapshot load REF` (`--merge`: `account-region-merge` default, `overwrite`, `service-merge`) / `list` (cloud; `--all` for org-wide) / `remove REF` / `show REF` / `versions REF`. - `show`, `versions`, and bare `list` (no `s3://` arg) only ever call the LocalStack platform API — never the emulator. `remove`, by contrast, proxies its delete through the running emulator despite being a "cloud" (`pod:`) concept, so it still requires one to be reachable; don't conflate "operates on cloud-hosted storage" with "never touches the emulator" — they're different axes. -- A REF is a local `.snapshot` file, a `pod:` cloud snapshot on the LocalStack platform (requires auth), or an `s3://bucket/prefix` remote in the user's own bucket (the emulator performs the transfer; S3 supports save/load/list only). +- A REF is a local `.snapshot` file, a `pod:` cloud snapshot on the LocalStack platform (requires an identity), or an `s3://bucket/prefix` remote in the user's own bucket (the emulator performs the transfer; S3 supports save/load/list only). - Every save to an existing `pod:` snapshot creates a new **version**. `versions REF` lists them; `load` and `show` accept a `pod::` REF (latest when omitted). `save`, `remove`, `versions`, and S3 remotes reject a version suffix rather than ignore it. - A `[[containers]]` block (AWS only) can set `snapshot = "pod:..."` to auto-load after a fresh start; `lstk start --snapshot REF` overrides it for one run, `--no-snapshot` skips it. +- Emulator-backed pod operations against an lstk-managed local emulator do not pre-check for a token: with none supplied they send no auth header, so the running emulator reuses the identity it was started with. Externally-managed targets (`--endpoint-url` and its environment-variable equivalents) and platform-direct commands (`list`, `show`, `versions`) still need a token from the environment or keychain. - `save`/`load`/`remove` and `list s3://...` support the global `--endpoint-url` targeting described under "Targeting an External Emulator"; `show`, `versions`, and bare `list` silently ignore it (they never touch the emulator regardless). -REF parsing helpers, S3 credential precedence and remote-upsert mechanics, and the auto-load wiring are documented in `internal/snapshot/CLAUDE.md`. +REF parsing helpers, the authentication rules, S3 credential precedence and remote-upsert mechanics, and the auto-load wiring are documented in `internal/snapshot/CLAUDE.md`. # NPM Distribution diff --git a/cmd/snapshot.go b/cmd/snapshot.go index c44b3a65..07b38f76 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -358,6 +358,11 @@ func runSnapshotLoad(cfg *env.Env, tel *telemetry.Client, logger log.Logger) fun if err != nil { return err } + if src.Kind == snapshot.KindPod { + if err := requireExternalPodAuth(external, cfg.AuthToken); err != nil { + return err + } + } var starter snapshot.Starter if !external { @@ -414,27 +419,36 @@ func runSnapshotRemove(cfg *env.Env) func(*cobra.Command, []string) error { if !force { return fmt.Errorf("snapshot remove requires confirmation; use --force to skip in non-interactive mode") } - rt, client, host, containers, _, _, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg) + rt, client, host, containers, _, external, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg) if err != nil { return err } + if err := requireExternalPodAuth(external, cfg.AuthToken); err != nil { + return err + } sink := output.NewPlainSink(os.Stdout) return snapshot.Remove(cmd.Context(), rt, containers, ref.Value, cfg.AuthToken, client, host, force, sink) } - rt, client, host, containers, _, _, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg) + rt, client, host, containers, _, external, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg) if err != nil { return err } + if err := requireExternalPodAuth(external, cfg.AuthToken); err != nil { + return err + } return ui.RunSnapshotRemove(cmd.Context(), rt, containers, client, host, args[0], cwd, home, cfg.AuthToken, force) } } func execDiff(cmd *cobra.Command, cfg *env.Env, podName string, version int, strategy string) error { - rt, client, host, containers, _, _, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg) + rt, client, host, containers, _, external, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg) if err != nil { return err } + if err := requireExternalPodAuth(external, cfg.AuthToken); err != nil { + return err + } if isInteractiveMode(cfg) { return ui.RunSnapshotDiff(cmd.Context(), rt, containers, client, host, podName, version, cfg.AuthToken, strategy) @@ -443,6 +457,13 @@ func execDiff(cmd *cobra.Command, cfg *env.Env, podName string, version int, str return snapshot.DiffPod(cmd.Context(), rt, containers, client, host, podName, version, cfg.AuthToken, strategy, sink) } +func requireExternalPodAuth(external bool, authToken string) error { + if external && authToken == "" { + return fmt.Errorf("authentication is required for cloud snapshot operations against an externally-managed emulator — set LOCALSTACK_AUTH_TOKEN or run %q", "lstk login") + } + return nil +} + // resolveSnapshotDeps resolves the runtime, host, and target container(s) for // a snapshot subcommand that contacts the emulator (save/load/remove/list // s3://...). When an endpoint URL is resolved (--endpoint-url/ @@ -786,10 +807,15 @@ func runSnapshotSave(cfg *env.Env) func(*cobra.Command, []string) error { return err } - rt, client, host, containers, _, _, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg) + rt, client, host, containers, _, external, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg) if err != nil { return err } + if dest.Kind == snapshot.KindPod { + if err := requireExternalPodAuth(external, cfg.AuthToken); err != nil { + return err + } + } if isInteractiveMode(cfg) { return ui.RunSnapshotSave(cmd.Context(), rt, containers, client, host, dest, cfg.AuthToken, services) diff --git a/cmd/snapshot_auth_test.go b/cmd/snapshot_auth_test.go new file mode 100644 index 00000000..8dce37b8 --- /dev/null +++ b/cmd/snapshot_auth_test.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRequireExternalPodAuth(t *testing.T) { + t.Parallel() + + require.NoError(t, requireExternalPodAuth(false, ""), "a locally managed emulator may reuse its startup identity") + require.NoError(t, requireExternalPodAuth(true, "test-token"), "an external emulator accepts explicit caller authentication") + assert.ErrorContains(t, requireExternalPodAuth(true, ""), "authentication is required for cloud snapshot operations against an externally-managed emulator") +} diff --git a/internal/emulator/aws/client.go b/internal/emulator/aws/client.go index 15befd55..69a19caa 100644 --- a/internal/emulator/aws/client.go +++ b/internal/emulator/aws/client.go @@ -4,7 +4,6 @@ import ( "bufio" "bytes" "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -351,7 +350,7 @@ func (c *Client) DiffPodSnapshot(ctx context.Context, baseURL, podName string, v if err != nil { return nil, fmt.Errorf("create request: %w", err) } - req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(":"+authToken))) + setBasicAuth(req, authToken) resp, err := c.http.Do(req) if err != nil { @@ -365,12 +364,18 @@ func (c *Client) DiffPodSnapshot(ctx context.Context, baseURL, podName string, v if isPodVersionNotFoundMsg(bodyStr) { return nil, fmt.Errorf("%w: %s", snapshot.ErrPodVersionNotFound, bodyStr) } + // Not-found is classified from the body first: the platform answers 403 + // for a pod the identity cannot see, which is a missing pod rather than a + // missing identity. if isPodNotFoundMsg(bodyStr) { return nil, fmt.Errorf("%w: %s", snapshot.ErrPodNotFound, bodyStr) } if isFeatureUnavailableResponse(resp.StatusCode, body) { return nil, snapshot.ErrSnapshotFeatureUnavailable } + if authRejected(resp.StatusCode) { + return nil, fmt.Errorf("%w: %s", snapshot.ErrAuthRequired, bodyStr) + } return nil, emulatorStatusError(fmt.Sprintf("diff failed (HTTP %d)", resp.StatusCode), body) } @@ -390,7 +395,7 @@ func (c *Client) DiffPodSnapshot(ctx context.Context, baseURL, podName string, v counts.Additions++ case "MODIFICATION": counts.Modifications++ - // DELETION is intentionally omitted: the diff endpoint does not currently return deletions. + // DELETION is intentionally omitted: the diff endpoint does not currently return deletions. } } result[svc] = counts @@ -415,7 +420,7 @@ func (c *Client) RemovePodSnapshot(ctx context.Context, baseURL, podName, authTo return fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(":"+authToken))) + setBasicAuth(req, authToken) resp, err := c.http.Do(req) if err != nil { @@ -426,12 +431,18 @@ func (c *Client) RemovePodSnapshot(ctx context.Context, baseURL, podName, authTo if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) bodyStr := strings.TrimSpace(string(body)) + // Not-found is classified from the body first: the platform answers 403 + // for a pod the identity cannot see, which is a missing pod rather than a + // missing identity. if strings.Contains(strings.ToLower(bodyStr), "not found") { return fmt.Errorf("%w: %s", snapshot.ErrPodNotFound, bodyStr) } if isFeatureUnavailableResponse(resp.StatusCode, body) { return snapshot.ErrSnapshotFeatureUnavailable } + if authRejected(resp.StatusCode) { + return fmt.Errorf("%w: %s", snapshot.ErrAuthRequired, bodyStr) + } return emulatorStatusError(fmt.Sprintf("pod remove failed (HTTP %d)", resp.StatusCode), body) } return nil diff --git a/internal/emulator/aws/pod_auth_test.go b/internal/emulator/aws/pod_auth_test.go new file mode 100644 index 00000000..16664d43 --- /dev/null +++ b/internal/emulator/aws/pod_auth_test.go @@ -0,0 +1,91 @@ +package aws + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/localstack/lstk/internal/snapshot" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type podCall struct { + name string + call func(c *Client, host string) error +} + +// podCalls returns the emulator-backed pod operations that authenticate with the +// caller's token when there is one and fall back to the emulator's own identity +// when there is not. Every operation below is called without a token. +func podCalls() []podCall { + return []podCall{ + {"save", func(c *Client, host string) error { + _, err := c.SavePodSnapshot(context.Background(), host, "my-pod", "", nil) + return err + }}, + {"load", func(c *Client, host string) error { + _, err := c.LoadPodSnapshot(context.Background(), host, "my-pod", 0, "", "") + return err + }}, + {"diff", func(c *Client, host string) error { + _, err := c.DiffPodSnapshot(context.Background(), host, "my-pod", 0, "") + return err + }}, + {"remove", func(c *Client, host string) error { + return c.RemovePodSnapshot(context.Background(), host, "my-pod", "") + }}, + } +} + +// An omitted token must leave the Authorization header off the request entirely, +// so the emulator falls back to the identity it was started with instead of +// receiving empty credentials. +func TestPodRequests_OmitAuthorizationHeaderWithoutToken(t *testing.T) { + t.Parallel() + for _, tc := range podCalls() { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var hasAuthHeader bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, hasAuthHeader = r.Header["Authorization"] + if strings.HasSuffix(r.URL.Path, "/diff") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + return + } + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = w.Write([]byte(`{"event":"completion","status":"ok","info":{"version":1}}` + "\n")) + })) + defer server.Close() + + require.NoError(t, tc.call(NewClient(), server.URL)) + assert.False(t, hasAuthHeader, "Authorization header should be omitted when no token is supplied") + }) + } +} + +// A rejection for lack of a usable identity must surface as ErrAuthRequired so +// the snapshot layer can render an actionable message. +func TestPodRequests_MapUnauthorizedToErrAuthRequired(t *testing.T) { + t.Parallel() + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { + for _, tc := range podCalls() { + t.Run(http.StatusText(status)+"/"+tc.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte("no credentials configured")) + })) + defer server.Close() + + err := tc.call(NewClient(), server.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, snapshot.ErrAuthRequired), "got %v", err) + }) + } + } +} diff --git a/internal/emulator/aws/remote.go b/internal/emulator/aws/remote.go index 2fb7318e..7f2b410e 100644 --- a/internal/emulator/aws/remote.go +++ b/internal/emulator/aws/remote.go @@ -50,7 +50,9 @@ func marshalPodBody(remoteName string, params map[string]string, services []stri } // setBasicAuth sets the LocalStack Basic auth header when a token is present. -// S3 remotes do not require a platform token, so it is optional. +// It is optional: S3 remotes do not require a platform token, and for +// platform-hosted pods on a locally managed emulator an omitted header makes +// the emulator fall back to the identity it was started with. func setBasicAuth(req *http.Request, authToken string) { if authToken == "" { return @@ -58,6 +60,15 @@ func setBasicAuth(req *http.Request, authToken string) { req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(":"+authToken))) } +// authRejected reports whether status is the emulator rejecting a pod operation +// for lack of a usable identity: 401 for no or invalid credentials, 403 for +// credentials that exist but don't grant access. Callers wrap the response body +// in snapshot.ErrAuthRequired so the domain layer can render an actionable +// message with the emulator's own explanation. +func authRejected(status int) bool { + return status == http.StatusUnauthorized || status == http.StatusForbidden +} + // S3BucketExists reports whether an S3 bucket exists, via an unsigned HEAD to the // S3 endpoint: a 404 means the bucket does not exist; any other status (200, 403, // or a redirect for a bucket in another region) means it does. This lets lstk @@ -163,6 +174,10 @@ func (c *Client) ListPodsRemote(ctx context.Context, baseURL, remoteName string, if isFeatureUnavailableResponse(resp.StatusCode, respBody) { return nil, snapshot.ErrSnapshotFeatureUnavailable } + msg := strings.TrimSpace(string(respBody)) + if authRejected(resp.StatusCode) { + return nil, fmt.Errorf("%w: %s", snapshot.ErrAuthRequired, msg) + } return nil, emulatorStatusError(fmt.Sprintf("list pods failed (HTTP %d)", resp.StatusCode), respBody) } @@ -204,6 +219,10 @@ func (c *Client) doPodSave(ctx context.Context, baseURL, podName, authToken stri if isFeatureUnavailableResponse(resp.StatusCode, respBody) { return snapshot.PodSaveResult{}, snapshot.ErrSnapshotFeatureUnavailable } + msg := strings.TrimSpace(string(respBody)) + if authRejected(resp.StatusCode) { + return snapshot.PodSaveResult{}, fmt.Errorf("%w: %s", snapshot.ErrAuthRequired, msg) + } return snapshot.PodSaveResult{}, emulatorStatusError(fmt.Sprintf("pod save failed (HTTP %d)", resp.StatusCode), respBody) } @@ -283,8 +302,12 @@ func (c *Client) doPodLoad(ctx context.Context, baseURL, podName string, version if isFeatureUnavailableResponse(resp.StatusCode, respBody) { return nil, snapshot.ErrSnapshotFeatureUnavailable } - if bodyStr := strings.TrimSpace(string(respBody)); isPodVersionNotFoundMsg(bodyStr) { - return nil, fmt.Errorf("%w: %s", snapshot.ErrPodVersionNotFound, bodyStr) + msg := strings.TrimSpace(string(respBody)) + if isPodVersionNotFoundMsg(msg) { + return nil, fmt.Errorf("%w: %s", snapshot.ErrPodVersionNotFound, msg) + } + if authRejected(resp.StatusCode) { + return nil, fmt.Errorf("%w: %s", snapshot.ErrAuthRequired, msg) } return nil, emulatorStatusError(fmt.Sprintf("pod load failed (HTTP %d)", resp.StatusCode), respBody) } diff --git a/internal/snapshot/CLAUDE.md b/internal/snapshot/CLAUDE.md index f4604e15..0d9f379b 100644 --- a/internal/snapshot/CLAUDE.md +++ b/internal/snapshot/CLAUDE.md @@ -6,7 +6,7 @@ Detail moved out of the root CLAUDE.md; see the root file for the command list. A REF is parsed by helpers in `internal/snapshot/destination.go`: - **local file** — absolute/relative path; the `.snapshot` extension is forced (any other extension is replaced). On load, `.zip` files saved by older lstk versions are still accepted. -- **cloud snapshot** — `pod:` prefix (e.g. `pod:my-baseline`), stored on the LocalStack platform. Requires auth (`LOCALSTACK_AUTH_TOKEN` or `lstk login`). +- **cloud snapshot** — `pod:` prefix (e.g. `pod:my-baseline`), stored on the LocalStack platform. Requires an identity (`LOCALSTACK_AUTH_TOKEN`, `lstk login`, or the running emulator's — see Authentication below). - **S3 remote** — `s3://bucket/prefix` (parsed to `KindS3`). The CLI never touches S3; the emulator performs the transfer. `ParseDestination` (save), `ParseSource` (load), `ParseRemovable` (remove), `ParseShowable` (show), and `ParseVersionable` (versions) share pod-name validation; `ParseRemovable`, `ParseShowable`, and `ParseVersionable` reject local paths (via the shared `parseCloudOnly` helper) so those cloud-only commands never touch local files. @@ -34,6 +34,16 @@ Two things to keep in mind when touching this: `snapshot list` and `snapshot show` reach the same conclusion from a different signal: they query the platform API (`/v1/cloudpods*`), not the emulator, and it answers an unentitled plan with `403 {"error": true, "message": "generic.forbidden"}`. `ListCloudPods`/`GetCloudPod` map that to `api.ErrCloudPodsForbidden`, which both commands render through the same `emitFeatureUnavailableError`. Only `403` maps — a rejected token is a `401` and stays a generic error, so a re-login problem is never reported as a billing problem. +## Authentication + +Emulator-backed pod operations (`save pod:`, `load pod:`, `load --dry-run`, and `remove`) against an lstk-managed local emulator send the caller's token as a Basic auth header only when there is one (`setBasicAuth` in `internal/emulator/aws/remote.go`) and never pre-check for it. Omitting the header is what lets the emulator reuse the identity it was started with — `lstk start` passes an env-provided `LOCALSTACK_AUTH_TOKEN` to the container but does not persist it to the keychain, so in CI the following steps may have no token of their own (DEVX-1022). An explicitly supplied token still wins, since it is sent as the header. + +That fallback is limited to locally managed emulators. `requireExternalPodAuth` at the command boundary rejects tokenless platform-pod operations when `resolveSnapshotDeps` selected an externally-managed target through `--endpoint-url`, `LSTK_ENDPOINT_URL`, or `AWS_ENDPOINT_URL`; network access to a remote emulator must not grant use of the identity it was started with. S3-remote operations remain separate: they use the caller's AWS credentials and do not require a LocalStack platform token. + +A rejected request comes back 401/403, which the client maps to `snapshot.ErrAuthRequired` (`authRejected`) for the domain layer to render as the actionable "Authentication failed for cloud snapshots" error (`emitAuthRequired` in `internal/snapshot/auth.go`). Since the rejection can mean no identity, an invalid token, or one without access — and on an `s3://` remote it can even be the AWS credentials rather than the platform token — the emulator's own message is used as the error summary rather than a guess. Body-based classifications run first (`isPodNotFoundMsg`, remove's `"not found"` match): the platform answers 403 for a pod the identity cannot see, and that is a missing pod, not a missing identity. + +Commands that talk to the platform API directly (`list` without an `s3://` location, `show`, `versions`) have no emulator to fall back on and keep their up-front token check, resolved from the environment or keychain in `cmd/root.go`. + ## Limiting saved services (`--services`) `lstk snapshot save [destination] --services s3,lambda` (shorthand `-s`) limits a save to a subset of the emulator's services; omitted or empty means every service, same as today. Applies uniformly to local files, `pod:` platform saves, and S3-remote pod saves. `validate.ServiceList` (`internal/validate/validate.go`) parses and validates the comma-separated value — syntax only (a regex over `[\w-]+` tokens), never against a known-service allow-list: lstk has no canonical service registry, and the platform itself silently drops unrecognized names rather than rejecting them (mirrors the legacy CLI's `is_comma_delimited_list`). diff --git a/internal/snapshot/auth.go b/internal/snapshot/auth.go new file mode 100644 index 00000000..abee79a5 --- /dev/null +++ b/internal/snapshot/auth.go @@ -0,0 +1,50 @@ +package snapshot + +import ( + "errors" + "strings" + + "github.com/localstack/lstk/internal/output" +) + +// ErrAuthRequired indicates the emulator rejected a cloud snapshot operation +// because the identity it used was missing or not accepted. +// +// Emulator-backed pod operations (save/load/diff/remove) against a locally +// managed emulator deliberately do not pre-check for a token: when the caller +// supplies none, the running emulator reuses the identity it was started with +// (e.g. a LOCALSTACK_AUTH_TOKEN that was only present for `lstk start`). +// Externally-managed targets require caller authentication at the command +// boundary. For local targets, the verdict comes from the emulator and surfaces +// here. +var ErrAuthRequired = errors.New("authentication required for cloud snapshots") + +// emitAuthRequired renders the actionable error for a pod operation the emulator +// rejected on authentication grounds, and returns the error to propagate. +// +// The emulator's own explanation is used as the summary when there is one: the +// rejection can mean a missing identity, a token that is no longer valid, or one +// without access to the snapshot, and only the emulator knows which. +func emitAuthRequired(sink output.Sink, err error) error { + summary := "The emulator has no LocalStack identity to use for cloud snapshots" + if detail := authRejectionDetail(err); detail != "" { + summary = detail + } + sink.Emit(output.ErrorEvent{ + Title: "Authentication failed for cloud snapshots", + Summary: summary, + Code: output.ErrAuthRequired, + Actions: []output.ErrorAction{ + {Label: "Log in:", Value: "lstk login"}, + {Label: "Or provide a valid token via the environment variable:", Value: "LOCALSTACK_AUTH_TOKEN"}, + }, + }) + return output.NewSilentError(err) +} + +// authRejectionDetail returns the message the emulator gave for the rejection, +// i.e. what the client wrapped in ErrAuthRequired, or "" when it carried none. +func authRejectionDetail(err error) string { + detail := strings.TrimPrefix(err.Error(), ErrAuthRequired.Error()) + return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(detail), ":")) +} diff --git a/internal/snapshot/diff.go b/internal/snapshot/diff.go index d61f8e67..94726599 100644 --- a/internal/snapshot/diff.go +++ b/internal/snapshot/diff.go @@ -32,11 +32,8 @@ type PodDiffer interface { // DiffPod calls the diff endpoint for a named pod and emits a SnapshotDiffEvent. // It requires the emulator to already be running (unlike LoadPod, there is no auto-start). // version 0 diffs against the pod's latest version. +// An empty authToken is intentional for a locally managed emulator; see ErrAuthRequired. func DiffPod(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, differ PodDiffer, host, podName string, version int, authToken, strategy string, sink output.Sink) error { - if authToken == "" { - return fmt.Errorf("pod snapshots require authentication — set LOCALSTACK_AUTH_TOKEN or run %q", "lstk login") - } - if err := rt.IsHealthy(ctx); err != nil { rt.EmitUnhealthyError(sink, err) return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) @@ -71,6 +68,9 @@ func DiffPod(ctx context.Context, rt runtime.Runtime, containers []config.Contai if errors.Is(err, ErrPodVersionNotFound) { return emitPodVersionNotFound(err, podName, "Could not check pod diff", sink) } + if errors.Is(err, ErrAuthRequired) { + return emitAuthRequired(sink, err) + } if errors.Is(err, ErrPodNotFound) { sink.Emit(output.ErrorEvent{ Title: "Could not check pod diff", diff --git a/internal/snapshot/diff_test.go b/internal/snapshot/diff_test.go index c923dc01..de6f7064 100644 --- a/internal/snapshot/diff_test.go +++ b/internal/snapshot/diff_test.go @@ -123,15 +123,43 @@ func TestDiffPod_EmptyResult(t *testing.T) { assert.Empty(t, diffEvent.Services) } -func TestDiffPod_NoAuthToken(t *testing.T) { +// Without a caller-supplied token the diff still goes through, with the empty +// token passed on: the running emulator reuses the identity it was started with. +func TestDiffPod_NoAuthTokenReusesEmulatorIdentity(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) differ := NewMockPodDiffer(ctrl) + differ.EXPECT().DiffPodSnapshot(gomock.Any(), gomock.Any(), "my-baseline", 0, ""). + Return(snapshot.DiffResult{}, nil) + sink := output.NewPlainSink(io.Discard) + err := snapshot.DiffPod(context.Background(), healthyRunningMock(t), awsContainers, differ, "", "my-baseline", 0, "", "", sink) + require.NoError(t, err) +} + +// When the emulator has no identity either, its rejection is rendered as an +// actionable error instead of a raw HTTP failure. +func TestDiffPod_AuthRequiredFromEmulator(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + differ := NewMockPodDiffer(ctrl) + differ.EXPECT().DiffPodSnapshot(gomock.Any(), gomock.Any(), "my-baseline", 0, ""). + Return(nil, fmt.Errorf("%w: no credentials", snapshot.ErrAuthRequired)) - err := snapshot.DiffPod(context.Background(), runtime.NewMockRuntime(ctrl), awsContainers, differ, "", "my-baseline", 0, "", "", sink) + sink, getEvents := captureEvents(t) + err := snapshot.DiffPod(context.Background(), healthyRunningMock(t), awsContainers, differ, "", "my-baseline", 0, "", "", sink) require.Error(t, err) - assert.Contains(t, err.Error(), "authentication") + assert.True(t, output.IsSilent(err), "the error should be silent: it is rendered through the sink") + + var errEvent *output.ErrorEvent + for _, e := range getEvents() { + if ev, ok := e.(output.ErrorEvent); ok { + errEvent = &ev + } + } + require.NotNil(t, errEvent, "ErrorEvent should have been emitted") + assert.Contains(t, errEvent.Title, "Authentication failed") + assert.Equal(t, "no credentials", errEvent.Summary, "the emulator's own explanation should be surfaced") } func TestDiffPod_DifferError(t *testing.T) { diff --git a/internal/snapshot/load.go b/internal/snapshot/load.go index 0c592d9a..99b04dde 100644 --- a/internal/snapshot/load.go +++ b/internal/snapshot/load.go @@ -145,6 +145,9 @@ func load(ctx context.Context, rt runtime.Runtime, containers []config.Container }) return output.NewSilentError(err) } + if errors.Is(err, ErrAuthRequired) { + return emitAuthRequired(sink, err) + } if errors.Is(err, ErrPodNotFound) { sink.Emit(output.ErrorEvent{ Title: "Could not load snapshot", @@ -190,16 +193,12 @@ func LoadLocal(ctx context.Context, rt runtime.Runtime, containers []config.Cont // LoadPod loads a platform-hosted cloud snapshot. version 0 loads the pod's // latest version; a non-zero version pins the load to that specific one. +// An empty authToken is intentional for a locally managed emulator; see ErrAuthRequired. func LoadPod(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, loader PodLoader, host, podName string, version int, authToken, strategy string, starter Starter, sink output.Sink) error { - if authToken == "" { - return fmt.Errorf("pod snapshots require authentication — set LOCALSTACK_AUTH_TOKEN or run %q", "lstk login") - } - spinnerText := fmt.Sprintf("Loading snapshot from pod %q...", podName) if version > 0 { spinnerText = fmt.Sprintf("Loading snapshot from pod %q (version %d)...", podName, version) } - var services []string err := load(ctx, rt, containers, sink, starter, spinnerText, diff --git a/internal/snapshot/load_test.go b/internal/snapshot/load_test.go index f833c90b..e561a566 100644 --- a/internal/snapshot/load_test.go +++ b/internal/snapshot/load_test.go @@ -336,15 +336,43 @@ func TestLoadPod_Success(t *testing.T) { assert.Equal(t, []string{"s3", "dynamodb"}, loaded.Services) } -func TestLoadPod_NoAuthToken(t *testing.T) { +// Without a caller-supplied token the load still goes through, with the empty +// token passed on: the running emulator reuses the identity it was started with. +func TestLoadPod_NoAuthTokenReusesEmulatorIdentity(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) loader := NewMockPodLoader(ctrl) + loader.EXPECT().LoadPodSnapshot(gomock.Any(), gomock.Any(), "my-baseline", 0, "", gomock.Any()). + Return([]string{"s3"}, nil) + sink := output.NewPlainSink(io.Discard) + err := snapshot.LoadPod(context.Background(), healthyRunningMock(t), awsContainers, loader, "", "my-baseline", 0, "", "", nopStarter, sink) + require.NoError(t, err) +} + +// When the emulator has no identity either, its rejection is rendered as an +// actionable error instead of a raw HTTP failure. +func TestLoadPod_AuthRequiredFromEmulator(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + loader := NewMockPodLoader(ctrl) + loader.EXPECT().LoadPodSnapshot(gomock.Any(), gomock.Any(), "my-baseline", 0, "", gomock.Any()). + Return(nil, fmt.Errorf("%w: no credentials", snapshot.ErrAuthRequired)) - err := snapshot.LoadPod(context.Background(), runtime.NewMockRuntime(ctrl), awsContainers, loader, "", "my-baseline", 0, "", "", nopStarter, sink) + sink, getEvents := captureEvents(t) + err := snapshot.LoadPod(context.Background(), healthyRunningMock(t), awsContainers, loader, "", "my-baseline", 0, "", "", nopStarter, sink) require.Error(t, err) - assert.Contains(t, err.Error(), "authentication") + assert.True(t, output.IsSilent(err), "the error should be silent: it is rendered through the sink") + + var errEvent *output.ErrorEvent + for _, e := range getEvents() { + if ev, ok := e.(output.ErrorEvent); ok { + errEvent = &ev + } + } + require.NotNil(t, errEvent, "ErrorEvent should have been emitted") + assert.Contains(t, errEvent.Title, "Authentication failed") + assert.Equal(t, "no credentials", errEvent.Summary, "the emulator's own explanation should be surfaced") } func TestLoadPod_LoaderError(t *testing.T) { diff --git a/internal/snapshot/remote.go b/internal/snapshot/remote.go index ffb67fa0..4683d195 100644 --- a/internal/snapshot/remote.go +++ b/internal/snapshot/remote.go @@ -220,6 +220,9 @@ func ListRemoteS3(ctx context.Context, rt runtime.Runtime, containers []config.C } pods, err := client.ListPodsRemote(ctx, host, name, creds.params(), authToken, "") sink.Emit(output.SpinnerStop()) + if errors.Is(err, ErrAuthRequired) { + return emitAuthRequired(sink, err) + } if err != nil { if errors.Is(err, ErrSnapshotFeatureUnavailable) { return emitFeatureUnavailableError(sink) diff --git a/internal/snapshot/remove.go b/internal/snapshot/remove.go index 5e994d3d..bf4d8532 100644 --- a/internal/snapshot/remove.go +++ b/internal/snapshot/remove.go @@ -22,11 +22,8 @@ type PodRemover interface { } // Remove deletes a remote pod snapshot, prompting for confirmation unless force is true. +// An empty authToken is intentional; see ErrAuthRequired. func Remove(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, podName, authToken string, remover PodRemover, host string, force bool, sink output.Sink) error { - if authToken == "" { - return fmt.Errorf("pod snapshots require authentication — set LOCALSTACK_AUTH_TOKEN or run %q", "lstk login") - } - if err := rt.IsHealthy(ctx); err != nil { rt.EmitUnhealthyError(sink, err) return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) @@ -84,6 +81,9 @@ func remove(ctx context.Context, podName, authToken string, remover PodRemover, if errors.Is(err, ErrSnapshotFeatureUnavailable) { return emitFeatureUnavailableError(sink) } + if errors.Is(err, ErrAuthRequired) { + return emitAuthRequired(sink, err) + } if errors.Is(err, ErrPodNotFound) { return fmt.Errorf("cloud pod %q not found", podName) } diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index 5252b5b2..d19cecc8 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -66,10 +66,14 @@ func save(ctx context.Context, rt runtime.Runtime, containers []config.Container } }() - if err := do(); err != nil { + err = do() + if err != nil { if errors.Is(err, ErrSnapshotFeatureUnavailable) { return emitFeatureUnavailableError(sink) } + if errors.Is(err, ErrAuthRequired) { + return emitAuthRequired(sink, err) + } return err } return nil @@ -119,10 +123,8 @@ func fileSize(dest string) int64 { // SavePod saves the running emulator's state to a platform-hosted pod. // services, when non-empty, limits the save to that subset of services. +// An empty authToken is intentional; see ErrAuthRequired. func SavePod(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, saver PodSaver, host, podName, authToken string, services []string, sink output.Sink) error { - if authToken == "" { - return fmt.Errorf("pod snapshots require authentication — set LOCALSTACK_AUTH_TOKEN or run %q", "lstk login") - } var result PodSaveResult return save(ctx, rt, containers, sink, fmt.Sprintf("Saving snapshot to pod %q...", podName), diff --git a/internal/snapshot/save_test.go b/internal/snapshot/save_test.go index 6168b6b1..66b0f5b0 100644 --- a/internal/snapshot/save_test.go +++ b/internal/snapshot/save_test.go @@ -276,15 +276,43 @@ func TestSavePod_Success(t *testing.T) { assert.Equal(t, int64(1048576), saved.Size) } -func TestSavePod_NoAuthToken(t *testing.T) { +// Without a caller-supplied token the save still goes through, with the empty +// token passed on: the running emulator reuses the identity it was started with. +func TestSavePod_NoAuthTokenReusesEmulatorIdentity(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) saver := NewMockPodSaver(ctrl) + saver.EXPECT().SavePodSnapshot(gomock.Any(), gomock.Any(), "my-baseline", "", gomock.Any()). + Return(snapshot.PodSaveResult{Version: 1}, nil) sink := output.NewPlainSink(io.Discard) - err := snapshot.SavePod(context.Background(), runtime.NewMockRuntime(ctrl), awsContainers, saver, "", "my-baseline", "", nil, sink) + err := snapshot.SavePod(context.Background(), healthyRunningMock(t), awsContainers, saver, "", "my-baseline", "", nil, sink) + require.NoError(t, err) +} + +// When the emulator has no identity either, its rejection is rendered as an +// actionable error instead of a raw HTTP failure. +func TestSavePod_AuthRequiredFromEmulator(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + saver := NewMockPodSaver(ctrl) + saver.EXPECT().SavePodSnapshot(gomock.Any(), gomock.Any(), "my-baseline", "", gomock.Any()). + Return(snapshot.PodSaveResult{}, fmt.Errorf("%w: no credentials", snapshot.ErrAuthRequired)) + + sink, getEvents := captureEvents(t) + err := snapshot.SavePod(context.Background(), healthyRunningMock(t), awsContainers, saver, "", "my-baseline", "", nil, sink) require.Error(t, err) - assert.Contains(t, err.Error(), "authentication") + assert.True(t, output.IsSilent(err), "the error should be silent: it is rendered through the sink") + + var errEvent *output.ErrorEvent + for _, e := range getEvents() { + if ev, ok := e.(output.ErrorEvent); ok { + errEvent = &ev + } + } + require.NotNil(t, errEvent, "ErrorEvent should have been emitted") + assert.Contains(t, errEvent.Title, "Authentication failed") + assert.Equal(t, "no credentials", errEvent.Summary, "the emulator's own explanation should be surfaced") } func TestSavePod_EmulatorNotRunning(t *testing.T) { @@ -338,4 +366,3 @@ func TestSavePod_SaverError(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "platform unreachable") } - diff --git a/test/integration/snapshot_external_auth_test.go b/test/integration/snapshot_external_auth_test.go new file mode 100644 index 00000000..3ee98b7c --- /dev/null +++ b/test/integration/snapshot_external_auth_test.go @@ -0,0 +1,54 @@ +package integration_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" +) + +func TestExternalPodOperationsRequireCallerAuthentication(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + }{ + {name: "load", args: []string{"snapshot", "load", "pod:my-baseline"}}, + {name: "dry run", args: []string{"snapshot", "load", "--dry-run", "pod:my-baseline"}}, + {name: "save", args: []string{"snapshot", "save", "pod:my-baseline"}}, + {name: "remove", args: []string{"snapshot", "remove", "pod:my-baseline", "--force"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var podCalls atomic.Int32 + health := awsHealthHandler() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/_localstack/pods") { + podCalls.Add(1) + w.WriteHeader(http.StatusOK) + return + } + health.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + + environ := env.Environ(testEnvWithHome(t.TempDir(), "")). + Without(env.AuthToken). + With(env.DisableEvents, "1") + args := append([]string{"--non-interactive", "--endpoint-url", srv.URL}, tc.args...) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), environ, args...) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "authentication is required for cloud snapshot operations against an externally-managed emulator") + assert.Zero(t, podCalls.Load(), "the protected pod endpoint must not be called without caller authentication") + }) + } +} diff --git a/test/integration/snapshot_load_test.go b/test/integration/snapshot_load_test.go index c954fb28..517b164c 100644 --- a/test/integration/snapshot_load_test.go +++ b/test/integration/snapshot_load_test.go @@ -33,6 +33,28 @@ func mockPodDiffServer(t *testing.T) *httptest.Server { return srv } +// mockPodDiffServerCapturingAuth behaves like mockPodDiffServer but records the +// Authorization header it received (empty when the header was absent). +func mockPodDiffServerCapturingAuth(t *testing.T) (*httptest.Server, func() string) { + t.Helper() + var gotAuth atomic.Value + gotAuth.Store("") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/_localstack/pods/") && + strings.HasSuffix(r.URL.Path, "/diff") && + r.Method == http.MethodGet { + gotAuth.Store(r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"s3":[{"operation_type":"ADDITION"}]}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + return srv, func() string { return gotAuth.Load().(string) } +} + // mockLocalLoadServer returns a test server that handles local snapshot import: // - POST /_localstack/pods → import (always succeeds) // - POST /_localstack/state/reset → state reset (overwrite strategy) @@ -97,6 +119,34 @@ func mockPodLoadServer(t *testing.T, respondOK bool) *httptest.Server { return srv } +// mockPodLoadServerCapturingAuth returns a test server that handles +// PUT /_localstack/pods/{name} with the given status and records the +// Authorization header it received (empty when the header was absent). A status +// of 401/403 mimics an emulator that has no usable identity of its own. +func mockPodLoadServerCapturingAuth(t *testing.T, status int) (*httptest.Server, func() string) { + t.Helper() + var gotAuth atomic.Value + gotAuth.Store("") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/_localstack/pods/") || r.Method != http.MethodPut { + w.WriteHeader(http.StatusNotFound) + return + } + gotAuth.Store(r.Header.Get("Authorization")) + if status != http.StatusOK { + w.WriteHeader(status) + _, _ = w.Write([]byte("no credentials configured")) + return + } + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"event":"service","service":"s3","status":"ok"}` + "\n")) + _, _ = w.Write([]byte(`{"event":"completion","status":"ok"}` + "\n")) + })) + t.Cleanup(srv.Close) + return srv, func() string { return gotAuth.Load().(string) } +} + // mockPodNotFoundServer mimics the emulator response when the requested cloud // snapshot does not exist: the platform version lookup fails, so the load // completes with the generic "Failed to get version information" diagnostic. @@ -175,18 +225,6 @@ func TestSnapshotLoadS3RequiresPodName(t *testing.T) { assert.Contains(t, stderr, "pod name is required") } -func TestSnapshotLoadPodNoAuthToken(t *testing.T) { - t.Parallel() - ctx := testContext(t) - - _, stderr, err := runLstk(t, ctx, t.TempDir(), - env.Environ(testEnvWithHome(t.TempDir(), "")).Without(env.AuthToken), - "--non-interactive", "snapshot", "load", "pod:my-baseline", - ) - requireExitCode(t, 1, err) - assert.Contains(t, stderr, "authentication") -} - func TestSnapshotLoadPodInvalidName(t *testing.T) { t.Parallel() for _, ref := range []string{"pod:", "pod:bad.name", "pod:my pod"} { @@ -365,6 +403,92 @@ func TestSnapshotLoadPodSuccess(t *testing.T) { assert.Contains(t, stdout, "dynamodb") } +// A pod load must not be rejected client-side just because the caller supplied +// no token: the emulator was started with an identity (e.g. a job-level +// LOCALSTACK_AUTH_TOKEN that only `lstk start` saw) and reuses it, so lstk sends +// no Authorization header and lets the emulator decide. +func TestSnapshotLoadPodReusesEmulatorIdentity(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv, gotAuth := mockPodLoadServerCapturingAuth(t, http.StatusOK) + + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.LocalStackHost, lsHost(srv)). + Without(env.AuthToken), + "--non-interactive", "snapshot", "load", "pod:my-baseline", + ) + require.NoError(t, err, "lstk snapshot load pod:my-baseline failed: %s", stderr) + assert.Contains(t, stdout, "Snapshot loaded") + assert.Empty(t, gotAuth(), "no Authorization header should be sent so the emulator reuses its own identity") +} + +// An explicitly supplied token still overrides the emulator's identity. +func TestSnapshotLoadPodExplicitTokenOverridesEmulatorIdentity(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv, gotAuth := mockPodLoadServerCapturingAuth(t, http.StatusOK) + + _, stderr, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.LocalStackHost, lsHost(srv)). + With(env.AuthToken, "the-token"), + "--non-interactive", "snapshot", "load", "pod:my-baseline", + ) + require.NoError(t, err, "lstk snapshot load pod:my-baseline failed: %s", stderr) + assert.Equal(t, "Basic OnRoZS10b2tlbg==", gotAuth()) // base64(":the-token") +} + +// When neither the caller nor the emulator has an identity, the emulator's +// rejection is surfaced as an actionable authentication error. +func TestSnapshotLoadPodEmulatorRejectsUnauthenticated(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv, _ := mockPodLoadServerCapturingAuth(t, http.StatusUnauthorized) + + stdout, _, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.LocalStackHost, lsHost(srv)). + Without(env.AuthToken), + "--non-interactive", "snapshot", "load", "pod:my-baseline", + ) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "Authentication failed") + assert.Contains(t, stdout, "no credentials configured", "the emulator's own explanation should be surfaced") + assert.Contains(t, stdout, "lstk login") +} + +// With no emulator running there is no identity to reuse, so the auto-start path +// still requires a token from the environment or keychain. +func TestSnapshotLoadPodNoAuthTokenAndNoEmulator(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + // Intentionally no startTestContainer: load auto-starts the emulator, which + // needs a token of its own. + + _, stderr, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")).Without(env.AuthToken), + "--non-interactive", "snapshot", "load", "pod:my-baseline", + ) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "authentication required") +} + func TestSnapshotLoadPodServerError(t *testing.T) { requireDocker(t) cleanup() @@ -496,16 +620,44 @@ func TestSnapshotLoadDryRunOnLocalRef(t *testing.T) { assert.Contains(t, stderr, "pod refs") } +// A dry run without a token is not rejected client-side either; it needs a +// running emulator whose identity it can reuse (there is no auto-start). func TestSnapshotLoadDryRunPodNoAuthToken(t *testing.T) { - t.Parallel() + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + // Intentionally no startTestContainer: --dry-run does not auto-start. - _, stderr, err := runLstk(t, ctx, t.TempDir(), + stdout, _, err := runLstk(t, ctx, t.TempDir(), env.Environ(testEnvWithHome(t.TempDir(), "")).Without(env.AuthToken), "--non-interactive", "snapshot", "load", "--dry-run", "pod:my-baseline", ) requireExitCode(t, 1, err) - assert.Contains(t, stderr, "authentication") + assert.Contains(t, stdout, "not running") +} + +// The dry run reuses the running emulator's identity: no token supplied, no +// Authorization header sent, and the diff still runs. +func TestSnapshotLoadDryRunPodReusesEmulatorIdentity(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv, gotAuth := mockPodDiffServerCapturingAuth(t) + + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.LocalStackHost, lsHost(srv)). + Without(env.AuthToken), + "--non-interactive", "snapshot", "load", "--dry-run", "pod:my-baseline", + ) + require.NoError(t, err, "lstk snapshot load --dry-run failed: %s", stderr) + assert.Contains(t, stdout, "Dry-run results") + assert.Empty(t, gotAuth(), "no Authorization header should be sent so the emulator reuses its own identity") } func TestSnapshotLoadDryRunPodSuccess(t *testing.T) { diff --git a/test/integration/snapshot_remove_test.go b/test/integration/snapshot_remove_test.go index 0b720074..5f965547 100644 --- a/test/integration/snapshot_remove_test.go +++ b/test/integration/snapshot_remove_test.go @@ -20,21 +20,25 @@ import ( ) // mockPodRemoveServer returns a test server that handles DELETE /_localstack/pods/{name}. -// status is the HTTP status code to respond with. -// The returned function reports how many times the endpoint was called. -func mockPodRemoveServer(t *testing.T, status int) (*httptest.Server, func() int32) { +// status is the HTTP status code to respond with. The returned functions report how +// many times the endpoint was called and which Authorization header it last received +// (empty when the header was absent). +func mockPodRemoveServer(t *testing.T, status int) (srv *httptest.Server, calls func() int32, auth func() string) { t.Helper() - var calls atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var callCount atomic.Int32 + var gotAuth atomic.Value + gotAuth.Store("") + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/_localstack/pods/") && r.Method == http.MethodDelete { - calls.Add(1) + callCount.Add(1) + gotAuth.Store(r.Header.Get("Authorization")) w.WriteHeader(status) return } w.WriteHeader(http.StatusNotFound) })) t.Cleanup(srv.Close) - return srv, calls.Load + return srv, callCount.Load, func() string { return gotAuth.Load().(string) } } // --- no Docker required (parallel) --- @@ -65,16 +69,23 @@ func TestSnapshotRemoveLocalBareNameRejected(t *testing.T) { assert.Contains(t, stderr, "CLI cannot delete local files") } -func TestSnapshotRemovePodNoAuthToken(t *testing.T) { - t.Parallel() +// Removal goes through the emulator, so without a caller-supplied token there is +// no client-side rejection: with no emulator running the command fails on that +// instead. +func TestSnapshotRemovePodNoAuthTokenAndNoEmulator(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + // Intentionally no startTestContainer: the emulator is not running. - _, stderr, err := runLstk(t, ctx, t.TempDir(), + stdout, _, err := runLstk(t, ctx, t.TempDir(), env.Environ(testEnvWithHome(t.TempDir(), "")).Without(env.AuthToken), "--non-interactive", "snapshot", "remove", "pod:my-baseline", "--force", ) requireExitCode(t, 1, err) - assert.Contains(t, stderr, "authentication") + assert.Contains(t, stdout, "not running") } func TestSnapshotRemovePodInvalidName(t *testing.T) { @@ -132,7 +143,7 @@ func TestSnapshotRemovePodSuccess(t *testing.T) { ctx := testContext(t) startTestContainer(t, ctx) - srv, calls := mockPodRemoveServer(t, http.StatusOK) + srv, calls, _ := mockPodRemoveServer(t, http.StatusOK) stdout, stderr, err := runLstk(t, ctx, t.TempDir(), env.Environ(testEnvWithHome(t.TempDir(), "")). @@ -146,6 +157,51 @@ func TestSnapshotRemovePodSuccess(t *testing.T) { assert.Equal(t, int32(1), calls(), "DELETE endpoint should be called exactly once") } +// Removal without a caller-supplied token reuses the running emulator's +// identity: lstk sends no Authorization header instead of failing client-side. +func TestSnapshotRemovePodReusesEmulatorIdentity(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv, calls, gotAuth := mockPodRemoveServer(t, http.StatusOK) + + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.LocalStackHost, lsHost(srv)). + Without(env.AuthToken), + "--non-interactive", "snapshot", "remove", "pod:my-baseline", "--force", + ) + require.NoError(t, err, "lstk snapshot remove pod:my-baseline failed: %s", stderr) + assert.Contains(t, stdout, "deleted") + assert.Equal(t, int32(1), calls()) + assert.Empty(t, gotAuth(), "no Authorization header should be sent so the emulator reuses its own identity") +} + +// A 401 from the emulator (neither side has an identity) is rendered as an +// actionable authentication error carrying the emulator's own explanation. +func TestSnapshotRemovePodEmulatorRejectsUnauthenticated(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv, _, _ := mockPodRemoveServer(t, http.StatusUnauthorized) + + stdout, _, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.LocalStackHost, lsHost(srv)). + Without(env.AuthToken), + "--non-interactive", "snapshot", "remove", "pod:my-baseline", "--force", + ) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "Authentication failed") + assert.Contains(t, stdout, "lstk login") +} + func TestSnapshotRemovePodServerError(t *testing.T) { requireDocker(t) cleanup() @@ -153,7 +209,7 @@ func TestSnapshotRemovePodServerError(t *testing.T) { ctx := testContext(t) startTestContainer(t, ctx) - srv, calls := mockPodRemoveServer(t, http.StatusInternalServerError) + srv, calls, _ := mockPodRemoveServer(t, http.StatusInternalServerError) _, stderr, err := runLstk(t, ctx, t.TempDir(), env.Environ(testEnvWithHome(t.TempDir(), "")). @@ -233,7 +289,7 @@ func TestSnapshotRemoveInteractive(t *testing.T) { } t.Run("confirms with y", func(t *testing.T) { - srv, calls := mockPodRemoveServer(t, http.StatusOK) + srv, calls, _ := mockPodRemoveServer(t, http.StatusOK) ptmx, out, outputCh, cmd := startRemove(t, srv) _, err := ptmx.Write([]byte("y")) require.NoError(t, err) @@ -245,7 +301,7 @@ func TestSnapshotRemoveInteractive(t *testing.T) { }) t.Run("cancels with n", func(t *testing.T) { - srv, calls := mockPodRemoveServer(t, http.StatusOK) + srv, calls, _ := mockPodRemoveServer(t, http.StatusOK) ptmx, out, outputCh, cmd := startRemove(t, srv) _, err := ptmx.Write([]byte("n")) require.NoError(t, err) @@ -257,7 +313,7 @@ func TestSnapshotRemoveInteractive(t *testing.T) { }) t.Run("force skips confirmation prompt", func(t *testing.T) { - srv, calls := mockPodRemoveServer(t, http.StatusOK) + srv, calls, _ := mockPodRemoveServer(t, http.StatusOK) binPath, err := filepath.Abs(binaryPath()) require.NoError(t, err) diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index 77975785..77aa4959 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "testing" "github.com/localstack/lstk/test/integration/env" @@ -363,6 +364,26 @@ func mockPodSaveServer(t *testing.T, respondOK bool) *httptest.Server { return srv } +// mockPodSaveServerCapturingAuth behaves like mockPodSaveServer but records the +// Authorization header it received (empty when the header was absent). +func mockPodSaveServerCapturingAuth(t *testing.T) (*httptest.Server, func() string) { + t.Helper() + var gotAuth atomic.Value + gotAuth.Store("") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/_localstack/pods/") && r.Method == http.MethodPost { + gotAuth.Store(r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"event":"completion","status":"ok","operation":"save","info":{"name":"my-baseline","version":1,"remote":"platform","services":["s3"],"size":1024}}` + "\n")) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + return srv, func() string { return gotAuth.Load().(string) } +} + func TestSnapshotSavePodSuccess(t *testing.T) { requireDocker(t) cleanup() @@ -404,16 +425,44 @@ func TestSnapshotSavePodServerError(t *testing.T) { assert.Contains(t, stderr, "platform error") } -func TestSnapshotSavePodNoAuthToken(t *testing.T) { - t.Parallel() +// A pod save without a caller-supplied token reuses the running emulator's +// identity: lstk sends no Authorization header instead of failing client-side. +func TestSnapshotSavePodReusesEmulatorIdentity(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startTestContainer(t, ctx) + srv, gotAuth := mockPodSaveServerCapturingAuth(t) - _, stderr, err := runLstk(t, ctx, t.TempDir(), + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.LocalStackHost, lsHost(srv)). + Without(env.AuthToken), + "--non-interactive", "snapshot", "save", "pod:my-baseline", + ) + require.NoError(t, err, "lstk snapshot save pod:my-baseline failed: %s", stderr) + assert.Contains(t, stdout, "my-baseline") + assert.Empty(t, gotAuth(), "no Authorization header should be sent so the emulator reuses its own identity") +} + +// With no emulator running there is no identity to reuse, and save does not +// auto-start one. +func TestSnapshotSavePodNoAuthTokenAndNoEmulator(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + // Intentionally no startTestContainer: the emulator is not running. + + stdout, _, err := runLstk(t, ctx, t.TempDir(), env.Environ(testEnvWithHome(t.TempDir(), "")).Without(env.AuthToken), "--non-interactive", "snapshot", "save", "pod:my-baseline", ) requireExitCode(t, 1, err) - assert.Contains(t, stderr, "authentication") + assert.Contains(t, stdout, "not running") } func TestSnapshotSavePodInvalidName(t *testing.T) {