graphql-complexity-limit-tester
Crafts over-limit depth and complexity queries then asserts rejection before execution, verifying that graphql-depth-limit, graphql-cost-analysis, and graphql-armor (max-depth / cost-limit / max-tokens plugins) are actually enforced and not just configured. Use when auditing a GraphQL service for DoS exposure after depth or cost limits have been added as mitigations, or when adding tests that prove the limits in CI before a production deployment.
Install with skills.sh (any agent)
npx skills add testland/qa --skill graphql-complexity-limit-testergraphql-complexity-limit-tester
Overview
introspection-attack-surface-reference names query-depth limiting and query-cost limiting as key DoS mitigations, but nothing in that catalog executes a test. This skill closes that gap: it authors tests that send an over-limit query and assert a validation error is returned before any resolver runs.
Three library families are covered:
Differentiation vs. apollo-server-tests: that skill covers resolver correctness + production-config gates (introspection, APQ, hideSchemaDetails). This skill is scoped exclusively to depth/complexity DoS tests - over-limit query construction, validation-layer rejection assertion, and the cross-library matrix.
Hard stop: no limit configured
If the server under test has no depth or complexity limit configured at all (no depthLimit/costAnalysis validation rule, no ApolloArmor/EnvelopArmorPlugin installed), halt immediately:
HALT: no depth/complexity limit configured.
Tests would pass vacuously - no enforcement exists to verify.
Install graphql-depth-limit, graphql-cost-analysis, or
@escape.tech/graphql-armor first, then re-run this skill.Do not write tests that assert on a server with no limit - they will produce false positives.
Step 1 - Install
Choose the library that matches the project.
# graphql-depth-limit (express-graphql / Apollo standalone rule)
npm install --save-dev graphql-depth-limit
# graphql-cost-analysis (Apollo standalone rule)
npm install --save-dev graphql-cost-analysis
# graphql-armor (Apollo or Envelop/Yoga - bundles all plugins)
npm install --save @escape.tech/graphql-armorStep 2 - Identify the configured limit
Read the server setup to find the active limit value before crafting queries. Common locations:
| Library | Where the limit lives |
|---|---|
graphql-depth-limit | depthLimit(N) in validationRules array |
graphql-cost-analysis | costAnalysis({ maximumCost: N }) in validationRules |
graphql-armor max-depth | armor.protect() or new ApolloArmor({ maxDepth: { n: N } }) |
graphql-armor cost-limit | new ApolloArmor({ costLimit: { maxCost: N } }) |
If the limit is not explicit, use the library default: n = 6 for graphql-armor max-depth (per escape.tech/graphql-armor/docs/plugins/max-depth (opens in new window)), maxCost = 5000 for graphql-armor cost-limit (per escape.tech/graphql-armor/docs/plugins/cost-limit (opens in new window)).
Step 3 - Craft over-limit queries
Depth query
Build a query whose nesting depth is configuredLimit + 1. If the schema is User { friends: [User] } and the depth limit is 5:
query DepthBust {
user { # depth 1
friends { # depth 2
friends { # depth 3
friends { # depth 4
friends { # depth 5
friends { id } # depth 6 -> over limit
}
}
}
}
}
}For schemas without recursive types, chain any nested relationship until the depth exceeds the limit.
Cost/complexity query
For graphql-cost-analysis, assign costs via the schema directive @cost(complexity: N) or via costMap. To construct an over-limit query without schema changes, use a fan-out pattern whose calculated cost exceeds maximumCost. Per github.com/pa-bru/graphql-cost-analysis (opens in new window), defaultCost applies to each field when no explicit cost is set; repeat high-cost fields until the sum exceeds the threshold:
query CostBust {
users { id name email createdAt updatedAt roles permissions profile
settings { notifications theme language timezone } }
}For graphql-armor cost-limit, the default costs are: objectCost = 2, scalarCost = 1, depthCostFactor = 1.5 (per escape.tech/graphql-armor/docs/plugins/cost-limit (opens in new window)). A query with cost above maxCost = 5000 can be crafted by stacking scalar fields at multiple nesting levels.
Max-tokens query
For graphql-armor max-tokens, the default token limit is n = 1000 (per escape.tech/graphql-armor/docs/plugins/max-tokens (opens in new window)). Tokens include field names, arguments, braces, and directives. A query with more than 1000 tokens can be constructed by repeating field selections:
query TokenBust {
users {
f1 f2 f3 f4 f5 f6 f7 f8 f9 f10
# ... repeat until token count > configured limit
}
}Step 4 - Write the tests
graphql-depth-limit with Apollo Server
Per the apollo-server-tests skill, use executeOperation for in-process validation. The depthLimit(n) rule is passed as a validationRules option (per npm registry description of graphql-depth-limit).
import { ApolloServer } from '@apollo/server';
import depthLimit from 'graphql-depth-limit';
import { typeDefs, resolvers } from './schema';
const DEPTH_LIMIT = 5;
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(DEPTH_LIMIT)],
});
test('rejects query exceeding depth limit', async () => {
const overDepthQuery = `
query DepthBust {
user { friends { friends { friends { friends { friends { id } } } } } }
}
`;
const resp = await server.executeOperation({ query: overDepthQuery });
if (resp.body.kind !== 'single') throw new Error('expected single');
// Validation errors are returned in errors[], not thrown
expect(resp.body.singleResult.errors).toBeDefined();
expect(resp.body.singleResult.data).toBeUndefined();
});
test('accepts query within depth limit', async () => {
const safeQuery = `
query SafeDepth {
user { friends { id } }
}
`;
const resp = await server.executeOperation({ query: safeQuery });
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeUndefined();
});graphql-cost-analysis with Apollo Server
Per github.com/pa-bru/graphql-cost-analysis (opens in new window), costAnalysis plugs into validationRules alongside any other rules:
import { ApolloServer } from '@apollo/server';
import costAnalysis from 'graphql-cost-analysis';
const MAX_COST = 100;
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
costAnalysis({
maximumCost: MAX_COST,
defaultCost: 1,
variables: {},
}),
],
});
test('rejects query exceeding cost limit', async () => {
// Each field costs defaultCost=1; repeat fields to exceed MAX_COST
const fields = Array.from({ length: MAX_COST + 1 }, (_, i) => `field${i}`).join('\n ');
const overCostQuery = `query CostBust { users { ${fields} } }`;
const resp = await server.executeOperation({ query: overCostQuery });
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeDefined();
});The createError(maximumCost, cost) option (per github.com/pa-bru/graphql-cost-analysis (opens in new window)) lets you assert on a custom error message if the project overrides the default error format.
graphql-armor (Apollo) - depth + cost + tokens
Per escape.tech/graphql-armor/docs/getting-started (opens in new window), ApolloArmor spreads protection options into the server constructor:
import { ApolloServer } from '@apollo/server';
import { ApolloArmor } from '@escape.tech/graphql-armor';
const armor = new ApolloArmor({
maxDepth: { n: 4 }, // override default 6
costLimit: { maxCost: 200 }, // override default 5000
maxTokens: { n: 50 }, // override default 1000
});
const server = new ApolloServer({
typeDefs,
resolvers,
...armor.protect(),
});
test('graphql-armor rejects over-depth query', async () => {
const query = `{ a { b { c { d { e { id } } } } } }`; // depth 6 > limit 4
const resp = await server.executeOperation({ query });
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeDefined();
// With exposeLimits: true (default), error includes limit detail
// With exposeLimits: false, message is 'Query validation error.'
// Per escape.tech/graphql-armor/docs/plugins/max-depth
});
test('graphql-armor rejects over-cost query', async () => {
// objectCost=2, scalarCost=1, depthCostFactor=1.5 (defaults per
// escape.tech/graphql-armor/docs/plugins/cost-limit)
// Stack fields so calculated cost > maxCost=200
const query = `{ users { id name email createdAt updatedAt
profile { bio avatar roles permissions settings { a b c d e } } } }`;
const resp = await server.executeOperation({ query });
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeDefined();
});
test('graphql-armor rejects over-token query', async () => {
// n=50 tokens; build a query with more than 50 tokens
const fields = Array.from({ length: 60 }, (_, i) => `f${i}`).join(' ');
const query = `{ users { ${fields} } }`;
const resp = await server.executeOperation({ query });
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeDefined();
});graphql-armor (Envelop / GraphQL Yoga)
Per escape.tech/graphql-armor/docs/getting-started (opens in new window):
import { envelop } from '@envelop/core';
import { EnvelopArmorPlugin } from '@escape.tech/graphql-armor';
const getEnveloped = envelop({
plugins: [
EnvelopArmorPlugin({
maxDepth: { n: 4 },
costLimit: { maxCost: 200 },
maxTokens: { n: 50 },
}),
],
});Test via the Yoga HTTP layer using supertest (same pattern as apollo-server-tests).
Step 5 - Assert rejection happens before execution
Confirm limits are enforced at validation, not resolver time. One way: instrument a resolver with a side-effect counter and assert it was never called on an over-limit query:
let resolverCallCount = 0;
const server = new ApolloServer({
typeDefs,
resolvers: {
Query: {
users: () => {
resolverCallCount++;
return [];
},
},
},
validationRules: [depthLimit(3)],
});
test('resolver never called on over-limit query', async () => {
resolverCallCount = 0;
const resp = await server.executeOperation({
query: '{ users { friends { friends { friends { id } } } } }',
});
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeDefined();
expect(resolverCallCount).toBe(0); // validation short-circuits execution
});Running
npm test # jest / vitest discover *.test.ts
npx jest --testPathPattern complexity -t "limit"Run against the production configuration. Tests that pass in NODE_ENV=test but fail in NODE_ENV=production (or vice versa) signal a configuration drift problem. See the CI note in apollo-server-tests.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Writing depth tests without checking the actual configured limit | Query may be under-limit; test passes vacuously | Read validationRules / ApolloArmor config first |
| Testing at depth = limit (not limit+1) | Boundary is ambiguous; limit is exclusive or inclusive depending on library | Use limit+1 to be unambiguous |
Asserting errors[0].message string exactly | Error text includes dynamic limit values; brittle | Assert errors is defined; check extensions.code if set |
| Skipping the resolver-call assertion | A misconfigured rule may reach resolvers silently | Instrument resolvers to confirm validation short-circuits |
Using depthLimit and costAnalysis together without verifying precedence | First rule to reject wins; a low depth limit may mask cost-limit tests | Test each rule independently with the other absent |
| Assuming graphql-armor defaults when the project overrides them | Wrong threshold = vacuous pass | Always read the actual ApolloArmor(...) / EnvelopArmorPlugin(...) call |
Limitations
References
Related skills
apollo-server-tests
Wraps Apollo Server testing patterns: `server.executeOperation()` (in-process, no HTTP), `supertest` against an ephemeral-port HTTP server (port 0), context injection via the `contextValue` second-argument, and assertion patterns for response shape + errors. Includes the production-config gates testable through this skill - introspection-disabled, persisted-query mode, hideSchemaDetailsFromClientErrors. Use when writing tests for an Apollo Server v4+ GraphQL service.
graphql-n-plus-one-remediation
Traces a GraphQL resolver tree to locate the N+1 pattern (one parent query returns N rows, then a child field resolver fires once per row), classifies every child field resolver as safe or N+1 risk, and applies one of three fixes: per-request DataLoader batching, eager projection in the parent resolver, or selection-set-aware prefetch. Use when a list-returning resolver is added or changed in review, when a connection-pool exhaustion or slow-query alert traces back to GraphQL traffic, or when a resolver trace shows a child field resolved once per parent row.
graphql-subscription-test-author
Authors GraphQL subscription resolver test suites over graphql-ws (WebSocket) and graphql-sse (Server-Sent Events) transports: subscribe to event streams via the async-iterator API, assert emitted data shape and sequence, verify connection lifecycle and protocol close codes, and validate auth-on-connect (connectionParams / authenticate callback) plus resolver-level pubsub trigger logic. Use for real-time subscription operations; not for queries or mutations - for those use apollo-server-tests, graphql-yoga-tests, or mercurius-tests.
graphql-yoga-tests
Tests a GraphQL Yoga server (the-guild.dev runtime) with `yoga.fetch()` for in-process, no-network request simulation of queries and mutations, `@graphql-tools/executor-http` for subscription and incremental-delivery (streaming) tests, auth-header pass-through, and production-config gates for disabled introspection and persisted operations. Use to test a GraphQL Yoga server, write Yoga query, mutation, or subscription tests, or check its production plugin config; for a different runtime harness use apollo-server-tests, mercurius-tests, or hasura-tests instead, not this skill.
hasura-tests
Wraps Hasura GraphQL Engine testing patterns: docker-compose test instance, the metadata API for declarative schema/permission setup, x-hasura-role and x-hasura-user-id session headers for role-based permission tests, the v1/graphql endpoint via curl/HTTPie/native HTTP clients, and the role-by-table-by-operation permission-matrix pattern. Use for a metadata-driven Hasura engine where row-level permissions dominate; for a code-first server runtime harness use graphql-yoga-tests, apollo-server-tests, or mercurius-tests instead, not this skill.
introspection-attack-surface-reference
Pure-reference catalog of GraphQL introspection as an attack surface and the production-deployment controls for it. Covers what introspection exposes (every type, field, directive, deprecation, description via __schema / __type), Apollo Server's default behaviour (introspection: false when NODE_ENV=production), the `hideSchemaDetailsFromClientErrors: true` companion setting (strips 'did you mean' suggestions), Yoga / Mercurius / Hasura equivalents, query-depth + query-cost limits, persisted-query allowlisting as the strongest mitigation, and the testable behaviours each control creates. Use when designing the production-safety posture of a GraphQL server or auditing an existing deployment.
mercurius-tests
Wraps Mercurius (Fastify GraphQL plugin) testing patterns: `app.inject()` for HTTP-layer simulation without spinning up a network listener, plugin-registration setup (await app.register(mercurius, { schema, resolvers, graphiql: false })), production-config gates (graphiql: false; jit threshold; query depth limits via fastify-rate-limit + complexity), and the per-test app lifecycle (app.close() in afterEach). Use when writing tests for a Fastify + Mercurius GraphQL server.
persisted-query-strategy-reference
Pure-reference catalog of GraphQL Persisted Query strategies: the Apollo APQ SHA-256 hash protocol, the PersistedQueryNotFoundError retry flow, the `extensions.persistedQuery` payload, GET-vs-POST and CDN-cache implications, and the three operation modes (auto-register, strict allowlist, hybrid). Use to design or audit a server's persisted-query request layer. This strategy reference emits no tests; to author the runtime tests use apollo-server-tests, graphql-yoga-tests, or mercurius-tests, and for introspection lockdown see introspection-attack-surface-reference.
pothos-builder-tests
Wraps Pothos GraphQL schema-builder testing patterns: testing the SchemaBuilder output (lexicographicSortSchema + printSchema for snapshot tests), testing resolvers via the standard `graphql()` function from graphql-js (no server needed), integration with Apollo Server / GraphQL Yoga (Pothos emits standard graphql-js schemas), and code-first builder unit tests. Covers the SchemaBuilder API surface (queryType, mutationType, objectType, t.field, t.arg). Use when testing a Pothos-built schema before or alongside the server-runtime tests.