Skip to content

Commit

Permalink
chore(arcjet): Increase test coverage to 100% (#2157)
Browse files Browse the repository at this point in the history
This increases the code coverage of our `arcjet` core package to 100%. I added some tests and did some code-path cleanup to reach 100%.

Closes #1802
  • Loading branch information
blaine-arcjet authored Nov 5, 2024
1 parent 56e5319 commit 17f8a9a
Show file tree
Hide file tree
Showing 2 changed files with 184 additions and 27 deletions.
29 changes: 10 additions & 19 deletions arcjet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,18 +326,17 @@ function createTypeValidator(
};
}

function createValueValidator(...values: string[]): Validator {
function createValueValidator(
// This uses types to ensure we have at least 2 values
...values: [string, string, ...string[]]
): Validator {
return (key, value) => {
// We cast the values to unknown because the optionValue isn't known but
// we only want to use `values` on string enumerations
if (!(values as unknown[]).includes(value)) {
if (values.length === 1) {
throw new Error(`invalid value for \`${key}\` - expected ${values[0]}`);
} else {
throw new Error(
`invalid value for \`${key}\` - expected one of ${values.map((value) => `'${value}'`).join(", ")}`,
);
}
throw new Error(
`invalid value for \`${key}\` - expected one of ${values.map((value) => `'${value}'`).join(", ")}`,
);
}
};
}
Expand Down Expand Up @@ -375,11 +374,7 @@ function createValidator({
try {
validate(key, value);
} catch (err) {
if (err instanceof Error) {
throw new Error(`\`${rule}\` options error: ${err.message}`);
} else {
throw new Error(`\`${rule}\` options error: unknown failure`);
}
throw new Error(`\`${rule}\` options error: ${errorMessage(err)}`);
}
}
}
Expand Down Expand Up @@ -1309,19 +1304,15 @@ export default function arcjet<
);
log.debug("fingerprint (%s): %s", rt, fingerprint);
} catch (error) {
const errMsg = errorMessage(error);
log.error(
{
// Workaround for inability to JSON.stringify Error objects
error: errMsg,
},
{ error },
"Failed to build fingerprint. Please verify your Characteristics.",
);

const decision = new ArcjetErrorDecision({
ttl: 0,
reason: new ArcjetErrorReason(
`Failed to build fingerprint - ${errMsg}`,
`Failed to build fingerprint - ${errorMessage(error)}`,
),
// No results because we couldn't create a fingerprint
results: [],
Expand Down
182 changes: 174 additions & 8 deletions arcjet/test/index.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1679,6 +1679,27 @@ describe("Primitive > sensitiveInfo", () => {
);
});

test("does not throw via `validate()`", () => {
const context = {
key: "test-key",
fingerprint: "test-fingerprint",
runtime: "test",
log,
characteristics: [],
getBody: () => Promise.resolve(undefined),
};
const details = {
email: undefined,
};

const [rule] = sensitiveInfo({ mode: "LIVE", allow: [] });
expect(rule.type).toEqual("SENSITIVE_INFO");
assertIsLocalRule(rule);
expect(() => {
const _ = rule.validate(context, details);
}).not.toThrow();
});

test("allows specifying sensitive info entities to allow", async () => {
const [rule] = sensitiveInfo({
allow: ["EMAIL", "CREDIT_CARD_NUMBER"],
Expand Down Expand Up @@ -1905,7 +1926,8 @@ describe("Primitive > sensitiveInfo", () => {
runtime: "test",
log,
characteristics: [],
getBody: () => Promise.resolve("[email protected] +353 87 123 4567"),
getBody: () =>
Promise.resolve("127.0.0.1 [email protected] +353 87 123 4567"),
};
const details = {
ip: "172.100.1.1",
Expand All @@ -1921,25 +1943,31 @@ describe("Primitive > sensitiveInfo", () => {

const [rule] = sensitiveInfo({
mode: "LIVE",
deny: ["CREDIT_CARD_NUMBER"],
deny: ["CREDIT_CARD_NUMBER", "IP_ADDRESS"],
});
expect(rule.type).toEqual("SENSITIVE_INFO");
assertIsLocalRule(rule);
const result = await rule.protect(context, details);
expect(result).toMatchObject({
state: "RUN",
conclusion: "ALLOW",
conclusion: "DENY",
reason: new ArcjetSensitiveInfoReason({
denied: [],
allowed: [
denied: [
{
start: 0,
end: 16,
end: 9,
identifiedType: "IP_ADDRESS",
},
],
allowed: [
{
start: 10,
end: 26,
identifiedType: "EMAIL",
},
{
start: 17,
end: 33,
start: 27,
end: 43,
identifiedType: "PHONE_NUMBER",
},
],
Expand Down Expand Up @@ -2051,6 +2079,49 @@ describe("Primitive > sensitiveInfo", () => {
});
});

test("it throws when custom function returns non-string", async () => {
const context = {
key: "test-key",
fingerprint: "test-fingerprint",
runtime: "test",
log,
characteristics: [],
getBody: () => Promise.resolve("this is bad"),
};
const details = {
ip: "172.100.1.1",
method: "GET",
protocol: "http",
host: "example.com",
path: "/",
headers: new Headers(),
cookies: "",
query: "",
extra: {},
};

const customDetect = (tokens: string[]) => {
return tokens.map((token) => {
if (token === "bad") {
return 12345;
}
});
};

const [rule] = sensitiveInfo({
mode: "LIVE",
allow: [],
contextWindowSize: 1,
// @ts-expect-error
detect: customDetect,
});
expect(rule.type).toEqual("SENSITIVE_INFO");
assertIsLocalRule(rule);
expect(async () => {
const _ = await rule.protect(context, details);
}).rejects.toEqual(new Error("invalid entity type"));
});

test("it allows custom entities identified by a function that would have otherwise been blocked", async () => {
const context = {
key: "test-key",
Expand Down Expand Up @@ -2141,6 +2212,40 @@ describe("Primitive > sensitiveInfo", () => {
assertIsLocalRule(rule);
await rule.protect(context, details);
});

test("it returns an error decision when body is not available", async () => {
const context = {
key: "test-key",
fingerprint: "test-fingerprint",
runtime: "test",
log,
characteristics: [],
getBody: () => Promise.resolve(undefined),
};
const details = {
ip: "172.100.1.1",
method: "GET",
protocol: "http",
host: "example.com",
path: "/",
headers: new Headers(),
cookies: "",
query: "",
extra: {},
};

const [rule] = sensitiveInfo({
mode: "LIVE",
allow: [],
contextWindowSize: 1,
});
expect(rule.type).toEqual("SENSITIVE_INFO");
assertIsLocalRule(rule);
const decision = await rule.protect(context, details);
expect(decision.ttl).toEqual(0);
expect(decision.state).toEqual("NOT_RUN");
expect(decision.conclusion).toEqual("ERROR");
});
});

describe("Products > protectSignup", () => {
Expand Down Expand Up @@ -3086,6 +3191,67 @@ describe("SDK", () => {
);
});

test("provides `waitUntil` in context to `client.report()` if available", async () => {
const client = {
decide: jest.fn(async () => {
return new ArcjetErrorDecision({
ttl: 0,
reason: new ArcjetErrorReason("This decision not under test"),
results: [],
});
}),
report: jest.fn(),
};

const waitUntil = jest.fn();

const SYMBOL_FOR_REQ_CONTEXT = Symbol.for("@vercel/request-context");
// @ts-ignore
globalThis[SYMBOL_FOR_REQ_CONTEXT] = {
get() {
return { waitUntil };
},
};

const key = "test-key";
const context = {
key,
fingerprint:
"fp::2::516289fae7993d35ffb6e76883e09b475bbc7a622a378f3b430f35e8c657687e",
getBody: () => Promise.resolve(undefined),
};
const request = {
ip: "172.100.1.1",
method: "GET",
protocol: "http",
host: "example.com",
path: "/",
headers: new Headers([["User-Agent", "curl/8.1.2"]]),
"extra-test": "extra-test-value",
};
const rule = testRuleLocalDenied();

const aj = arcjet({
key,
rules: [[rule]],
client,
log,
});

const _ = await aj.protect(context, request);
expect(client.report).toHaveBeenCalledTimes(1);
expect(client.report).toHaveBeenCalledWith(
expect.objectContaining({
waitUntil,
}),
expect.anything(),
expect.anything(),
[rule],
);
// @ts-ignore
delete globalThis[SYMBOL_FOR_REQ_CONTEXT];
});

test("does not call `client.decide()` if the local decision is DENY", async () => {
const client = {
decide: jest.fn(async () => {
Expand Down

0 comments on commit 17f8a9a

Please sign in to comment.