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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions datastore/gcs/extensions/datastores/_lib/gcs_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ export type GcpCredentialErrorKind =
* Detection is HTTP-status-only for the credentials-rejected branch — body
* shape varies (JSON vs HTML vs plain text on edge cases) and is not
* reliable for classification.
*
* The `causeName` parameter is intended for callers that need to
* re-classify a caught `GcsOperationError` (e.g. by passing in `err.name`).
* `send()` itself never reaches the cause-name branch from this function —
* the session-expired path for token-refresh failures is short-circuited
* inside `tokenRefreshError`, which embeds the hint at construction time.
*/
export function classifyGcpCredentialError(
causeName: string | undefined,
Expand Down Expand Up @@ -278,16 +284,15 @@ async function createSignedJwt(
/**
* Wrap a token-endpoint failure as a `GcsOperationError`. When the body
* indicates `invalid_grant` (refresh token revoked, expired, or never valid),
* stamp `name = "CredentialsProviderError"` so the downstream classifier in
* `send()` recognises the SSO-equivalent path. Other failures keep a
* generic name; the message preserves status + body for debugging.
*/
/**
* Wrap a token-endpoint failure as a GcsOperationError. Exported for tests
* because mocking `oauth2.googleapis.com/token` from outside the module
* isn't feasible (the URL is hardcoded in `tokenFromUserCredentials`).
* Exporting this lets us prove end-to-end that an `invalid_grant` body
* produces the swamp-flavoured "session expired" message.
* stamp `name = "CredentialsProviderError"` and front-load the
* swamp-flavoured "session expired" hint so callers see the cause and
* remediation before the raw token-endpoint response. Other failures keep
* a generic `TokenRefreshError` name; the message preserves status + body
* for debugging.
*
* Exported so tests can drive it directly — mocking the hardcoded
* `oauth2.googleapis.com/token` URL inside `tokenFromUserCredentials` from
* outside the module isn't feasible.
*/
export function tokenRefreshError(
context: string,
Expand Down Expand Up @@ -373,6 +378,12 @@ async function tokenFromMetadataServer(): Promise<TokenResponse> {
{ headers: { "Metadata-Flavor": "Google" } },
);
if (!resp.ok) {
// Intentionally a plain Error rather than the typed `tokenRefreshError`
// path used by the user-credential and service-account paths. The
// remediation differs (check the instance's attached service account
// and IAM scopes, not `gcloud auth application-default login`), so
// stamping `name = "CredentialsProviderError"` here would mislead
// `classifyGcpCredentialError` into prepending the wrong hint.
throw new Error(
`Metadata server token request failed: ${resp.status} ${await resp
.text()}`,
Expand Down
11 changes: 6 additions & 5 deletions datastore/gcs/extensions/datastores/_lib/gcs_client_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,12 @@ Deno.test("formatGcpCredentialHint: other → undefined", () => {

// --- Wrapper integration tests -------------------------------------------
//
// The token-refresh path is exercised by writing a temporary
// `authorized_user` credentials file, pointing the OAuth token endpoint at
// our mock server, and intercepting the refresh request. `GOOGLE_APPLICATION_CREDENTIALS`
// is restored in `finally`. `clearTokenCache()` runs in setup AND teardown
// because the module-level cache leaks across tests otherwise.
// `send()` is exercised against a mock server returning the relevant
// status codes. `clearTokenCache()` runs in setup AND teardown because
// the module-level token cache leaks across tests otherwise. The
// token-refresh `invalid_grant` path is covered separately below by
// driving `tokenRefreshError` directly — see the comment on its export
// for why we don't mock the OAuth endpoint.

Deno.test({
sanitizeResources: false,
Expand Down
2 changes: 1 addition & 1 deletion datastore/gcs/manifest.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
manifestVersion: 1
name: "@swamp/gcs-datastore"
version: "2026.05.04.2"
version: "2026.05.04.3"
description: |
Store data in a Google Cloud Storage bucket with local cache synchronization.
Provides distributed locking via GCS generation-based preconditions and
Expand Down
5 changes: 4 additions & 1 deletion datastore/s3/extensions/datastores/_lib/s3_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,11 @@ export function formatAwsCredentialHint(
awsProfile: string | undefined,
): string | undefined {
if (kind === "session-expired") {
// Wrap the profile name in double quotes inside the single-quoted
// command so the copy-pasted shell command stays valid for profiles
// that contain spaces (uncommon but legal in AWS config).
const cmd = awsProfile
? `aws sso login --profile ${awsProfile}`
? `aws sso login --profile "${awsProfile}"`
: `aws sso login`;
return `Datastore session expired: your AWS profile's SSO session is no longer valid. Run '${cmd}' to refresh, then retry.`;
}
Expand Down
25 changes: 20 additions & 5 deletions datastore/s3/extensions/datastores/_lib/s3_client_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,16 +400,29 @@ Deno.test("classifyAwsCredentialError: undefined code + 403 → other", () => {
);
});

Deno.test("formatAwsCredentialHint: session-expired with profile renders --profile flag", () => {
Deno.test("formatAwsCredentialHint: session-expired with profile renders quoted --profile flag", () => {
const hint = formatAwsCredentialHint("session-expired", "demo");
assert(hint !== undefined);
// Profile is double-quoted so spaces in profile names don't break the
// copy-pasted command. Outer single quotes wrap the whole command in prose.
assert(
hint.includes("aws sso login --profile demo"),
`expected --profile in hint, got: ${hint}`,
hint.includes(`aws sso login --profile "demo"`),
`expected double-quoted --profile in hint, got: ${hint}`,
);
assert(hint.startsWith("Datastore session expired"));
});

Deno.test("formatAwsCredentialHint: session-expired with multi-word profile stays valid shell", () => {
const hint = formatAwsCredentialHint("session-expired", "my dev profile");
assert(hint !== undefined);
// The full command appears as `Run 'aws sso login --profile "my dev profile"' to refresh`
// — single-quoted outer, double-quoted profile, valid POSIX shell.
assert(
hint.includes(`aws sso login --profile "my dev profile"`),
`expected multi-word profile to be double-quoted, got: ${hint}`,
);
});

Deno.test("formatAwsCredentialHint: session-expired without profile renders generic command", () => {
const hint = formatAwsCredentialHint("session-expired", undefined);
assert(hint !== undefined);
Expand Down Expand Up @@ -743,8 +756,10 @@ Deno.test({
`expected aws sso login remediation, got: ${err.message}`,
);
assert(
err.message.includes("--profile swamp-issue-226-nonexistent-profile"),
`expected --profile flag with the configured profile, got: ${err.message}`,
err.message.includes(
`--profile "swamp-issue-226-nonexistent-profile"`,
),
`expected double-quoted --profile flag with the configured profile, got: ${err.message}`,
);
// .name preserved so existing `error.name === "CredentialsProviderError"`
// checks at call sites keep working.
Expand Down
2 changes: 1 addition & 1 deletion datastore/s3/manifest.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
manifestVersion: 1
name: "@swamp/s3-datastore"
version: "2026.05.04.2"
version: "2026.05.04.3"
description: |
Store data in an Amazon S3 bucket with local cache synchronization.
Provides distributed locking via S3 conditional writes and bidirectional
Expand Down
Loading