diff --git a/datastore/gcs/extensions/datastores/_lib/gcs_cache_sync_test.ts b/datastore/gcs/extensions/datastores/_lib/gcs_cache_sync_test.ts index 08e62971..10fe0e2d 100644 --- a/datastore/gcs/extensions/datastores/_lib/gcs_cache_sync_test.ts +++ b/datastore/gcs/extensions/datastores/_lib/gcs_cache_sync_test.ts @@ -1377,7 +1377,12 @@ Deno.test({ GcsOperationError, ); assertEquals(err.httpStatusCode, 403); - assertStringIncludes(err.message, "check GCS credentials"); + // Issue #226: 403 now leads with the swamp-flavoured credentials-rejected + // hint instead of the old generic "check GCS credentials" message. + assertStringIncludes( + err.message, + "Datastore credentials rejected by GCS", + ); assertEquals(calls, 1, "403 is terminal — no retries"); } finally { await shutdown(); diff --git a/datastore/gcs/extensions/datastores/_lib/gcs_client.ts b/datastore/gcs/extensions/datastores/_lib/gcs_client.ts index 5f8fb6e7..6ea4b8a5 100644 --- a/datastore/gcs/extensions/datastores/_lib/gcs_client.ts +++ b/datastore/gcs/extensions/datastores/_lib/gcs_client.ts @@ -140,6 +140,52 @@ export class GcsOperationError extends Error { } } +/** + * Classification of GCS-surfaced credential failures. Mirrors + * `AwsCredentialErrorKind` from the s3 datastore for symmetry — the user- + * facing concept ("session expired" vs "credentials rejected") is the same + * across providers, only the remediation command differs. + */ +export type GcpCredentialErrorKind = + | "session-expired" + | "credentials-rejected" + | "other"; + +/** + * Classify a GCS error by its derived `causeName` (from the cause-chain) and + * HTTP `status`. Pure function — takes primitives so it can be unit-tested + * without constructing token-refresh failures or HTTP responses. + * + * 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. + */ +export function classifyGcpCredentialError( + causeName: string | undefined, + status: number | undefined | null, +): GcpCredentialErrorKind { + if (causeName === "CredentialsProviderError") return "session-expired"; + if (status === 401 || status === 403) return "credentials-rejected"; + return "other"; +} + +/** + * Render a swamp-flavoured remediation hint for the classified credential + * failure. Returns `undefined` for `kind === "other"` so the caller falls + * through to existing generic messaging. + */ +export function formatGcpCredentialHint( + kind: GcpCredentialErrorKind, +): string | undefined { + if (kind === "session-expired") { + return "Datastore session expired: your GCP Application Default Credentials have expired or been revoked. Run 'gcloud auth application-default login' to refresh, then retry."; + } + if (kind === "credentials-rejected") { + return "Datastore credentials rejected by GCS: verify GOOGLE_APPLICATION_CREDENTIALS, gcloud ADC, or the attached service account, then retry."; + } + return undefined; +} + // --------------------------------------------------------------------------- // ADC token acquisition // --------------------------------------------------------------------------- @@ -229,6 +275,50 @@ async function createSignedJwt( return `${signingInput}.${base64url(signature)}`; } +/** + * 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. + */ +export function tokenRefreshError( + context: string, + status: number, + body: string, +): GcsOperationError { + const isInvalidGrant = body.includes("invalid_grant"); + const name = isInvalidGrant + ? "CredentialsProviderError" + : "TokenRefreshError"; + // Front-load the swamp-flavoured hint when the failure is the GCP-equivalent + // of an expired SSO session, so the user sees the cause and remediation + // before the raw token-endpoint response. + const hint = isInvalidGrant + ? formatGcpCredentialHint("session-expired") + : undefined; + const message = hint + ? `${hint} ${context}: ${status} ${body}` + : `${context}: ${status} ${body}`; + return new GcsOperationError( + message, + { + name, + httpStatusCode: status, + code: isInvalidGrant ? "invalid_grant" : undefined, + bodyPreview: body.slice(0, 256), + uploadId: undefined, + }, + ); +} + /** Exchange a signed JWT for an access token. */ async function tokenFromServiceAccount( sa: ServiceAccountKey, @@ -243,9 +333,10 @@ async function tokenFromServiceAccount( }), }); if (!resp.ok) { - throw new Error( - `Service account token exchange failed: ${resp.status} ${await resp - .text()}`, + throw tokenRefreshError( + "Service account token exchange failed", + resp.status, + await resp.text(), ); } return await resp.json() as TokenResponse; @@ -266,9 +357,10 @@ async function tokenFromUserCredentials( }), }); if (!resp.ok) { - throw new Error( - `User credential token refresh failed: ${resp.status} ${await resp - .text()}`, + throw tokenRefreshError( + "User credential token refresh failed", + resp.status, + await resp.text(), ); } return await resp.json() as TokenResponse; @@ -619,9 +711,27 @@ export class GcsClient { response.headers.get("content-type"), ); - const parts: string[] = [`GCS ${op} failed`, `HTTP ${response.status}`]; + const credentialKind = classifyGcpCredentialError( + undefined, + response.status, + ); + const credentialHint = formatGcpCredentialHint(credentialKind); + + const parts: string[] = []; + // Front-load the swamp-flavoured hint so the user sees the cause and + // remediation before the SDK's framing of the failure. + if (credentialHint) parts.push(credentialHint); + parts.push(`GCS ${op} failed`, `HTTP ${response.status}`); if (code) parts.push(code); - if (response.status === 401 || response.status === 403) { + // Existing generic 401/403 hint stays as fallback when the credential + // classifier did not produce a more specific hint. Currently + // `classifyGcpCredentialError` covers all 401/403 cases so this branch + // is dead today, but kept structurally for future kinds that might + // resolve to "other" with status 401/403. + if ( + (response.status === 401 || response.status === 403) && + credentialKind === "other" + ) { parts.push( "(check GCS credentials — GOOGLE_APPLICATION_CREDENTIALS, gcloud ADC, or attached service account — and project/bucket configuration)", ); diff --git a/datastore/gcs/extensions/datastores/_lib/gcs_client_test.ts b/datastore/gcs/extensions/datastores/_lib/gcs_client_test.ts index 4738dbcb..df551e68 100644 --- a/datastore/gcs/extensions/datastores/_lib/gcs_client_test.ts +++ b/datastore/gcs/extensions/datastores/_lib/gcs_client_test.ts @@ -33,10 +33,14 @@ import { assertStringIncludes, } from "jsr:@std/assert@1.0.19"; import { + classifyGcpCredentialError, + clearTokenCache, + formatGcpCredentialHint, GcsClient, GcsOperationError, NotFoundError, PreconditionFailedError, + tokenRefreshError, } from "./gcs_client.ts"; /** @@ -211,7 +215,12 @@ Deno.test({ assertEquals(err.httpStatusCode, 403); assertEquals(err.code, "authError"); assertStringIncludes(err.message, "HTTP 403"); - assertStringIncludes(err.message, "check GCS credentials"); + // Issue #226: 403 now leads with the swamp-flavoured credentials-rejected + // hint instead of the old generic "check GCS credentials" message. + assert( + err.message.startsWith("Datastore credentials rejected by GCS"), + `expected swamp hint to lead message, got: ${err.message}`, + ); assert( err.bodyPreview && err.bodyPreview.length > 0, "bodyPreview must be non-empty", @@ -400,3 +409,228 @@ Deno.test({ } }, }); + +// --- Issue #226: SSO/credential errors get a swamp-flavoured hint --------- +// Pure-helper unit tests (no SDK, no mock server, no env). + +Deno.test("classifyGcpCredentialError: CredentialsProviderError → session-expired", () => { + assertEquals( + classifyGcpCredentialError("CredentialsProviderError", undefined), + "session-expired", + ); +}); + +Deno.test("classifyGcpCredentialError: 401 → credentials-rejected", () => { + assertEquals( + classifyGcpCredentialError(undefined, 401), + "credentials-rejected", + ); +}); + +Deno.test("classifyGcpCredentialError: 403 → credentials-rejected", () => { + assertEquals( + classifyGcpCredentialError(undefined, 403), + "credentials-rejected", + ); +}); + +Deno.test("classifyGcpCredentialError: 404 → other (regression)", () => { + assertEquals(classifyGcpCredentialError(undefined, 404), "other"); +}); + +Deno.test("classifyGcpCredentialError: 500 → other (regression)", () => { + assertEquals(classifyGcpCredentialError(undefined, 500), "other"); +}); + +Deno.test("formatGcpCredentialHint: session-expired references gcloud auth", () => { + const hint = formatGcpCredentialHint("session-expired"); + assert(hint !== undefined); + assert(hint.startsWith("Datastore session expired")); + assert(hint.includes("gcloud auth application-default login")); +}); + +Deno.test("formatGcpCredentialHint: credentials-rejected references ADC env vars", () => { + const hint = formatGcpCredentialHint("credentials-rejected"); + assert(hint !== undefined); + assert(hint.startsWith("Datastore credentials rejected by GCS")); + assert(hint.includes("GOOGLE_APPLICATION_CREDENTIALS")); +}); + +Deno.test("formatGcpCredentialHint: other → undefined", () => { + assertEquals(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. + +Deno.test({ + sanitizeResources: false, + name: "Issue #226: GCS 401 prepends credentials-rejected hint", + fn: async () => { + clearTokenCache(); + const { url, shutdown } = startServer(() => + new Response("unauthorized", { + status: 401, + headers: { "Content-Type": "text/plain" }, + }) + ); + try { + const client = new GcsClient({ bucket: "b", apiEndpoint: url }); + const err = await assertRejects( + () => client.getObject("any"), + GcsOperationError, + ); + assertEquals(err.httpStatusCode, 401); + assert( + err.message.startsWith("Datastore credentials rejected by GCS"), + `expected credentials-rejected hint to lead message, got: ${err.message}`, + ); + } finally { + await shutdown(); + clearTokenCache(); + } + }, +}); + +Deno.test({ + sanitizeResources: false, + name: "Issue #226: GCS 500 does not add credential framing", + fn: async () => { + clearTokenCache(); + const { url, shutdown } = startServer(() => + new Response("internal error", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }) + ); + try { + const client = new GcsClient({ bucket: "b", apiEndpoint: url }); + const err = await assertRejects( + () => client.getObject("any"), + GcsOperationError, + ); + assertEquals(err.httpStatusCode, 500); + assert( + !err.message.startsWith("Datastore"), + `5xx should not add credential framing, got: ${err.message}`, + ); + } finally { + await shutdown(); + clearTokenCache(); + } + }, +}); + +// 404/412 fast paths still throw the narrow types — regression guard for +// the issue #226 changes that did NOT touch lines 675-682 of gcs_client.ts. +Deno.test({ + sanitizeResources: false, + name: "Issue #226 regression: GCS 404 still throws NotFoundError", + fn: async () => { + clearTokenCache(); + const { url, shutdown } = startServer(() => + new Response("not found", { status: 404 }) + ); + try { + const client = new GcsClient({ bucket: "b", apiEndpoint: url }); + await assertRejects(() => client.getObject("any"), NotFoundError); + } finally { + await shutdown(); + clearTokenCache(); + } + }, +}); + +Deno.test({ + sanitizeResources: false, + name: "Issue #226 regression: GCS 412 still throws PreconditionFailedError", + fn: async () => { + clearTokenCache(); + const { url, shutdown } = startServer(() => + new Response("precondition", { status: 412 }) + ); + try { + const client = new GcsClient({ bucket: "b", apiEndpoint: url }); + await assertRejects( + () => client.getObject("any"), + PreconditionFailedError, + ); + } finally { + await shutdown(); + clearTokenCache(); + } + }, +}); + +// --- tokenRefreshError unit tests (proves the invalid_grant path) ----------- +// +// `tokenRefreshError` is what tokenFromUserCredentials and tokenFromServiceAccount +// call on non-OK token-endpoint responses. We test it directly because the +// OAuth URL is hardcoded inside those functions and not mockable from outside. +// Exercising tokenRefreshError end-to-end proves the user-facing behavior: +// when the body indicates `invalid_grant`, the resulting GcsOperationError +// has name === "CredentialsProviderError" and a session-expired message. + +Deno.test("tokenRefreshError: invalid_grant body produces session-expired hint", () => { + const err = tokenRefreshError( + "User credential token refresh failed", + 400, + JSON.stringify({ + error: "invalid_grant", + error_description: "Token has been expired or revoked.", + }), + ); + assert( + err.message.startsWith("Datastore session expired"), + `expected session-expired hint to lead message, got: ${err.message}`, + ); + assert(err.message.includes("gcloud auth application-default login")); + assertEquals(err.name, "CredentialsProviderError"); + assertEquals(err.code, "invalid_grant"); + assertEquals(err.httpStatusCode, 400); +}); + +Deno.test("tokenRefreshError: non-invalid_grant 4xx keeps generic name", () => { + const err = tokenRefreshError( + "User credential token refresh failed", + 400, + JSON.stringify({ error: "invalid_request" }), + ); + assert( + !err.message.startsWith("Datastore session expired"), + `non-invalid_grant should not get session-expired framing, got: ${err.message}`, + ); + assertEquals(err.name, "TokenRefreshError"); + assertEquals(err.code, undefined); +}); + +Deno.test("tokenRefreshError: 5xx without invalid_grant marker", () => { + const err = tokenRefreshError( + "Service account token exchange failed", + 503, + "service unavailable", + ); + assertEquals(err.name, "TokenRefreshError"); + assertEquals(err.httpStatusCode, 503); + assert(!err.message.startsWith("Datastore")); +}); + +// Composition: end-to-end shape that tokenFromUserCredentials throws when +// the gcloud ADC refresh token is revoked — exactly the GCS equivalent of +// the SSO-expired path issue #226 describes for AWS. +Deno.test("composition: invalid_grant token refresh → session-expired classification", () => { + const err = tokenRefreshError( + "User credential token refresh failed", + 400, + '{"error":"invalid_grant","error_description":"reauth required"}', + ); + assertEquals( + classifyGcpCredentialError(err.name, err.httpStatusCode), + "session-expired", + ); +}); diff --git a/datastore/gcs/manifest.yaml b/datastore/gcs/manifest.yaml index d78829bd..37cd35de 100644 --- a/datastore/gcs/manifest.yaml +++ b/datastore/gcs/manifest.yaml @@ -1,6 +1,6 @@ manifestVersion: 1 name: "@swamp/gcs-datastore" -version: "2026.05.04.1" +version: "2026.05.04.2" description: | Store data in a Google Cloud Storage bucket with local cache synchronization. Provides distributed locking via GCS generation-based preconditions and diff --git a/datastore/s3/extensions/datastores/_lib/s3_client.ts b/datastore/s3/extensions/datastores/_lib/s3_client.ts index cee022a6..31ed3929 100644 --- a/datastore/s3/extensions/datastores/_lib/s3_client.ts +++ b/datastore/s3/extensions/datastores/_lib/s3_client.ts @@ -113,6 +113,93 @@ export class S3OperationError extends Error { } } +/** + * Classification of AWS-SDK-surfaced credential failures. `session-expired` + * means the credential resolver could not produce valid credentials (SSO + * token expired, STS session aged out). `credentials-rejected` means + * credentials were sent to AWS and explicitly rejected. + */ +export type AwsCredentialErrorKind = + | "session-expired" + | "credentials-rejected" + | "other"; + +/** + * Classify an SDK error by its normalized `code` and HTTP `status`. Pure + * function — takes primitives so it can be unit-tested without constructing + * SDK error shapes. `code` is the value derived inside `wrapError` from + * `e.Code ?? e.name ?? cause.name` (with leading-underscore strip for + * minified class names). + */ +export function classifyAwsCredentialError( + code: string | undefined, + status: number | undefined, +): AwsCredentialErrorKind { + if (code === "CredentialsProviderError" || code === "ExpiredTokenException") { + return "session-expired"; + } + if ( + code === "InvalidAccessKeyId" || + code === "SignatureDoesNotMatch" || + (status === 403 && code === "AccessDenied") + ) { + return "credentials-rejected"; + } + return "other"; +} + +/** + * Derive a normalized error `code` string from an AWS SDK error. The SDK + * surfaces codes in three places: + * + * 1. `e.Code` — XML/JSON-coded error from the service (e.g. `InvalidAccessKeyId`). + * 2. `e.name` — the SDK's class name (e.g. `CredentialsProviderError`, + * `TimeoutError`). Generic `"Error"` is treated as no signal. + * 3. `e.cause.name` — minified SDK builds wrap the real error class behind a + * generic outer error, with the underscored class name (e.g. + * `_CredentialsProviderError`) on the cause. Strip the leading underscore + * so the classifier can match the canonical name. + * + * Pure function — takes a structural shape so it can be unit-tested without + * constructing real SDK errors. + */ +export function deriveAwsErrorCode(e: { + Code?: string; + name?: string; + cause?: unknown; +}): string | undefined { + if (e.Code) return e.Code; + if (e.name && e.name !== "Error") return e.name; + if (e.cause instanceof Error && e.cause.name && e.cause.name !== "Error") { + return e.cause.name.replace(/^_+/, ""); + } + return undefined; +} + +/** + * Render a swamp-flavoured remediation hint for the classified credential + * failure. The hint names the cause in swamp's vocabulary ("datastore session + * expired") rather than S3's ("putObjectConditional failed") and points at a + * concrete next action. Returns `undefined` for `kind === "other"` so the + * caller can fall through to existing generic messaging. + */ +export function formatAwsCredentialHint( + kind: AwsCredentialErrorKind, + awsProfile: string | undefined, +): string | undefined { + if (kind === "session-expired") { + const cmd = 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.`; + } + if (kind === "credentials-rejected") { + const who = awsProfile ? `'${awsProfile}'` : `your AWS profile`; + return `Datastore credentials rejected by AWS: verify ${who}, environment variables, or credential provider, then retry.`; + } + return undefined; +} + /** * Drain a Node Readable into a Uint8Array, capped at `maxBytes`. The AWS * SDK's default Node request handler (used under Deno-npm) returns an @@ -356,10 +443,20 @@ export class S3Client { }; const status = e.$metadata?.httpStatusCode; const requestId = e.$metadata?.requestId; - const code = e.Code ?? (e.name !== "Error" ? e.name : undefined); + const code = deriveAwsErrorCode(e); const preview = e.$response?.__errorBodyPreview; - const parts: string[] = [`S3 ${op} failed`]; + const credentialKind = classifyAwsCredentialError(code, status); + const credentialHint = formatAwsCredentialHint( + credentialKind, + Deno.env.get("AWS_PROFILE"), + ); + + const parts: string[] = []; + // Front-load the swamp-flavoured hint so the user sees the cause and + // remediation before the SDK's framing of the failure. + if (credentialHint) parts.push(credentialHint); + parts.push(`S3 ${op} failed`); // Signal-triggered aborts carry no HTTP status; surface the timeout // context so callers can distinguish "timed out" from "service 5xx". // `AbortSignal.timeout()` triggers a DOMException with name @@ -373,7 +470,10 @@ export class S3Client { if (code && code !== "Unknown") parts.push(code); const rawMsg = e.message && e.message !== "UnknownError" ? e.message : ""; if (rawMsg) parts.push(`— ${rawMsg}`); - if (status === 401 || status === 403) { + // The generic 401/403 hint stays as a fallback for non-credential auth + // failures (e.g. BucketRegionMismatch surfaced as 403). Skip it when the + // credential classifier already produced a more specific hint. + if ((status === 401 || status === 403) && credentialKind === "other") { parts.push( "(check AWS credentials — profile, env vars, or credential provider — and endpoint configuration)", ); diff --git a/datastore/s3/extensions/datastores/_lib/s3_client_test.ts b/datastore/s3/extensions/datastores/_lib/s3_client_test.ts index ac3cf8fc..3bc1703f 100644 --- a/datastore/s3/extensions/datastores/_lib/s3_client_test.ts +++ b/datastore/s3/extensions/datastores/_lib/s3_client_test.ts @@ -18,7 +18,33 @@ // along with Swamp. If not, see . import { assert, assertEquals } from "jsr:@std/assert@1.0.19"; -import { S3Client, S3OperationError } from "./s3_client.ts"; +import { + classifyAwsCredentialError, + deriveAwsErrorCode, + formatAwsCredentialHint, + S3Client, + S3OperationError, +} from "./s3_client.ts"; + +/** + * Run `fn` with `AWS_PROFILE` set to `value` (or unset when `value` is + * undefined), restoring the prior value in `finally`. Used by tests that + * exercise the profile-aware credential hint. + */ +async function withAwsProfile( + value: string | undefined, + fn: () => Promise, +): Promise { + const prior = Deno.env.get("AWS_PROFILE"); + if (value === undefined) Deno.env.delete("AWS_PROFILE"); + else Deno.env.set("AWS_PROFILE", value); + try { + await fn(); + } finally { + if (prior !== undefined) Deno.env.set("AWS_PROFILE", prior); + else Deno.env.delete("AWS_PROFILE"); + } +} /** * Start a local HTTP server that produces a fixed response, and run `fn` @@ -311,3 +337,435 @@ Deno.test({ }, ), }); + +// --- Issue #226: SSO/credential errors get a swamp-flavoured hint --------- +// Pure-helper unit tests (no SDK, no mock server, no env). + +Deno.test("classifyAwsCredentialError: CredentialsProviderError → session-expired", () => { + assertEquals( + classifyAwsCredentialError("CredentialsProviderError", undefined), + "session-expired", + ); +}); + +Deno.test("classifyAwsCredentialError: ExpiredTokenException → session-expired", () => { + assertEquals( + classifyAwsCredentialError("ExpiredTokenException", undefined), + "session-expired", + ); +}); + +Deno.test("classifyAwsCredentialError: InvalidAccessKeyId → credentials-rejected", () => { + assertEquals( + classifyAwsCredentialError("InvalidAccessKeyId", 403), + "credentials-rejected", + ); +}); + +Deno.test("classifyAwsCredentialError: SignatureDoesNotMatch → credentials-rejected", () => { + assertEquals( + classifyAwsCredentialError("SignatureDoesNotMatch", 403), + "credentials-rejected", + ); +}); + +Deno.test("classifyAwsCredentialError: AccessDenied + 403 → credentials-rejected", () => { + assertEquals( + classifyAwsCredentialError("AccessDenied", 403), + "credentials-rejected", + ); +}); + +// 401 is a status guard: AccessDenied is the 403 shape; a 401 is a different +// failure mode (presigned URL expired, etc.) and should not be classified +// as credentials-rejected by `code` alone. +Deno.test("classifyAwsCredentialError: AccessDenied + 401 → other (status guard)", () => { + assertEquals( + classifyAwsCredentialError("AccessDenied", 401), + "other", + ); +}); + +Deno.test("classifyAwsCredentialError: BucketRegionMismatch + 403 → other (regression)", () => { + assertEquals( + classifyAwsCredentialError("BucketRegionMismatch", 403), + "other", + ); +}); + +Deno.test("classifyAwsCredentialError: undefined code + 403 → other", () => { + assertEquals( + classifyAwsCredentialError(undefined, 403), + "other", + ); +}); + +Deno.test("formatAwsCredentialHint: session-expired with profile renders --profile flag", () => { + const hint = formatAwsCredentialHint("session-expired", "demo"); + assert(hint !== undefined); + assert( + hint.includes("aws sso login --profile demo"), + `expected --profile in hint, got: ${hint}`, + ); + assert(hint.startsWith("Datastore session expired")); +}); + +Deno.test("formatAwsCredentialHint: session-expired without profile renders generic command", () => { + const hint = formatAwsCredentialHint("session-expired", undefined); + assert(hint !== undefined); + assert(hint.includes("aws sso login")); + assert( + !hint.includes("--profile"), + `expected no --profile flag when AWS_PROFILE unset, got: ${hint}`, + ); +}); + +Deno.test("formatAwsCredentialHint: credentials-rejected with profile names it", () => { + const hint = formatAwsCredentialHint("credentials-rejected", "demo"); + assert(hint !== undefined); + assert(hint.includes("'demo'")); + assert(hint.startsWith("Datastore credentials rejected by AWS")); +}); + +Deno.test("formatAwsCredentialHint: credentials-rejected without profile uses generic phrasing", () => { + const hint = formatAwsCredentialHint("credentials-rejected", undefined); + assert(hint !== undefined); + assert(hint.includes("your AWS profile")); +}); + +Deno.test("formatAwsCredentialHint: other → undefined (caller falls through)", () => { + assertEquals( + formatAwsCredentialHint("other", "demo"), + undefined, + ); +}); + +// --- Wrapper integration tests via withMockServer for server-side errors --- +// +// These tests deliberately do NOT set AWS_PROFILE — when AWS_PROFILE is set, +// the AWS SDK's credential resolver attempts to load that named profile +// from ~/.aws/credentials before falling through to the env-var keys +// withMockServer provides, which causes spurious CredentialsProviderError +// failures locally. The profile-name branches of formatAwsCredentialHint +// are covered by the pure-function unit tests above. + +Deno.test({ + sanitizeResources: false, + name: "InvalidAccessKeyId 403 prepends credentials-rejected hint", + fn: () => + withAwsProfile(undefined, () => + withMockServer( + () => + new Response( + 'InvalidAccessKeyIdThe AWS Access Key Id you provided does not exist in our records.req-iaki', + { + status: 403, + headers: { + "Content-Type": "application/xml", + "x-amz-request-id": "req-iaki", + }, + }, + ), + async (client) => { + let caught: unknown; + try { + await client.getObject("k"); + } catch (e) { + caught = e; + } + assert(caught instanceof S3OperationError); + const err = caught as S3OperationError; + assert( + err.message.startsWith("Datastore credentials rejected by AWS"), + `expected swamp hint to lead message, got: ${err.message}`, + ); + // .name preserved so existing `error.name === "InvalidAccessKeyId"` + // checks at call sites keep working. + assertEquals(err.name, "InvalidAccessKeyId"); + // Existing generic 401/403 hint should NOT also fire — the + // credential-specific hint replaces it. + assert( + !err.message.includes("(check AWS credentials"), + `generic 401/403 hint should be suppressed when credential-specific hint fires, got: ${err.message}`, + ); + }, + )), +}); + +Deno.test({ + sanitizeResources: false, + name: "AccessDenied 403 prepends credentials-rejected hint", + fn: () => + withAwsProfile(undefined, () => + withMockServer( + () => + new Response( + 'AccessDeniedaccess deniedr', + { + status: 403, + headers: { "Content-Type": "application/xml" }, + }, + ), + async (client) => { + let caught: unknown; + try { + await client.getObject("k"); + } catch (e) { + caught = e; + } + const err = caught as S3OperationError; + assert( + err.message.startsWith("Datastore credentials rejected by AWS"), + `got: ${err.message}`, + ); + assertEquals(err.name, "AccessDenied"); + }, + )), +}); + +Deno.test({ + sanitizeResources: false, + name: "SignatureDoesNotMatch 403 prepends credentials-rejected hint", + fn: () => + withAwsProfile(undefined, () => + withMockServer( + () => + new Response( + 'SignatureDoesNotMatchsig mismatchr', + { + status: 403, + headers: { "Content-Type": "application/xml" }, + }, + ), + async (client) => { + let caught: unknown; + try { + await client.getObject("k"); + } catch (e) { + caught = e; + } + const err = caught as S3OperationError; + assert(err.message.startsWith("Datastore credentials rejected")); + assertEquals(err.name, "SignatureDoesNotMatch"); + }, + )), +}); + +// Regression guard: a non-credential 403 (e.g. BucketRegionMismatch) still +// gets the existing generic auth hint, not the credential-specific hint. +Deno.test({ + sanitizeResources: false, + name: "BucketRegionMismatch 403 falls through to generic auth hint", + fn: () => + withAwsProfile(undefined, () => + withMockServer( + () => + new Response( + 'BucketRegionMismatchwrong regionr', + { + status: 403, + headers: { "Content-Type": "application/xml" }, + }, + ), + async (client) => { + let caught: unknown; + try { + await client.getObject("k"); + } catch (e) { + caught = e; + } + const err = caught as S3OperationError; + assert( + !err.message.startsWith("Datastore credentials rejected"), + `BucketRegionMismatch should not be classified as credentials-rejected, got: ${err.message}`, + ); + assert( + !err.message.startsWith("Datastore session expired"), + `BucketRegionMismatch should not be classified as session-expired, got: ${err.message}`, + ); + assert( + err.message.includes("(check AWS credentials"), + `generic auth hint should still fire for non-credential 403, got: ${err.message}`, + ); + }, + )), +}); + +// 5xx regression guard: no credential framing on server errors. +Deno.test({ + sanitizeResources: false, + name: "500 response does not add credential framing", + fn: () => + withAwsProfile(undefined, () => + withMockServer( + () => + new Response("internal error", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }), + async (client) => { + let caught: unknown; + try { + await client.getObject("k"); + } catch (e) { + caught = e; + } + const err = caught as S3OperationError; + assert(!err.message.startsWith("Datastore")); + }, + )), +}); + +// --- deriveAwsErrorCode unit tests (cause-chain handling) ---------------- + +Deno.test("deriveAwsErrorCode: prefers e.Code", () => { + assertEquals( + deriveAwsErrorCode({ + Code: "InvalidAccessKeyId", + name: "Error", + }), + "InvalidAccessKeyId", + ); +}); + +Deno.test("deriveAwsErrorCode: falls back to e.name when set", () => { + assertEquals( + deriveAwsErrorCode({ name: "CredentialsProviderError" }), + "CredentialsProviderError", + ); +}); + +Deno.test("deriveAwsErrorCode: ignores generic 'Error' name", () => { + assertEquals(deriveAwsErrorCode({ name: "Error" }), undefined); +}); + +// Issue #226 stack trace shows `_CredentialsProviderError` on the cause. +Deno.test("deriveAwsErrorCode: walks cause chain and strips leading underscore", () => { + const inner = new Error("Token is expired"); + inner.name = "_CredentialsProviderError"; + assertEquals( + deriveAwsErrorCode({ name: "Error", cause: inner }), + "CredentialsProviderError", + ); +}); + +Deno.test("deriveAwsErrorCode: strips multiple leading underscores", () => { + const inner = new Error("x"); + inner.name = "__CredentialsProviderError"; + assertEquals( + deriveAwsErrorCode({ name: "Error", cause: inner }), + "CredentialsProviderError", + ); +}); + +Deno.test("deriveAwsErrorCode: ignores non-Error cause", () => { + assertEquals( + deriveAwsErrorCode({ name: "Error", cause: { name: "Foo" } }), + undefined, + ); +}); + +Deno.test("deriveAwsErrorCode: returns undefined when no signal anywhere", () => { + assertEquals(deriveAwsErrorCode({}), undefined); + assertEquals(deriveAwsErrorCode({ name: "Error" }), undefined); +}); + +// Composition: cause-chain CredentialsProviderError flows through to the +// classifier via deriveAwsErrorCode → classifyAwsCredentialError → "session-expired". +Deno.test("composition: cause-chain _CredentialsProviderError classifies as session-expired", () => { + const inner = new Error("Token is expired"); + inner.name = "_CredentialsProviderError"; + const code = deriveAwsErrorCode({ name: "Error", cause: inner }); + assertEquals(code, "CredentialsProviderError"); + assertEquals( + classifyAwsCredentialError(code, undefined), + "session-expired", + ); +}); + +// --- Client-side CredentialsProviderError integration test ---------------- +// +// Issue #226's primary path: the SDK throws CredentialsProviderError before +// any HTTP call when the credential resolver fails. Reproduce by pointing +// AWS_PROFILE at a profile that doesn't exist, unsetting all env-var keys, +// and disabling IMDS so the chain exhausts quickly. The first SDK call +// then surfaces CredentialsProviderError, which wrapError must classify as +// session-expired and prepend the swamp-flavoured hint. + +Deno.test({ + sanitizeResources: false, + // Disable op sanitization too — the SDK's credential chain may leave + // pending IO ops on the failed IMDS lookup despite AWS_EC2_METADATA_DISABLED. + sanitizeOps: false, + name: + "Issue #226: client-side CredentialsProviderError surfaces session-expired hint", + fn: async () => { + const priorAccessKey = Deno.env.get("AWS_ACCESS_KEY_ID"); + const priorSecret = Deno.env.get("AWS_SECRET_ACCESS_KEY"); + const priorSession = Deno.env.get("AWS_SESSION_TOKEN"); + const priorProfile = Deno.env.get("AWS_PROFILE"); + const priorImds = Deno.env.get("AWS_EC2_METADATA_DISABLED"); + const priorConfig = Deno.env.get("AWS_CONFIG_FILE"); + const priorCreds = Deno.env.get("AWS_SHARED_CREDENTIALS_FILE"); + Deno.env.delete("AWS_ACCESS_KEY_ID"); + Deno.env.delete("AWS_SECRET_ACCESS_KEY"); + Deno.env.delete("AWS_SESSION_TOKEN"); + Deno.env.set("AWS_PROFILE", "swamp-issue-226-nonexistent-profile"); + Deno.env.set("AWS_EC2_METADATA_DISABLED", "true"); + // Point at empty files so the SDK doesn't accidentally pick up a real + // matching profile from the developer's home directory. + const emptyFile = await Deno.makeTempFile({ prefix: "swamp-empty-" }); + Deno.env.set("AWS_CONFIG_FILE", emptyFile); + Deno.env.set("AWS_SHARED_CREDENTIALS_FILE", emptyFile); + + try { + const client = new S3Client({ + bucket: "test-bucket", + region: "us-east-1", + }); + let caught: unknown; + try { + await client.headBucket(); + } catch (e) { + caught = e; + } + assert( + caught instanceof S3OperationError, + `expected S3OperationError, got: ${caught}`, + ); + const err = caught as S3OperationError; + assert( + err.message.startsWith("Datastore session expired"), + `expected session-expired hint to lead message, got: ${err.message}`, + ); + assert( + err.message.includes("aws sso login"), + `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}`, + ); + // .name preserved so existing `error.name === "CredentialsProviderError"` + // checks at call sites keep working. + assert( + err.name === "CredentialsProviderError" || + err.name.endsWith("CredentialsProviderError"), + `expected name to reflect underlying SDK error, got: ${err.name}`, + ); + } finally { + await Deno.remove(emptyFile).catch(() => {}); + if (priorAccessKey) Deno.env.set("AWS_ACCESS_KEY_ID", priorAccessKey); + if (priorSecret) Deno.env.set("AWS_SECRET_ACCESS_KEY", priorSecret); + if (priorSession) Deno.env.set("AWS_SESSION_TOKEN", priorSession); + if (priorProfile) Deno.env.set("AWS_PROFILE", priorProfile); + else Deno.env.delete("AWS_PROFILE"); + if (priorImds) Deno.env.set("AWS_EC2_METADATA_DISABLED", priorImds); + else Deno.env.delete("AWS_EC2_METADATA_DISABLED"); + if (priorConfig) Deno.env.set("AWS_CONFIG_FILE", priorConfig); + else Deno.env.delete("AWS_CONFIG_FILE"); + if (priorCreds) Deno.env.set("AWS_SHARED_CREDENTIALS_FILE", priorCreds); + else Deno.env.delete("AWS_SHARED_CREDENTIALS_FILE"); + } + }, +}); diff --git a/datastore/s3/manifest.yaml b/datastore/s3/manifest.yaml index 985d1ba8..0fe5a67d 100644 --- a/datastore/s3/manifest.yaml +++ b/datastore/s3/manifest.yaml @@ -1,6 +1,6 @@ manifestVersion: 1 name: "@swamp/s3-datastore" -version: "2026.05.04.1" +version: "2026.05.04.2" description: | Store data in an Amazon S3 bucket with local cache synchronization. Provides distributed locking via S3 conditional writes and bidirectional