Files
mailflare/tests/aws-credentials-panel.test.mjs
yi111 f90de963d6 Show the IAM policy when a save is refused for missing SES permissions (#98)
Saving credentials that validate against AWS but carry no SES permissions
answers 400 with "Attach the policy shown below", yet the panel rendered
nothing below it. Two causes: the 400 body carried the report but not the
policy, and requestJson discarded the parsed body on a rejected response,
so the report never reached the panel. The block was also gated on
`configured && !editing`, which is exactly the state a refused save leaves.

The 400 now carries the policy, requestJson throws an ApiError that keeps
the parsed body, and the panel keeps a refused report so the missing
permissions and the policy render under the message, expanded. A later
successful save, re-check, replace or remove clears it.

Refs #79
2026-10-10 07:50:28 +07:00

178 lines
7.5 KiB
JavaScript

import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import test, { after } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
import { build } from "esbuild";
import { JSDOM } from "jsdom";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const outDir = mkdtempSync(join(root, "tests", ".tmp-aws-credentials-panel-"));
const outfile = join(outDir, "panel.mjs");
after(() => rmSync(outDir, { recursive: true, force: true }));
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "https://mail.example/" });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLInputElement = dom.window.HTMLInputElement;
globalThis.Element = dom.window.Element;
globalThis.Node = dom.window.Node;
globalThis.Event = dom.window.Event;
globalThis.localStorage = dom.window.localStorage;
globalThis.sessionStorage = dom.window.sessionStorage;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
globalThis.testDom = dom;
await build({
stdin: {
contents: `
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { act } from "react";
import { LanguageProvider } from "./src/components/language-provider";
import Panel from "./src/app/(admin)/domains/AwsCredentialsPanel";
import { ApiError, requestJson } from "./src/app/(admin)/domains/api";
export { ApiError, requestJson };
const dom = globalThis.testDom;
export async function renderPanel() {
// Each test gets its own mount, so panel state never leaks between them.
document.body.replaceChildren();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(createElement(LanguageProvider, { initialLocale: "en" }, createElement(Panel, { need: "sending" })));
});
return async function fillAndSave() {
const inputs = container.querySelectorAll("input");
const setValue = (input, value) => {
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value").set;
setter.call(input, value);
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
};
await act(async () => {
setValue(inputs[0], "AKIAIOSFODNN7EXAMPLE");
setValue(inputs[1], "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
setValue(inputs[2], "us-east-1");
});
await act(async () => {
container.querySelector("form").dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true }));
});
};
}
`,
resolveDir: root,
loader: "tsx",
},
outfile,
bundle: true,
platform: "browser",
format: "esm",
target: "es2022",
tsconfig: join(root, "tsconfig.json"),
external: ["react", "react-dom", "react-dom/client", "jsdom"],
logLevel: "silent",
alias: { "@": join(root, "src") },
banner: { js: 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url);' },
loader: { ".js": "jsx" },
plugins: [{
name: "runtime-stubs",
setup(builder) {
builder.onLoad({ filter: /\.css$/ }, () => ({ contents: "", loader: "js" }));
},
}],
});
const { renderPanel, requestJson, ApiError } = await import(pathToFileURL(outfile).href);
const en = JSON.parse(readFileSync(join(root, "src/lib/i18n/en.json"), "utf8"));
const AWS_IAM_POLICY_SOURCE = readFileSync(join(root, "src/lib/aws/validate.ts"), "utf8");
/** Answers the panel's calls from a queue of canned responses, recording what it was asked. */
function stubApi(responses) {
const calls = [];
globalThis.fetch = async (url, init) => {
calls.push({ url: String(url), method: init?.method ?? "GET" });
const next = responses.shift();
if (!next) throw new Error(`unexpected ${init?.method ?? "GET"} ${url}`);
return Response.json(next.body, { status: next.status ?? 200 });
};
return calls;
}
const NOT_CONFIGURED = {
status: { configured: false, source: null, region: null, accessKeyHint: null, accountId: null },
policy: { Version: "2012-10-17", Statement: [{ Effect: "Allow", Action: ["ses:GetAccount"], Resource: "*" }] },
};
const REFUSAL = {
error: "These credentials work but have no SES permissions. Attach the policy shown below.",
report: {
accountId: "123456789012",
arn: "arn:aws:iam::123456789012:user/mailflare",
region: "us-east-1",
sending: false,
productionAccess: null,
receivingRegion: true,
receiving: false,
sns: false,
s3: false,
missing: ["ses:GetAccount", "ses:SendEmail", "ses:DescribeActiveReceiptRuleSet"],
},
policy: { Version: "2012-10-17", Statement: [{ Effect: "Allow", Action: ["ses:GetAccount", "ses:SendEmail"], Resource: "*" }] },
};
test("a save the server refuses shows the missing permissions and the IAM policy", async () => {
stubApi([{ body: NOT_CONFIGURED }, { body: REFUSAL, status: 400 }]);
const save = await renderPanel();
await save();
const details = document.querySelector("body details");
assert.ok(details, "the missing-permission block is rendered");
assert.equal(details.open, true, "the block is expanded so the policy is readable without a click");
assert.match(details.textContent, /ses:GetAccount, ses:SendEmail, ses:DescribeActiveReceiptRuleSet/);
assert.match(details.querySelector("pre").textContent, /"ses:SendEmail"/);
assert.match(document.querySelector("body [role=alert]").textContent, /no SES permissions/);
});
test("a refusal body that carries no report leaves the panel as it was", async () => {
stubApi([{ body: NOT_CONFIGURED }, { body: { error: "AWS rejected these credentials. Check the access key ID and secret." }, status: 400 }]);
const save = await renderPanel();
await save();
assert.equal(document.querySelector("body details"), null);
assert.match(document.querySelector("body [role=alert]").textContent, /AWS rejected these credentials/);
});
test("a successful save keeps the credentials row and drops the refusal block", async () => {
const configured = { configured: true, source: "settings", region: "us-east-1", accessKeyHint: "…MPLE", accountId: "123456789012" };
stubApi([
{ body: NOT_CONFIGURED },
{ body: REFUSAL, status: 400 },
{ body: { status: configured, report: { ...REFUSAL.report, sending: true, productionAccess: true, missing: [] } } },
]);
const save = await renderPanel();
await save();
assert.ok(document.querySelector("body details"));
await save();
assert.equal(document.querySelector("body details"), null, "the refused report is cleared once the credentials save");
assert.equal(document.querySelector("body [role=alert]"), null);
});
test("the admin API answers a refused save with the policy it tells the admin to attach", async () => {
const route = readFileSync(join(root, "src/app/api/admin/aws/route.ts"), "utf8");
const refusal = route.slice(route.indexOf("!report.sending && !report.receiving"));
assert.match(refusal, /policy: AWS_IAM_POLICY/, "the 400 body carries the policy");
assert.match(AWS_IAM_POLICY_SOURCE, /export const AWS_IAM_POLICY = \{/, "the policy the route sends is the one validate.ts defines");
});
test("requestJson keeps the parsed body on a rejected response", async () => {
stubApi([{ body: { error: "nope", report: REFUSAL.report }, status: 400 }]);
const error = await requestJson("/api/admin/aws", "PUT", {}).then(() => null, (err) => err);
assert.ok(error instanceof ApiError);
assert.equal(error.message, "nope");
assert.deepEqual(error.data.report, REFUSAL.report);
assert.equal(typeof en["aws.missingSummary"], "string");
});