fix: reject credential idempotency headers
This commit is contained in:
@@ -468,6 +468,16 @@ export function createContractHttpExecutor(
|
|||||||
// A missing credential never downgrades into an anonymous request.
|
// A missing credential never downgrades into an anonymous request.
|
||||||
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
|
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
Object.keys(patch.headers).some(
|
||||||
|
(name) => name.toLowerCase() === "idempotency-key",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return finish(
|
||||||
|
violation("UNEXPECTED_IDEMPOTENCY_KEY", "REQUEST", "NOT_STARTED"),
|
||||||
|
"NOT_STARTED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
|
|||||||
@@ -144,6 +144,52 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
label: "query with the canonical reserved header",
|
||||||
|
operation: installed,
|
||||||
|
input: { limit: 20 },
|
||||||
|
context: { scope },
|
||||||
|
headerName: "Idempotency-Key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "valid KEYED command with a case-variant reserved header",
|
||||||
|
operation: createInstalled,
|
||||||
|
input: { name: "created" },
|
||||||
|
context: { scope, intent: mutationIntent() },
|
||||||
|
headerName: "iDeMpOtEnCy-KeY",
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"rejects a credential patch for $label before fetch",
|
||||||
|
async ({ operation, input, context, headerName }) => {
|
||||||
|
const attachCredentials = vi.fn(() => ({
|
||||||
|
kind: "READY" as const,
|
||||||
|
headers: { [headerName]: "credential-owned-key" },
|
||||||
|
credentials: "omit" as const,
|
||||||
|
}));
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
const executor = createContractHttpExecutor({
|
||||||
|
baseUrl: "https://api.example/",
|
||||||
|
maxRetryAttempts: 0,
|
||||||
|
attachCredentials,
|
||||||
|
fetcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executor.execute(operation, input, context),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
kind: "CONTRACT_VIOLATION",
|
||||||
|
violation: {
|
||||||
|
kind: "UNEXPECTED_IDEMPOTENCY_KEY",
|
||||||
|
operation: "REQUEST",
|
||||||
|
},
|
||||||
|
effect: "NOT_STARTED",
|
||||||
|
});
|
||||||
|
expect(attachCredentials).toHaveBeenCalledOnce();
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("reuses one supplied idempotency key across every physical retry", async () => {
|
it("reuses one supplied idempotency key across every physical retry", async () => {
|
||||||
const observedKeys: Array<string | null> = [];
|
const observedKeys: Array<string | null> = [];
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user