Skip to content

test(e2e): check GraphQL variables against GitHub's schema in the mock - #611

Merged
Vivswan merged 4 commits into
mainfrom
test/e2e-graphql-mock-validation
Nov 12, 2026
Merged

Vivswan merged 4 commits into
mainfrom
test/e2e-graphql-mock-validation

Conversation

@Vivswan

@Vivswan Vivswan commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Before

POST /graphql -> operation matched by name -> barriers -> handler(variables as sent) -> data
$ bun test/e2e/run.ts --scenario environment-pins-apply-converges | sed -n '3,/passed$/p'
  PASS  environment-pins-apply-converges

1/1 passed

After

POST /graphql -> operation matched by name -> on GitHub's schema? -> yes: barriers -> handler(variables as sent) -> data
                                                                  -> no:  200 {"errors": [...]}, no data, no handler, mock violation
$ bun test/e2e/run.ts --scenario environment-pins-apply-converges | sed -n '3,/passed$/p'
  FAIL  environment-pins-apply-converges
          mock violations:
            GraphQL PinEnvironment is refused by GitHub's schema: Variable "$pinned" has invalid value: Expected value of non-null type "Boolean!" not to be null.
          exit code 1 != expected 0
          result "failed" != expected "applied"
          outcome environments "failed" != expected "applied"
          mutations not found as a subsequence:
            want: PUT /repos/e2e-owner/e2e-repo/environments/api, GRAPHQL PinEnvironment, GRAPHQL PinEnvironment, GRAPHQL ReorderEnvironment
            writes: PUT /repos/e2e-owner/e2e-repo/environments/api, GRAPHQL PinEnvironment
          stdout missing: unpinned environment "sandbox"
          stdout missing: moved pinned environment "api" to position 1
          convergence: rerun exited 1, expected 0

0/1 passed

Both runs are a product mutant whose unpin sends pinned: null. A second mutant adds an unknown input field:

$ bun test/e2e/run.ts --scenario branches-wildcard-lifecycle | grep -m1 -A1 'mock violations'
          mock violations:
            GraphQL DeleteBranchProtectionRule is refused by GitHub's schema: Variable "$input" has invalid value: Expected value of type "DeleteBranchProtectionRuleInput" not to include unknown field "pattern", found: { branchProtectionRuleId: "TU9DS05PREU6cnVsZTplMmUtb3duZXIvZTJlLXJlcG86b2xkLyo=", pattern: "old/*" }.

Refused now: a variable off its declared type, an input field GitHub's schema lacks, a document that does not validate.

For the owner: this would not have caught the wildcard enforce_admins: null bug. That null was legal input. Catching it needs a check of what the mock answers, recorded below.

How

  • First stage: ahead of every barrier, as GitHub validates before it executes.
  • A refusal fails the run: GitHub's reply shape, plus a mock violation.
  • Accepted requests are untouched: handlers receive the variables as sent.
  • One schema builder: moved out of the declared-query test, tripwire included.
  • Library: graphql, with validate for the document and validateExecutionArgs for the variables.

Proof

  • Unit: 41 pass in four suites.
  • Corpus: 389/389 passed, before main added three scenarios.
  • Fuzz: 25/25 iterations ok, seed 20261111.
