mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Redact AWS remote import provider errors
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3caf4e604a
commit
ea62aa30e2
8 files changed
+437
-23
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createAwsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
|
||||
import { SecretProviderClientError } from "../secrets/types.js";
|
||||
|
||||
describe("awsSecretsManagerProvider", () => {
|
||||
const previousEnv = {
|
||||
@@ -398,6 +399,57 @@ describe("awsSecretsManagerProvider", () => {
|
||||
expect(JSON.stringify(listed)).not.toContain("team");
|
||||
});
|
||||
|
||||
it("redacts AWS provider exception text when remote listing fails", async () => {
|
||||
const rawProviderMessage =
|
||||
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod-use1",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
gateway: {
|
||||
async createSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async putSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async getSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async deleteSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async listSecrets() {
|
||||
throw new Error(rawProviderMessage);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await provider.listRemoteSecrets?.({});
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(SecretProviderClientError);
|
||||
expect(thrown).toMatchObject({
|
||||
code: "access_denied",
|
||||
status: 403,
|
||||
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||
rawMessage: rawProviderMessage,
|
||||
});
|
||||
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
|
||||
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("123456789012");
|
||||
});
|
||||
|
||||
it("resolves AWS secret values by provider version reference", async () => {
|
||||
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
|
||||
@@ -3,7 +3,7 @@ import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { secretRoutes } from "../routes/secrets.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import { HttpError, unprocessable } from "../errors.js";
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
listProviders: vi.fn(),
|
||||
@@ -301,6 +301,31 @@ describe("secret routes", () => {
|
||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
|
||||
});
|
||||
|
||||
it("returns sanitized remote import preview provider errors", async () => {
|
||||
mockSecretService.previewRemoteImport.mockRejectedValue(
|
||||
new HttpError(
|
||||
403,
|
||||
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||
{ code: "access_denied" },
|
||||
),
|
||||
);
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/secrets/remote-import/preview")
|
||||
.send({
|
||||
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({
|
||||
error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||
details: { code: "access_denied" },
|
||||
});
|
||||
expect(JSON.stringify(res.body)).not.toContain("arn:aws");
|
||||
expect(JSON.stringify(res.body)).not.toContain("123456789012");
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports remote references and logs aggregate row counts", async () => {
|
||||
mockSecretService.importRemoteSecrets.mockResolvedValue({
|
||||
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
|
||||
import { SecretProviderClientError } from "../secrets/types.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
@@ -448,6 +449,44 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
expect(preview.candidates[2]?.providerMetadata).toBeNull();
|
||||
});
|
||||
|
||||
it("sanitizes AWS remote import preview provider errors before crossing the service boundary", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const rawProviderMessage =
|
||||
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets";
|
||||
|
||||
vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockRejectedValueOnce(
|
||||
new SecretProviderClientError({
|
||||
code: "access_denied",
|
||||
provider: "aws_secrets_manager",
|
||||
operation: "listSecrets",
|
||||
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||
rawMessage: rawProviderMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await svc.previewRemoteImport(companyId, { providerConfigId: awsVault.id });
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toMatchObject({
|
||||
status: 403,
|
||||
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||
details: { code: "access_denied" },
|
||||
});
|
||||
expect(JSON.stringify(thrown)).not.toContain("arn:aws");
|
||||
expect(JSON.stringify(thrown)).not.toContain("123456789012");
|
||||
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
|
||||
});
|
||||
|
||||
it("imports AWS remote references row-by-row without fetching plaintext", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
@@ -519,6 +558,53 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
expect(JSON.stringify(versions[0])).not.toContain("sk-");
|
||||
});
|
||||
|
||||
it("sanitizes AWS remote import row provider errors", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const rawProviderMessage =
|
||||
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:DescribeSecret on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
|
||||
vi.spyOn(awsSecretsManagerProvider, "linkExternalSecret").mockRejectedValueOnce(
|
||||
new SecretProviderClientError({
|
||||
code: "access_denied",
|
||||
provider: "aws_secrets_manager",
|
||||
operation: "linkExternalSecret",
|
||||
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||
rawMessage: rawProviderMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await svc.importRemoteSecrets(companyId, {
|
||||
providerConfigId: awsVault.id,
|
||||
secrets: [
|
||||
{
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
name: "OpenAI API key",
|
||||
key: "openai-api-key",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
importedCount: 0,
|
||||
skippedCount: 0,
|
||||
errorCount: 1,
|
||||
results: [
|
||||
expect.objectContaining({
|
||||
status: "error",
|
||||
reason: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain(rawProviderMessage);
|
||||
expect(JSON.stringify(result.results[0]?.reason)).not.toContain("arn:aws");
|
||||
expect(JSON.stringify(result.results[0]?.reason)).not.toContain("123456789012");
|
||||
});
|
||||
|
||||
it("rejects Paperclip-managed AWS namespace refs during preview and import commit", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
||||
@@ -4,10 +4,11 @@ import { Hash } from "@smithy/hash-node";
|
||||
import { HttpRequest } from "@smithy/protocol-http";
|
||||
import { SignatureV4 } from "@smithy/signature-v4";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import type {
|
||||
PreparedSecretVersion,
|
||||
RemoteSecretListResult,
|
||||
SecretProviderClientErrorCode,
|
||||
SecretProviderHealthCheck,
|
||||
SecretProviderModule,
|
||||
SecretProviderValidationResult,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
SecretProviderWriteContext,
|
||||
StoredSecretVersionMaterial,
|
||||
} from "./types.js";
|
||||
import { SecretProviderClientError } from "./types.js";
|
||||
|
||||
const AWS_SECRETS_MANAGER_SCHEME = "aws_secrets_manager_v1";
|
||||
const DEFAULT_PREFIX = "paperclip";
|
||||
@@ -464,15 +466,49 @@ function asAwsSecretsManagerMaterial(value: StoredSecretVersionMaterial): AwsSec
|
||||
throw unprocessable("Invalid AWS Secrets Manager material");
|
||||
}
|
||||
|
||||
function classifyAwsProviderError(message: string): SecretProviderClientErrorCode {
|
||||
if (/ResourceExistsException|AlreadyExists/i.test(message)) return "conflict";
|
||||
if (/ResourceNotFoundException|NotFound/i.test(message)) return "not_found";
|
||||
if (/AccessDeniedException|AccessDenied|UnrecognizedClientException|InvalidClientTokenId|not authorized/i.test(message)) {
|
||||
return "access_denied";
|
||||
}
|
||||
if (/Throttl|TooManyRequests|RequestLimitExceeded|Rate exceeded/i.test(message)) return "throttled";
|
||||
if (/ValidationException|InvalidParameter|InvalidRequest/i.test(message)) return "invalid_request";
|
||||
if (/fetch failed|ECONN|ENOTFOUND|ETIMEDOUT|network|timeout/i.test(message)) return "provider_unavailable";
|
||||
return "provider_error";
|
||||
}
|
||||
|
||||
function awsProviderSafeMessage(code: SecretProviderClientErrorCode): string {
|
||||
switch (code) {
|
||||
case "access_denied":
|
||||
return "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.";
|
||||
case "throttled":
|
||||
return "AWS Secrets Manager throttled the request. Wait and try again.";
|
||||
case "not_found":
|
||||
return "AWS Secrets Manager could not find the requested secret.";
|
||||
case "conflict":
|
||||
return "AWS Secrets Manager reported that the requested secret already exists.";
|
||||
case "invalid_request":
|
||||
return "AWS Secrets Manager rejected the request.";
|
||||
case "provider_unavailable":
|
||||
return "AWS Secrets Manager is unavailable right now.";
|
||||
case "provider_error":
|
||||
default:
|
||||
return "AWS Secrets Manager request failed.";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAwsError(operation: string, error: unknown): never {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/ResourceExistsException/i.test(message)) {
|
||||
throw conflict(`AWS Secrets Manager ${operation} conflict: ${message}`);
|
||||
}
|
||||
if (/ResourceNotFoundException/i.test(message)) {
|
||||
throw notFound(`AWS Secrets Manager ${operation} failed: ${message}`);
|
||||
}
|
||||
throw unprocessable(`AWS Secrets Manager ${operation} failed: ${message}`);
|
||||
const rawMessage = error instanceof Error ? error.message : String(error);
|
||||
const code = classifyAwsProviderError(rawMessage);
|
||||
throw new SecretProviderClientError({
|
||||
code,
|
||||
provider: "aws_secrets_manager",
|
||||
operation,
|
||||
message: awsProviderSafeMessage(code),
|
||||
rawMessage,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
class AwsSecretsManagerJsonGateway implements AwsSecretsManagerGateway {
|
||||
|
||||
@@ -41,6 +41,64 @@ export interface RemoteSecretListResult {
|
||||
nextToken?: string | null;
|
||||
}
|
||||
|
||||
export type SecretProviderClientErrorCode =
|
||||
| "access_denied"
|
||||
| "throttled"
|
||||
| "not_found"
|
||||
| "conflict"
|
||||
| "invalid_request"
|
||||
| "provider_unavailable"
|
||||
| "provider_error";
|
||||
|
||||
export interface SecretProviderClientErrorOptions {
|
||||
code: SecretProviderClientErrorCode;
|
||||
provider: SecretProvider;
|
||||
operation: string;
|
||||
message: string;
|
||||
status?: number;
|
||||
rawMessage?: string | null;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
const SECRET_PROVIDER_CLIENT_ERROR_STATUS: Record<SecretProviderClientErrorCode, number> = {
|
||||
access_denied: 403,
|
||||
throttled: 429,
|
||||
not_found: 404,
|
||||
conflict: 409,
|
||||
invalid_request: 422,
|
||||
provider_unavailable: 503,
|
||||
provider_error: 502,
|
||||
};
|
||||
|
||||
export class SecretProviderClientError extends Error {
|
||||
readonly code: SecretProviderClientErrorCode;
|
||||
readonly provider: SecretProvider;
|
||||
readonly operation: string;
|
||||
readonly status: number;
|
||||
readonly rawMessage: string | null;
|
||||
|
||||
constructor(options: SecretProviderClientErrorOptions) {
|
||||
super(options.message);
|
||||
this.name = "SecretProviderClientError";
|
||||
this.code = options.code;
|
||||
this.provider = options.provider;
|
||||
this.operation = options.operation;
|
||||
this.status = options.status ?? SECRET_PROVIDER_CLIENT_ERROR_STATUS[options.code];
|
||||
this.rawMessage = options.rawMessage ?? null;
|
||||
if (options.cause !== undefined) {
|
||||
Object.defineProperty(this, "cause", {
|
||||
value: options.cause,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isSecretProviderClientError(error: unknown): error is SecretProviderClientError {
|
||||
return error instanceof SecretProviderClientError;
|
||||
}
|
||||
|
||||
export interface SecretProviderRuntimeContext {
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
|
||||
+108
-10
@@ -36,7 +36,8 @@ import {
|
||||
secretProviderConfigPayloadSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import {
|
||||
checkSecretProviders,
|
||||
getSecretProvider,
|
||||
@@ -44,9 +45,11 @@ import {
|
||||
} from "../secrets/provider-registry.js";
|
||||
import type {
|
||||
PreparedSecretVersion,
|
||||
RemoteSecretListResult,
|
||||
SecretProviderHealthCheck,
|
||||
SecretProviderVaultRuntimeConfig,
|
||||
} from "../secrets/types.js";
|
||||
import { isSecretProviderClientError } from "../secrets/types.js";
|
||||
|
||||
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
@@ -57,6 +60,76 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet<SecretProvider> = new Set([
|
||||
"vault",
|
||||
]);
|
||||
|
||||
function remoteProviderHttpError(error: unknown, context: {
|
||||
companyId: string;
|
||||
provider: SecretProvider;
|
||||
providerConfigId: string;
|
||||
operation: string;
|
||||
}): HttpError {
|
||||
if (isSecretProviderClientError(error)) {
|
||||
logger.warn(
|
||||
{
|
||||
err: error,
|
||||
companyId: context.companyId,
|
||||
provider: context.provider,
|
||||
providerConfigId: context.providerConfigId,
|
||||
operation: context.operation,
|
||||
providerErrorCode: error.code,
|
||||
},
|
||||
"remote secret provider request failed",
|
||||
);
|
||||
return new HttpError(error.status, error.message, { code: error.code });
|
||||
}
|
||||
if (error instanceof HttpError) return error;
|
||||
logger.warn(
|
||||
{
|
||||
err: error,
|
||||
companyId: context.companyId,
|
||||
provider: context.provider,
|
||||
providerConfigId: context.providerConfigId,
|
||||
operation: context.operation,
|
||||
providerErrorCode: "provider_error",
|
||||
},
|
||||
"remote secret provider request failed",
|
||||
);
|
||||
return new HttpError(502, "Remote secret provider request failed.", { code: "provider_error" });
|
||||
}
|
||||
|
||||
function remoteImportRowFailureReason(error: unknown, fallback: string, context: {
|
||||
companyId: string;
|
||||
provider: SecretProvider;
|
||||
providerConfigId: string;
|
||||
operation: string;
|
||||
}): string {
|
||||
if (isSecretProviderClientError(error)) {
|
||||
logger.warn(
|
||||
{
|
||||
err: error,
|
||||
companyId: context.companyId,
|
||||
provider: context.provider,
|
||||
providerConfigId: context.providerConfigId,
|
||||
operation: context.operation,
|
||||
providerErrorCode: error.code,
|
||||
},
|
||||
"remote secret import row provider failure",
|
||||
);
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof HttpError && error.status < 500) return error.message;
|
||||
logger.warn(
|
||||
{
|
||||
err: error,
|
||||
companyId: context.companyId,
|
||||
provider: context.provider,
|
||||
providerConfigId: context.providerConfigId,
|
||||
operation: context.operation,
|
||||
providerErrorCode: "provider_error",
|
||||
},
|
||||
"remote secret import row failed",
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
type CanonicalEnvBinding =
|
||||
| { type: "plain"; value: string }
|
||||
| { type: "secret_ref"; secretId: string; version: number | "latest" };
|
||||
@@ -1038,12 +1111,22 @@ export function secretService(db: Db) {
|
||||
if (!provider.listRemoteSecrets) {
|
||||
throw unprocessable(`${providerId} provider does not support remote import listing`);
|
||||
}
|
||||
const listed = await provider.listRemoteSecrets({
|
||||
providerConfig: runtimeConfig,
|
||||
query: input.query,
|
||||
nextToken: input.nextToken,
|
||||
pageSize: input.pageSize,
|
||||
});
|
||||
let listed: RemoteSecretListResult;
|
||||
try {
|
||||
listed = await provider.listRemoteSecrets({
|
||||
providerConfig: runtimeConfig,
|
||||
query: input.query,
|
||||
nextToken: input.nextToken,
|
||||
pageSize: input.pageSize,
|
||||
});
|
||||
} catch (error) {
|
||||
throw remoteProviderHttpError(error, {
|
||||
companyId,
|
||||
provider: providerId,
|
||||
providerConfigId: providerConfig.id,
|
||||
operation: "remote_import.preview",
|
||||
});
|
||||
}
|
||||
const maps = await buildRemoteImportConflictMaps(companyId, providerId);
|
||||
const candidates: RemoteSecretImportCandidate[] = [];
|
||||
for (const remote of listed.secrets) {
|
||||
@@ -1069,7 +1152,12 @@ export function secretService(db: Db) {
|
||||
} catch (error) {
|
||||
conflicts.push({
|
||||
type: "provider_guardrail",
|
||||
message: error instanceof Error ? error.message : "Provider rejected this external reference",
|
||||
message: remoteImportRowFailureReason(error, "Provider rejected this external reference", {
|
||||
companyId,
|
||||
provider: providerId,
|
||||
providerConfigId: providerConfig.id,
|
||||
operation: "remote_import.preview.link_external_reference",
|
||||
}),
|
||||
});
|
||||
}
|
||||
conflicts.push(...remoteImportConflictsFor({
|
||||
@@ -1179,7 +1267,12 @@ export function secretService(db: Db) {
|
||||
name,
|
||||
key,
|
||||
status: "error",
|
||||
reason: error instanceof Error ? error.message : "Provider rejected this external reference",
|
||||
reason: remoteImportRowFailureReason(error, "Provider rejected this external reference", {
|
||||
companyId,
|
||||
provider: providerId,
|
||||
providerConfigId: providerConfig.id,
|
||||
operation: "remote_import.prepare_external_reference",
|
||||
}),
|
||||
secretId: null,
|
||||
conflicts: [],
|
||||
});
|
||||
@@ -1274,7 +1367,12 @@ export function secretService(db: Db) {
|
||||
name,
|
||||
key,
|
||||
status: "error",
|
||||
reason: error instanceof Error ? error.message : "Import failed",
|
||||
reason: remoteImportRowFailureReason(error, "Import failed", {
|
||||
companyId,
|
||||
provider: providerId,
|
||||
providerConfigId: providerConfig.id,
|
||||
operation: "remote_import.commit",
|
||||
}),
|
||||
secretId: null,
|
||||
conflicts: [],
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
RemoteSecretImportResult,
|
||||
} from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "../../api/client";
|
||||
|
||||
const mockSecretsApi = vi.hoisted(() => ({
|
||||
remoteImportPreview: vi.fn(),
|
||||
@@ -412,7 +413,7 @@ describe("ImportFromVaultDialog", () => {
|
||||
name: "gamma",
|
||||
key: "gamma",
|
||||
status: "error",
|
||||
reason: "AccessDeniedException for arn:aws:…/c",
|
||||
reason: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||
secretId: null,
|
||||
conflicts: [],
|
||||
},
|
||||
@@ -473,7 +474,9 @@ describe("ImportFromVaultDialog", () => {
|
||||
expect(document.body.textContent).toContain("1 created");
|
||||
expect(document.body.textContent).toContain("1 skipped");
|
||||
expect(document.body.textContent).toContain("1 failed");
|
||||
expect(document.body.textContent).toContain("AccessDeniedException");
|
||||
expect(document.body.textContent).toContain("AWS Secrets Manager denied the request");
|
||||
expect(document.body.textContent).not.toContain("AccessDeniedException");
|
||||
expect(document.body.textContent).not.toContain("123456789012");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
@@ -542,6 +545,48 @@ describe("ImportFromVaultDialog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders sanitized preview provider errors without raw AWS exception text", async () => {
|
||||
const rawProviderMessage =
|
||||
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized";
|
||||
mockSecretsApi.remoteImportPreview.mockRejectedValueOnce(
|
||||
new ApiError(
|
||||
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||
403,
|
||||
{ error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", details: { code: "access_denied" } },
|
||||
),
|
||||
);
|
||||
|
||||
const { queryClient } = makeWrapper();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ImportFromVaultDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
companyId="company-1"
|
||||
providerConfigs={[awsVault]}
|
||||
existingSecrets={[]}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const banner = document.querySelector('[data-testid="preview-error-banner"]');
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner?.textContent).toContain("AWS denied list access");
|
||||
expect(banner?.textContent).toContain("missing secretsmanager:ListSecrets");
|
||||
expect(banner?.textContent).not.toContain(rawProviderMessage);
|
||||
expect(banner?.textContent).not.toContain("arn:aws");
|
||||
expect(banner?.textContent).not.toContain("123456789012");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("debounces search and uses the new query for the next preview", async () => {
|
||||
mockSecretsApi.remoteImportPreview
|
||||
.mockResolvedValueOnce(makePreview([makeCandidate()]))
|
||||
|
||||
@@ -201,8 +201,22 @@ function readableErrorMessage(error: unknown): string {
|
||||
return "Unexpected error";
|
||||
}
|
||||
|
||||
function apiErrorCode(error: ApiError): string | null {
|
||||
const body = error.body;
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const record = body as Record<string, unknown>;
|
||||
if (typeof record.code === "string") return record.code;
|
||||
const details = record.details;
|
||||
if (details && typeof details === "object") {
|
||||
const code = (details as Record<string, unknown>).code;
|
||||
if (typeof code === "string") return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPermissionError(error: unknown): boolean {
|
||||
if (!(error instanceof ApiError)) return false;
|
||||
if (apiErrorCode(error) === "access_denied") return true;
|
||||
if (error.status === 401 || error.status === 403) return true;
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
@@ -214,6 +228,7 @@ function isPermissionError(error: unknown): boolean {
|
||||
|
||||
function isThrottlingError(error: unknown): boolean {
|
||||
if (!(error instanceof ApiError)) return false;
|
||||
if (apiErrorCode(error) === "throttled") return true;
|
||||
const message = error.message.toLowerCase();
|
||||
return message.includes("throttl") || message.includes("toomanyrequests");
|
||||
}
|
||||
@@ -1458,4 +1473,3 @@ function FooterStatus({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user