Technical details
  • Also refused: a document that does not parse.
  • What it does not catch: a null for a nullable input field, such as isAdminEnforced: null on a rule mutation, which GitHub's SDL types Boolean.
  • What it does not catch: a variable the document does not declare, which reaches the handler as sent, as on main.
  • Witness, step 1: git archive origin/main | tar -x -C <dir>, link node_modules, run bun run build:schema, apply one mutant, run the Before command in <dir>.
  • Witness, step 2: copy this branch's test/e2e/mock/ over <dir>/test/e2e/mock/ and run the same command for the After block.
  • Mutant 1: in src/sections/environments/pins.ts, the unpin's variables { environmentId, pinned: false } become { environmentId, pinned: null }.
  • Mutant 2: in src/sections/branches/graphql-writes.ts, the rule delete's { input: { branchProtectionRuleId: node.id } } gains pattern.
  • Mutant 2 on main's mock: bun test/e2e/run.ts --scenario branches-wildcard-lifecycle | sed -n '3,/passed$/p' prints PASS and 1/1 passed.
  • Unit command: bun test test/e2e/mock/graphql-request.test.ts test/e2e/mock/graphql-pipeline.test.ts test/sections/fragment-fake.test.ts test/sections/graphql-queries.test.ts.
  • Red on main: the first three files copied onto an export of main: 6 fail, 30 pass.
  • Two of the three control: tests pass on main on purpose: a valid request still answers data, and the handler still receives the variables as sent.
  • The third control: test is the unit fake's, in test/sections/fragment-fake.test.ts, and fails on main by design.
  • Corpus command: bun test/e2e/run.ts --sections all, then 388 scenarios plus the corpus witness line.
  • Fuzz command: FUZZ_SEED=20261111 bun test/e2e/fuzz.ts --iterations 25.
  • Where the corpus and the fuzz ran: on this change over the mainline commit it branched from.
  • After merging main: the one commit merged since touches no file of the mock, and the unit suites, the 126-scenario slice, the three scenarios main added, and the static gates re-ran on the merged tree.
  • Operations covered: all 14 declared operations validate and coerce on the 73 GraphQL requests of bun test/e2e/run.ts --sections branches,environments,repository (126 scenarios).
  • How the 73 were counted: a scratch script ran the runner's runScenario over that slice and checked every logged /graphql request with graphql-js.
  • Gap SDL: RepositoryFeatures and UpdateRepositoryFeatures validate only with src/upstream-gaps/issue-creation-policy.ts, which the shared builder already adds.
  • Gap tripwire: a gap whose SDL the package already ships fails the extension naming the gap file (negative control) in test/sections/graphql-queries.test.ts passes unchanged against the moved builder.
  • Cost: the schema is built once per process, with no document cache.
  • Refusal wording: graphql-js's, not api.github.com's.
  • Validator: the refused reply is logged off-spec, so the OpenAPI validator's GraphQL response contract (typed errors beside data) does not report it a second time.
  • Re-expected test: a read without $owner/$repo variables is a violation is now a read addressing no repository is a violation.
  • Why: a declared $repo left out is GitHub's own refusal now, so the target-resolution rule is reached by a read that declares no address.
  • Fixtures made valid: UpdateToggles selected hasWiki, which UpdateRepositoryInput lacks, and two fixture reads carried another operation's document.
  • Unit fake: test/sections/fragment-fake.ts runs the check in graphqlRouteOf, since tryGraphql sits at the lint's complexity ceiling.
  • Unit fake's control: test/sections/fragment-fake.test.ts pins the refusal path, which the section suites never reach.
  • One generated comment line under src/generated/: src/generated/upstream-gaps.ts changed by one comment line, regenerated from .github/scripts/gen-gaps-index.ts to name the schema builder as the gap SDL's consumer, so nothing a run executes under src/ changes.
  • Not built, the response check: executing each document over the handler's data with graphql-js executeSync reported 0 errors on the 71 data responses of the same 73 requests.
  • Its control: a rule node with isAdminEnforced: null reports Cannot return null for non-nullable field BranchProtectionRule.isAdminEnforced.
  • Found, not fixed here: on main, a wildcard branch with required_linear_history: null sends {"requiresLinearHistory": null} and the mock stores it.
  • Its first result: saved under test/e2e/scenarios/ on main, the scenario below prints PASS for bun test/e2e/run.ts --scenario branches-wildcard-null-linear-history.
  • Its second result: with fixpoint: apply_idempotent added under expect, the same command prints apply-idempotence: second apply exited 1, expected 0.
  • Why this check leaves it alone: GitHub's SDL types that input Boolean, so the null is legal input, and what the real API does with it is unverified.
name: branches-wildcard-null-linear-history
settings:
  branches:
    - name: "release/*"
      protection:
        required_linear_history: null
live_state:
  branches: ["main"]
  branch_protection_rules:
    - pattern: "release/*"
      requiresLinearHistory: true
token_permissions:
  administration: write
  contents: read
expect:
  exit_code: 0
  result: applied
  outcomes:
    branches: applied
  mutations:
    - "GRAPHQL UpdateBranchProtectionRule"

The e2e mock answered a GraphQL request by operation name and handed its variables to the handler unread, so a mutation carrying an input field GitHub's schema lacks, or a null for a `Boolean!` variable, passed every scenario.

The mock now checks each request the way GitHub does before it runs any of it: graphql-js validates the document against the schema and coerces the variables to the types the operation declares. A request that fails either is answered with HTTP 200, `errors`, and no `data`. No handler runs, and the pipeline records a mock violation, so the scenario fails whatever outcome it expected.

The check sits ahead of the check-mode, fault, and permission barriers. A request GitHub accepts reaches its handler with the variables exactly as sent.

The schema is the SDL of @octokit/graphql-schema plus the upstream-gap SDL, built once per process. Its builder moves from test/sections/graphql-queries.test.ts to test/e2e/mock/graphql-schema.ts, so the declared-query test and the mock read one schema and one gap tripwire. The section unit fake runs the same check.

The fixture operations of graphql-pipeline.test.ts were off GitHub's schema (`hasWiki` is not an `UpdateRepositoryInput` field, and two reads carried another operation's document), so they are now valid documents.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 07:42
@github-actions

Copy link
Copy Markdown
Contributor

File size check

0 over a hard cap (fails), 6 warning(s).

File Size Tier Cap
src/engine/diff.ts:56 11 comment lines warn 10
src/github/api-error.ts:9 11 comment lines warn 10
src/problem.ts:73 18 comment lines warn 10
src/sections/contract/errors.ts:18 11 comment lines warn 10
src/sections/contract/module.ts:48 12 comment lines warn 10
src/sections/shared/list/decl.ts:265 14 comment lines warn 10

Split the file, wrap the line, shorten or exempt the comment, or list the path in .file-size-allow.local with a # reason.

5 managed file(s) skipped; repo-platform owns them.

@github-actions

Copy link
Copy Markdown
Contributor

Member shape check

22 finding(s).

File Line Rule
src/action/io.ts 24 one-line-comment
src/cli/actions.ts 12 one-line-comment
src/github/api.ts 55 one-line-comment
src/sections/branches/schema.ts 246 all-or-none
src/sections/branches/schema.ts 267 all-or-none
src/sections/branches/schema.ts 405 constant-spacing
src/sections/branches/schema.ts 444 one-line-comment
src/sections/environments/schema.ts 46 one-line-comment
src/sections/environments/schema.ts 108 one-line-comment
src/sections/pages/index.ts 71 one-line-comment
test/cli/actions.test.ts 45 one-line-comment
test/scripts/release-pipeline-fixture.test.ts 25 one-line-comment
test/scripts/release-pipeline-fixture.ts 53 one-line-comment
test/sections/contract/validate-hook.test.ts 80 all-or-none
test/sections/teams/teams.test.ts 20 all-or-none
test/sections/teams/teams.test.ts 23 all-or-none
test/sections/teams/teams.test.ts 24 all-or-none
test/sections/teams/teams.test.ts 25 all-or-none
test/sections/teams/teams.test.ts 26 all-or-none
test/sections/teams/teams.test.ts 28 all-or-none
test/sections/teams/teams.test.ts 31 all-or-none
test/sections/teams/teams.test.ts 34 constant-spacing

Add the blank lines, make the comment one line directly above the item, comment every field or none, or list the path in .member-shape-allow.local with a # reason.

4 generated file(s) skipped.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are reported for the changes adding schema validation to GraphQL test mocks.

No open issues 🎉

What changed in this PR

This PR strengthens the GraphQL test mocks by rejecting documents and variables that GitHub's schema would refuse, preventing tests from passing on invalid requests.

Changes:

  • Adds schema validation before GraphQL pipeline barriers, returning errors and recording a mock violation on refusal.
  • Shares the schema builder across mock validation and declared-query tests, preserving upstream-gap checks.
  • Adds rejection and valid-request tests, updates fixtures, and applies validation to the section-test fake.
File Description
test/​sections/​graphql-queries.test.ts Uses the shared schema builder instead of a local implementation.
test/​sections/​fragment-fake.ts Validates GraphQL requests before invoking section-test handlers.
test/​e2e/​mock/​routes.ts Rejects invalid requests before barriers and records violations.
test/​e2e/​mock/​graphql-schema.ts Centralizes schema construction, gap extensions, and process-level caching.
test/​e2e/​mock/​graphql-request.ts Checks document syntax, schema validity, and variable coercion.
test/​e2e/​mock/​graphql-request.test.ts Tests wire-level refusals, unchanged state, and valid-request handling.
test/​e2e/​mock/​graphql-pipeline.test.ts Updates fixtures and tests validation ordering and unchanged variable forwarding.

Review effort: Balanced

… consumer

The unit fake's refusal of a request off GitHub's schema had no test, because the section suites send only requests GitHub accepts. test/sections/fragment-fake.test.ts sends `pinned: null` through the fake and expects GitHub's refusal with nothing stored, then the same request with a boolean, which unpins.

The generated gap index said UNSHIPPED_GRAPHQL_SDL is consumed by test/sections/graphql-queries.test.ts. The schema builder in test/e2e/mock/graphql-schema.ts consumes it now, so the generator's sentence names the builder and src/generated/upstream-gaps.ts is regenerated: one comment line.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 07:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are recorded for the GraphQL mock validation changes.

No open issues 🎉

Review effort: Balanced

@Vivswan Vivswan added the merge-when-green Owner approved: merge once every gate is green label Nov 12, 2026
Copilot AI balanced review requested due to automatic review settings November 12, 2026 09:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues block approval of the GraphQL mock validation changes.

No open issues 🎉

Review effort: Balanced

@Vivswan
Vivswan marked this pull request as ready for review November 12, 2026 09:18
@Vivswan
Vivswan merged commit ef0cb12 into main Nov 12, 2026
29 checks passed
@Vivswan
Vivswan deleted the test/e2e-graphql-mock-validation branch November 12, 2026 09:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-when-green Owner approved: merge once every gate is green

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants