From 9960f7e540a5afc26073a8618800add47c32d530 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:19:47 +0900 Subject: [PATCH 01/52] graphql: a request error is Apollo Server's, in graphql-js's words Both vendors run Apollo Server 4 over graphql-js. The engine now answers a request the way that pair does: Apollo Server's checks of the body and of a persisted query, then graphql-js's parse, validation, choice of operation and coercion of variables, each refusal with Apollo Server's code and graphql-js's wording (backlot.graphql.js_words). Each router writes the refusals in its vendor's envelope, and Linear answers them up to the choice of operation before it reads the credential. The telemetry classifier reads Fireflies' code beside the message, and the GraphQL message slugs those codes replace are gone. --- backlot/graphql/engine.py | 369 ++++++++++++++++++---- backlot/graphql/fireflies_resolvers.py | 7 +- backlot/graphql/js_words.py | 419 +++++++++++++++++++++++++ backlot/graphql/linear_resolvers.py | 7 +- backlot/routers/fireflies.py | 106 ++++++- backlot/routers/linear.py | 148 ++++++++- backlot/telemetry/errors.py | 39 +-- tests/test_cross_vendor.py | 12 +- tests/test_fireflies.py | 208 +++++++++++- tests/test_graphql.py | 224 ++++++++++--- tests/test_linear.py | 259 ++++++++++++--- tests/test_telemetry.py | 101 ++++-- 12 files changed, 1660 insertions(+), 239 deletions(-) create mode 100644 backlot/graphql/js_words.py diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index 50295f7c1..3685925b9 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -6,30 +6,37 @@ context dict (connection, caller, ``visible_ids``) that every resolver receives as ``info.context``. -Execution is deliberately split into parse -> validate -> execute rather than delegating to -``graphql_sync``, because the three stages produce *different* envelopes. Per the GraphQL -spec, a **request error** (malformed document, failed validation, uncoercible variables) is -detected before execution begins and the response carries no ``data`` entry at all, while a -**field error** raised mid-execution returns partial ``data`` alongside ``errors``. Real -servers make that distinction and clients key off it, so Backlot does too — -``ExecutionResult.formatted`` alone would emit ``"data": null`` for every request error. +Both vendors serve GraphQL through Apollo Server 4 over graphql-js, so the engine answers as that +pair does: Apollo Server's checks of the body and of a persisted query come first, then +graphql-js's parse, validation and choice of operation (:meth:`Engine.resolve_payload`), then the +coercion of variables and the run itself (:meth:`Engine.run`), each refusal a :class:`Refusal` +carrying Apollo Server's ``code`` and the message in graphql-js's words +(:mod:`backlot.graphql.js_words`). A router that reads the credential between the two stages calls +them apart. A refusal made before execution carries no ``data`` entry; an error made as the +operation runs comes back beside ``data``, at 200. Each router turns the refusals into its +vendor's envelope. """ from __future__ import annotations import functools +import hashlib from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Mapping from graphql import ( + DocumentNode, GraphQLError, GraphQLID, GraphQLInputObjectType, GraphQLList, GraphQLNonNull, + GraphQLObjectType, GraphQLScalarType, GraphQLSchema, + GraphQLSyntaxError, + OperationDefinitionNode, ValueNode, assert_valid_schema, build_schema, @@ -41,6 +48,7 @@ ) from backlot.graphql import body as request_body +from backlot.graphql import js_words Resolver = Callable[..., Any] @@ -84,19 +92,64 @@ def _ids_as_graphql_js_reads_them(value: Any, type_: Any) -> Any: Scalar = Mapping[str, Callable[..., Any]] +#: Apollo Server's codes for the refusals it and graphql-js make, as both vendors carried them on +#: 2026-10-25 (UTC). +BAD_REQUEST = "BAD_REQUEST" +PARSE_FAILED = "GRAPHQL_PARSE_FAILED" +VALIDATION_FAILED = "GRAPHQL_VALIDATION_FAILED" +OPERATION_RESOLUTION_FAILURE = "OPERATION_RESOLUTION_FAILURE" +BAD_USER_INPUT = "BAD_USER_INPUT" +PERSISTED_QUERY_NOT_FOUND = "PERSISTED_QUERY_NOT_FOUND" +INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR" + + +@dataclass(frozen=True) +class Refusal: + """One request error as Apollo Server answers it: ``message`` in graphql-js's words, the + ``locations`` graphql-js gives it, Apollo Server's ``code``, and the status of the answer.""" + + message: str + code: str + locations: tuple = () + status: int = 400 + + @dataclass(frozen=True) class Result: """A GraphQL response body plus how it should be surfaced over HTTP. - ``request_error`` marks the pre-execution failures above; routers map it to the status - code their vendor uses (real Linear answers a malformed document with 400, while a - field error is still a 200 with ``errors``). ``raised`` marks a field error that is Backlot's - own failure rather than a refusal; :func:`_raised` tells the two apart. + ``refusals`` holds the request errors, made before execution, and ``payload`` then carries no + ``data``; a router writes them in its vendor's envelope. ``assigned`` holds the index in + ``payload["errors"]`` of each error graphql-core made as the operation ran, which carries the + ``extensions.code`` Apollo Server gives an error with none of its own. ``raised`` marks a field + error that is Backlot's own failure rather than a refusal; :func:`_raised` tells the two apart. """ payload: dict - request_error: bool = False + refusals: tuple[Refusal, ...] = () raised: bool = False + assigned: frozenset[int] = frozenset() + + @property + def request_error(self) -> bool: + return bool(self.refusals) + + @property + def status(self) -> int: + """The status of the answer: a refusal's, or 200 for an operation that ran.""" + return self.refusals[0].status if self.refusals else 200 + + +@dataclass(frozen=True) +class Operation: + """A request read up to the choice of its operation (:meth:`Engine.resolve`): the parsed + ``document``, the ``operation`` chosen from it, and the ``variables`` and operation ``name`` + sent with it.""" + + document: DocumentNode + operation: OperationDefinitionNode + variables: Any + name: str | None def _marking_refusals(fn: Resolver) -> Resolver: @@ -132,20 +185,107 @@ def _raised(error: GraphQLError) -> bool: ) -def _request_error(errors: list[GraphQLError]) -> Result: - return Result(payload={"errors": [e.formatted for e in errors]}, request_error=True) +def _refused(*refusals: Refusal) -> Result: + """The request errors ``refusals``, in Apollo Server's own envelope: ``message``, the + ``locations`` where there are any, and ``extensions.code``.""" + errors = [] + for refusal in refusals: + entry: dict = {"message": refusal.message} + if refusal.locations: + entry["locations"] = list(refusal.locations) + entry["extensions"] = {"code": refusal.code} + errors.append(entry) + return Result(payload={"errors": errors}, refusals=refusals) -def _client_error(message: str) -> Result: - return _request_error([GraphQLError(message)]) +def _refusal(message: str, code: str = BAD_REQUEST, status: int = 400) -> Result: + return _refused(Refusal(message, code, status=status)) -def not_json() -> Result: - """The request error for a body that is not JSON, in graphql-js's words.""" - return _client_error("POST body sent invalid JSON.") +def _js_truthy(value: Any) -> bool: + """Whether JavaScript reads the JSON value ``value`` as true.""" + if value is None or value is False or value == "": + return False + if isinstance(value, (int, float)) and not isinstance(value, bool): + return value == value and value != 0 + return True + + +_NOT_A_STRING = "GraphQL queries must be strings." +# Apollo Server's longer refusal of a `query` that looks like a parsed document, +# `{"kind":"Document"}`: both vendors answered it on 2026-10-25 (UTC). +_A_DOCUMENT = ( + _NOT_A_STRING + " It looks like you're sending the internal graphql-js representation of a " + "parsed query in your request instead of a request in the GraphQL query language. You can " + "convert an AST to a string using the `print` function from `graphql`, or use a client like " + "`apollo-client` which converts the internal representation to a string for you." +) +NO_QUERY = "GraphQL operations must contain a non-empty `query` or a `persistedQuery` extension." +NO_KEYS = "POST body missing, invalid Content-Type, or JSON object has no keys." +RECURSIVELY_ENCODED = ( + "`{}` in a POST body should be provided as an object, not a recursively JSON-encoded string." +) -def from_sdl(module_file: str, name: str, resolvers, scalars=None) -> "Engine": +def body_refusal(payload: Any) -> Result | None: + """Apollo Server's refusal of a POST body ``payload`` read as JSON, or ``None`` for a body it + goes on with. The checks run in this order, each first where two apply, as both vendors + answered on 2026-10-25 (UTC): an array, an empty object or a value that is not an object, a + truthy `query` that is not a string, `variables` and then `extensions` as a string, then + `extensions`, `variables` and `operationName` of the wrong type. A ``null`` member is one not + sent.""" + if isinstance(payload, list): + return _refusal("Operation batching disabled.") + if not isinstance(payload, dict) or not payload: + return _refusal(NO_KEYS) + query = payload.get("query") + if _js_truthy(query) and not isinstance(query, str): + document = isinstance(query, dict) and query.get("kind") == "Document" + return _refusal(_A_DOCUMENT if document else _NOT_A_STRING) + for name in ("variables", "extensions"): + if isinstance(payload.get(name), str): + return _refusal(RECURSIVELY_ENCODED.format(name)) + for name in ("extensions", "variables"): + if payload.get(name) is not None and not isinstance(payload[name], dict): + return _refusal(f"`{name}` in a POST body must be an object if provided.") + name = payload.get("operationName") + if name is not None and not isinstance(name, str): + return _refusal("`operationName` in a POST body must be a string if provided.") + return None + + +#: Apollo Server's refusals of a persisted query, as api.fireflies.ai answered them on 2026-10-25 +#: (UTC). +PERSISTED_QUERY_VERSION = "Unsupported persisted query version" +PERSISTED_QUERY_MISMATCH = "provided sha does not match query" + + +def persisted_query_refusal(payload: dict) -> Result | None: + """Apollo Server's answer to the ``persistedQuery`` extension of ``payload``, or ``None`` for + a request it runs. Both vendors answered on 2026-10-25 (UTC) as a server that keeps no query: + a hash sent alone was ``PersistedQueryNotFound`` at 200 even after the query that hashes to it + had been sent with it and served. A version other than the number 1 is refused, then a hash + that is not the SHA-256 of the query sent, in lower-case hex, before the query is parsed.""" + extensions = payload.get("extensions") + persisted = extensions.get("persistedQuery") if isinstance(extensions, dict) else None + if not _js_truthy(persisted): + return None + version = persisted.get("version") if isinstance(persisted, dict) else None + if isinstance(version, bool) or not isinstance(version, (int, float)) or version != 1: + return _refusal(PERSISTED_QUERY_VERSION, INTERNAL_SERVER_ERROR) + query = payload.get("query") + if query is None: + return _refusal("PersistedQueryNotFound", PERSISTED_QUERY_NOT_FOUND, status=200) + # Both vendors hashed a query holding U+D800 as the same query with U+FFFD in its place on + # 2026-10-25 (UTC), the UTF-8 Node writes for a lone surrogate. + text = js_words.LONE_SURROGATE.sub("\ufffd", query) if isinstance(query, str) else None + digest = hashlib.sha256(text.encode("utf-8")).hexdigest() if text is not None else None + if digest is None or persisted.get("sha256Hash") != digest: + return _refusal(PERSISTED_QUERY_MISMATCH, INTERNAL_SERVER_ERROR) + return None + + +def from_sdl(module_file: str, name: str, resolvers, scalars=None, roots=()) -> "Engine": """An :class:`Engine` over the ``.graphql`` sitting beside ``module_file``. Each vendor's resolver module keeps its SDL as a sibling file, so this is the one place that @@ -154,9 +294,25 @@ def from_sdl(module_file: str, name: str, resolvers, scalars=None) -> "Engine": (Path(module_file).parent / f"{name}.graphql").read_text(encoding="utf-8"), resolvers, scalars, + roots, ) +def _with_roots(schema: GraphQLSchema, roots: tuple[str, ...]) -> GraphQLSchema: + """``schema`` with each of ``roots``, ``Mutation`` or ``Subscription``, as a root type that + has no field. graphql-core refuses a schema with an object type of no field, so this one is + marked as checked; it is only validated against and run for ``__typename``.""" + shadow = GraphQLSchema( + query=schema.query_type, + mutation=GraphQLObjectType("Mutation", {}) if "Mutation" in roots else None, + subscription=GraphQLObjectType("Subscription", {}) if "Subscription" in roots else None, + types=[t for name, t in schema.type_map.items() if not name.startswith("__")], + directives=schema.directives, + ) + shadow._validation_errors = [] + return shadow + + class Engine: """An executable schema: SDL text bound to a ``{type: {field: resolver}}`` map. @@ -164,6 +320,11 @@ class Engine: unbound fall through to the default resolver, which reads a key off the parent when it is a mapping — so a resolver can simply return dicts and let the selection set decide what is emitted. + + ``roots`` names the root types, ``Mutation`` or ``Subscription``, the vendor's schema has and + the SDL leaves out because Backlot serves none of their fields. A document is validated as if + each were there with no field, so a field of one is refused as one the type does not have and + ``__typename`` is answered with the type's name. """ def __init__( @@ -171,11 +332,13 @@ def __init__( sdl: str, resolvers: Mapping[str, Mapping[str, Resolver]] | None = None, scalars: Mapping[str, Scalar] | None = None, + roots: tuple[str, ...] = (), ): self.schema: GraphQLSchema = build_schema(sdl) # validate() would do this on the first request otherwise, surfacing a vendor's broken # SDL as a 500 rather than as an import-time failure. assert_valid_schema(self.schema) + self._checked = _with_roots(self.schema, roots) if roots else self.schema for scalar_name, coercion in (scalars or {}).items(): scalar = self.schema.type_map.get(scalar_name) # Same rule as an unknown resolver target below: a misspelt scalar would leave the SDL's @@ -200,66 +363,156 @@ def __init__( ) field.resolve = _marking_refusals(fn) - def execute( + def resolve( self, document: str, *, - variables: dict | None = None, + variables: Any = None, operation_name: str | None = None, - context: Any = None, - ) -> Result: + on_document: Callable[[DocumentNode], Result | None] | None = None, + ) -> Result | Operation: + """``document`` read as graphql-js reads it under Apollo Server, up to the choice of the + operation to run: parse, then ``on_document``, a vendor's look at the parsed document + before it is validated, then validation and the choice of operation, each refused with + Apollo Server's code. :meth:`run` takes the :class:`Operation` it returns.""" try: doc = parse(document) - except GraphQLError as exc: # GraphQLSyntaxError - return _request_error([exc]) - errors = validate(self.schema, doc) + except GraphQLSyntaxError as error: + return _refused( + Refusal(js_words.requote(error.message), PARSE_FAILED, js_words.locations(error)) + ) + if on_document is not None: + refused = on_document(doc) + if refused is not None: + return refused + errors = validate(self._checked, doc) if errors: - return _request_error(list(errors)) + return _refused( + *( + Refusal(js_words.requote(e.message), VALIDATION_FAILED, js_words.locations(e)) + for e in errors + ) + ) operation = get_operation_ast(doc, operation_name) - if variables and operation is not None: + if operation is None: + message = ( + "Must provide operation name if query contains multiple operations." + if operation_name is None + else f'Unknown operation named "{operation_name}".' + ) + return _refusal(message, OPERATION_RESOLUTION_FAILURE) + return Operation(doc, operation, variables, operation_name) + + def resolve_payload( + self, + payload: Any, + *, + on_document: Callable[[DocumentNode], Result | None] | None = None, + ) -> Result | Operation: + """A GraphQL-over-HTTP POST body (``{query, variables, operationName}``) its router has + read with :mod:`backlot.graphql.body`, up to the choice of operation: :func:`body_refusal`, + then :func:`persisted_query_refusal`, then Apollo Server's refusal of a body with no query, + then :meth:`resolve`.""" + refused = body_refusal(payload) + if refused is None: + refused = persisted_query_refusal(payload) + if refused is not None: + return refused + query = payload.get("query") + if not _js_truthy(query): + return _refusal(NO_QUERY) + return self.resolve( + query, + variables=payload.get("variables"), + operation_name=payload.get("operationName"), + on_document=on_document, + ) + + def run(self, operation: Operation, *, context: Any = None) -> Result: + """Coerce the variables of ``operation``, refused with Apollo Server's code, and run it + with ``context``.""" + variables = operation.variables + coercion = js_words.coerce_variable_values( + self.schema, list(operation.operation.variable_definitions), variables + ) + if coercion: + return _refused( + *(Refusal(e.message, BAD_USER_INPUT, js_words.locations(e)) for e in coercion) + ) + if variables: types = { definition.variable.name.value: type_from_ast(self.schema, definition.type) - for definition in operation.variable_definitions + for definition in operation.operation.variable_definitions } variables = { name: _ids_as_graphql_js_reads_them(value, types.get(name)) for name, value in variables.items() } + # An operation on a root the SDL leaves out has only `__typename` once it is validated. + on_a_root = getattr(self.schema, f"{operation.operation.operation.value}_type") is None result = execute_sync( - self.schema, - doc, + self._checked if on_a_root else self.schema, + operation.document, context_value=context, variable_values=variables, - operation_name=operation_name, + operation_name=operation.name, ) - # Variable coercion and operation selection fail *inside* execute_sync but still - # before any field runs. Those errors carry no ``path`` (nothing was resolved), which - # is what separates them from a non-null field error that propagated up to the root. - if result.data is None and result.errors and all(e.path is None for e in result.errors): - return _request_error(list(result.errors)) # graphql-core's ExecutionResult.formatted writes "data" first; api.fireflies.ai and - # api.linear.app open a response carrying a field error with "errors" (2026-10-21), so flip - # the two members here. + # api.linear.app open a response carrying a field error with "errors" (2026-10-21), so the + # errors come first here. An error graphql-core made itself is in graphql-js's words and + # carries the code Apollo Server gives an error with none, which a router writes in its + # vendor's envelope; one a resolver raised is the vendor's own. + payload: dict = {} + assigned = [] + if result.errors: + payload["errors"] = [] + for error in result.errors: + entry = error.formatted + if _made_by_graphql(error): + entry["message"] = js_words.requote(entry["message"]) + if "locations" in entry: + entry["locations"] = list(js_words.locations(error)) + entry.setdefault("extensions", {})["code"] = INTERNAL_SERVER_ERROR + assigned.append(len(payload["errors"])) + payload["errors"].append(entry) + payload["data"] = result.data return Result( - payload=dict(reversed(list(result.formatted.items()))), + payload=payload, raised=any(_raised(e) for e in result.errors or ()), + assigned=frozenset(assigned), ) - def execute_payload(self, payload: Any, *, context: Any = None) -> Result: - """Run a GraphQL-over-HTTP POST body (``{query, variables, operationName}``) its router has - read with :mod:`backlot.graphql.body`. A body of the wrong shape comes back as a GraphQL - error envelope in graphql-js's words rather than a framework-level validation error.""" - if not isinstance(payload, dict): - return not_json() - query = payload.get("query") - if not isinstance(query, str) or not query.strip(): - return _client_error("Must provide query string.") - variables = payload.get("variables") - if variables is not None and not isinstance(variables, dict): - return _client_error("Variables are invalid JSON.") - operation_name = payload.get("operationName") - if operation_name is not None and not isinstance(operation_name, str): - return _client_error("Must provide operation name as a string.") - return self.execute( - query, variables=variables, operation_name=operation_name, context=context + def execute( + self, + document: str, + *, + variables: Any = None, + operation_name: str | None = None, + context: Any = None, + on_document: Callable[[DocumentNode], Result | None] | None = None, + ) -> Result: + """:meth:`resolve`, then :meth:`run` the operation it chose.""" + resolved = self.resolve( + document, variables=variables, operation_name=operation_name, on_document=on_document ) + return resolved if isinstance(resolved, Result) else self.run(resolved, context=context) + + def execute_payload( + self, + payload: Any, + *, + context: Any = None, + on_document: Callable[[DocumentNode], Result | None] | None = None, + ) -> Result: + """:meth:`resolve_payload`, then :meth:`run` the operation it chose.""" + resolved = self.resolve_payload(payload, on_document=on_document) + return resolved if isinstance(resolved, Result) else self.run(resolved, context=context) + + +def _made_by_graphql(error: GraphQLError) -> bool: + """Whether graphql-core made ``error`` itself as the operation ran, as graphql-js would have: + not a refusal a resolver raised (:func:`_marking_refusals`) and not an exception that is + Backlot's own failure.""" + if getattr(error, "refusal", False) or getattr(error.original_error, "refusal", False): + return False + return error.original_error is None or isinstance(error.original_error, GraphQLError) diff --git a/backlot/graphql/fireflies_resolvers.py b/backlot/graphql/fireflies_resolvers.py index ac5450bb8..f46b9baea 100644 --- a/backlot/graphql/fireflies_resolvers.py +++ b/backlot/graphql/fireflies_resolvers.py @@ -709,11 +709,16 @@ def resolve_users(_root, info, **_ignored): } +# The root type api.fireflies.ai's introspection names beside `Query` (2026-10-25, UTC), none of +# whose fields Backlot serves; it names no subscription type. +ROOTS = ("Mutation",) + + def build_engine(): """The Fireflies engine, over the SDL beside this module.""" from backlot.graphql import engine - return engine.from_sdl(__file__, "fireflies", RESOLVERS) + return engine.from_sdl(__file__, "fireflies", RESOLVERS, roots=ROOTS) __all__ = [ diff --git a/backlot/graphql/js_words.py b/backlot/graphql/js_words.py new file mode 100644 index 000000000..0603e9491 --- /dev/null +++ b/backlot/graphql/js_words.py @@ -0,0 +1,419 @@ +"""graphql-js's words for the errors graphql-core makes with its own. + +Linear and Fireflies run graphql-js under Apollo Server, and graphql-core is a port of graphql-js +that writes the same messages with two differences: it quotes a name, a type, a token or a +character with ``'`` where graphql-js quotes it with ``"``, and it prints a variable's value with +Python's ``repr`` where graphql-js prints it with its own ``inspect``. Both vendors answered a +validation error, a syntax error and a variable's refusal in graphql-js's words on 2026-10-25 +(UTC), quotes and values as graphql-js 16.14.2 writes them: Linear also refused an array sent for +an input object as graphql-js 16.14.2 does, where 16.9.0 reads the array's indices as field names. + +:func:`requote` turns a syntax or validation message graphql-core wrote into graphql-js's, and +:func:`coerce_variable_values` makes a variable's refusals as graphql-js does, from the value as +the request sent it, so that no value passes through ``repr``. +""" + +from __future__ import annotations + +import json +import math +import re +from typing import Any, Callable + +from graphql import ( + GraphQLEnumType, + GraphQLError, + GraphQLInputObjectType, + GraphQLList, + GraphQLNonNull, + GraphQLScalarType, + GraphQLSchema, + is_input_type, + print_ast, + type_from_ast, +) +from graphql.language import DirectiveLocation, VariableDefinitionNode +from graphql.pyutils import Undefined, suggestion_list + +from backlot.graphql import body as request_body + +# graphql-js's `inspect` prints ten items of an array and two levels of nesting. +_MAX_ARRAY_LENGTH = 10 +_MAX_RECURSIVE_DEPTH = 2 +# graphql-js stops coercing variables at the fiftieth error, as `execute` asks it to. +_MAX_ERRORS = 50 +LONE_SURROGATE = re.compile("[\ud800-\udfff]") + + +def stringify(text: str) -> str: + """``text`` as ``JSON.stringify`` writes a string: in double quotes, a lone surrogate as a + lower-case ``\\u`` escape.""" + return LONE_SURROGATE.sub( + lambda m: f"\\u{ord(m.group()):04x}", json.dumps(text, ensure_ascii=False) + ) + + +def _is_array_index(key: str) -> bool: + return key.isascii() and key.isdigit() and str(int(key)) == key and int(key) < 2**32 - 1 + + +def js_keys(obj: dict) -> list[str]: + """``obj``'s keys in the order JavaScript gives an object's own keys: the array indices in + ascending order, then the other keys in the order they were first set.""" + indices = sorted((key for key in obj if _is_array_index(key)), key=int) + return indices + [key for key in obj if not _is_array_index(key)] + + +def number(value: int | float) -> str: + """A JSON number as JavaScript's ``String`` prints it.""" + return repr(request_body.Double(value)) if isinstance(value, float) else str(value) + + +def inspect(value: Any) -> str: + """``value``, a value ``JSON.parse`` could have made, as graphql-js's ``inspect`` prints it.""" + return _format(value, []) + + +def _format(value: Any, seen: list) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return number(value) + if isinstance(value, str): + return stringify(value) + if any(value is item for item in seen): + return "[Circular]" + seen = [*seen, value] + if isinstance(value, list): + if not value: + return "[]" + if len(seen) > _MAX_RECURSIVE_DEPTH: + return "[Array]" + items = [_format(item, seen) for item in value[:_MAX_ARRAY_LENGTH]] + remaining = len(value) - _MAX_ARRAY_LENGTH + if remaining == 1: + items.append("... 1 more item") + elif remaining > 1: + items.append(f"... {remaining} more items") + return "[" + ", ".join(items) + "]" + if not value: + return "{}" + if len(seen) > _MAX_RECURSIVE_DEPTH: + return "[Object]" + return "{ " + ", ".join(f"{key}: {_format(value[key], seen)}" for key in js_keys(value)) + " }" + + +# graphql-js breaks a line at `\r\n`, `\n` or `\r` and nowhere else. +_LINE_BREAK = re.compile(r"\r\n|[\n\r]") + + +def locations(error: GraphQLError) -> tuple: + """The ``locations`` graphql-js gives ``error``: each position's line and column, counting the + lines as graphql-js's ``getLocation`` does, where graphql-core's count misses a line that + starts at the position and adds one at each character Python's ``splitlines`` reads as a + break.""" + if error.source is None or not error.positions: + return () + body = error.source.body + out = [] + for position in error.positions: + line, start = 1, 0 + for m in _LINE_BREAK.finditer(body): + if m.start() >= position: + break + line, start = line + 1, m.end() + out.append({"line": line, "column": position + 1 - start}) + return tuple(out) + + +def did_you_mean(suggestions: list[str], sub_message: str = "") -> str: + """graphql-js's ``didYouMean``: up to five suggestions, each in double quotes.""" + if not suggestions: + return "" + quoted = [f'"{s}"' for s in suggestions[:5]] + message = " Did you mean " + (sub_message + " " if sub_message else "") + if len(quoted) == 1: + return message + quoted[0] + "?" + if len(quoted) == 2: + return message + quoted[0] + " or " + quoted[1] + "?" + return message + ", ".join(quoted[:-1]) + ", or " + quoted[-1] + "?" + + +# --- the messages graphql-core writes itself --------------------------------------------------- + +# A token or an escape sequence graphql-core quotes at the end of a syntax error: its text runs to +# the last quote, and may hold a quote of its own. +_SYNTAX_TAIL = re.compile( + r"((?:found|Unexpected) (?:Name|String|BlockString|Int|Float|Comment) |" + r"Invalid (?:Unicode|character) escape sequence: )'(.*)'\.$", + re.S, +) +# A character graphql-core quotes, at the end: graphql-js quotes it the same way but for `"`, +# which both quote as '"'. +_SYNTAX_CHAR = re.compile(r": '(.)'\.$", re.S) +_PUNCTUATOR = re.compile(r"'(\.\.\.|[!$&(){}\[\]:=@|])'") +# A keyword the parser expected, such as `on`. +_KEYWORD = re.compile(r"Expected '([_A-Za-z][_0-9A-Za-z]*)'") +# graphql-core names a directive's location by its value, `query` or `fragment definition`, where +# graphql-js names it by its name, `QUERY` or `FRAGMENT_DEFINITION`. +_DIRECTIVE_PLACE = re.compile(r"(may not be used on )(.+)(\.)$") +_PLACE_NAMES = {place.value: place.name for place in DirectiveLocation} +# What a validation or execution message quotes: a name, `$name`, `@name`, `Type.field`, a type +# such as `[Int!]!`, or the `name { ... }` a leaf-selection error suggests. +_QUOTED = re.compile( + r"'([$@]?[_A-Za-z][_0-9A-Za-z]*(?:\.[_A-Za-z][_0-9A-Za-z]*)?" + r"|[\[\]!_0-9A-Za-z]+" + r"|[_A-Za-z][_0-9A-Za-z]* \{ \.\.\. \})'" +) +# A GraphQL string literal ``print_ast`` wrote into a message, which may hold a quote. +_LITERAL = re.compile(r'"""(?:[^"\\]|\\.|"(?!""))*"""|"(?:[^"\\\n]|\\.)*"', re.S) + + +def requote(message: str) -> str: + """A syntax, validation or execution message graphql-core wrote, quoted as graphql-js quotes + it.""" + if message.startswith("Syntax Error: "): + m = _SYNTAX_TAIL.search(message) + if m: + message = message[: m.start(2) - 1] + '"' + m.group(2) + '".' + else: + m = _SYNTAX_CHAR.search(message) + if m: + message = ( + message[: m.start()] + f': "{m.group(1)}".' if m.group(1) != '"' else message + ) + return _KEYWORD.sub(r'Expected "\1"', _PUNCTUATOR.sub(r'"\1"', message)) + place = _DIRECTIVE_PLACE.search(message) + if place and place.group(2) in _PLACE_NAMES: + message = message[: place.start(2)] + _PLACE_NAMES[place.group(2)] + place.group(3) + parts = [] + last = 0 + tail = "" + for literal in _LITERAL.finditer(message): + parts.append(message[last : literal.start()]) + parts.append(literal) + last = literal.end() + parts.append(message[last:]) + if message.startswith("Expected value of type '"): + # A custom scalar's own refusal of a literal follows the `; ` graphql-js wraps it in, in + # the scalar's words: Linear's date scalars quote with `'` there (`kind 'IntValue'`). + for i, part in enumerate(parts): + if isinstance(part, str) and "; " in part: + head, rest = part.split("; ", 1) + parts[i] = head + "; " + tail = rest + "".join( + p if isinstance(p, str) else p.group() for p in parts[i + 1 :] + ) + parts = parts[: i + 1] + break + return ( + "".join(_QUOTED.sub(r'"\1"', p) if isinstance(p, str) else p.group() for p in parts) + tail + ) + + +# --- a variable's refusals, as graphql-js makes them ----------------------------------------- + + +def _type_name(type_: Any) -> str: + return str(type_) + + +def _builtin(type_: GraphQLScalarType, value: Any) -> Any: + """graphql-js's ``parseValue`` for a built-in scalar, or ``Undefined`` for any other scalar.""" + is_number = isinstance(value, (int, float)) and not isinstance(value, bool) + integral = is_number and ( + isinstance(value, int) or (math.isfinite(value) and value.is_integer()) + ) + name = type_.name + if name == "Int": + if not integral: + raise GraphQLError(f"Int cannot represent non-integer value: {inspect(value)}") + if not -(2**31) <= value <= 2**31 - 1: + raise GraphQLError( + f"Int cannot represent non 32-bit signed integer value: {number(value)}" + ) + return value + if name == "Float": + if not is_number or not math.isfinite(value): + raise GraphQLError(f"Float cannot represent non numeric value: {inspect(value)}") + return value + if name == "String": + if not isinstance(value, str): + raise GraphQLError(f"String cannot represent a non string value: {inspect(value)}") + return value + if name == "Boolean": + if not isinstance(value, bool): + raise GraphQLError(f"Boolean cannot represent a non boolean value: {inspect(value)}") + return value + if name == "ID": + if isinstance(value, str) or integral: + return value + raise GraphQLError(f"ID cannot represent value: {inspect(value)}") + return Undefined + + +def _enum(type_: GraphQLEnumType, value: Any) -> Any: + names = list(type_.values) + if not isinstance(value, str): + shown = inspect(value) + raise GraphQLError( + f'Enum "{type_.name}" cannot represent non-string value: {shown}.' + + did_you_mean(suggestion_list(shown, names), "the enum value") + ) + if value not in type_.values: + raise GraphQLError( + f'Value "{value}" does not exist in "{type_.name}" enum.' + + did_you_mean(suggestion_list(value, names), "the enum value") + ) + return value + + +OnError = Callable[[list, Any, GraphQLError], None] + + +def coerce_input_value(value: Any, type_: Any, on_error: OnError, path: tuple = ()) -> None: + """Report each refusal graphql-js's ``coerceInputValue`` makes of ``value`` as a ``type_``, to + ``on_error(path, invalid_value, error)``, in the order graphql-js makes them.""" + if isinstance(type_, GraphQLNonNull): + if value is not None: + coerce_input_value(value, type_.of_type, on_error, path) + return + on_error( + list(path), + value, + GraphQLError(f'Expected non-nullable type "{_type_name(type_)}" not to be null.'), + ) + return + if value is None: + return + if isinstance(type_, GraphQLList): + if isinstance(value, list): + for index, item in enumerate(value): + coerce_input_value(item, type_.of_type, on_error, (*path, index)) + else: + coerce_input_value(value, type_.of_type, on_error, path) + return + if isinstance(type_, GraphQLInputObjectType): + if not isinstance(value, dict): + on_error( + list(path), + value, + GraphQLError(f'Expected type "{type_.name}" to be an object.'), + ) + return + fields = type_.fields + for name, field in fields.items(): + if name not in value: + if field.default_value is Undefined and isinstance(field.type, GraphQLNonNull): + on_error( + list(path), + value, + GraphQLError( + f'Field "{name}" of required type "{_type_name(field.type)}" ' + "was not provided." + ), + ) + continue + coerce_input_value(value[name], field.type, on_error, (*path, name)) + for name in js_keys(value): + if name not in fields: + on_error( + list(path), + value, + GraphQLError( + f'Field "{name}" is not defined by type "{type_.name}".' + + did_you_mean(suggestion_list(name, list(fields))) + ), + ) + return + if isinstance(type_, GraphQLEnumType): + try: + _enum(type_, value) + except GraphQLError as error: + on_error(list(path), value, error) + return + try: + if _builtin(type_, value) is Undefined: + type_.parse_value(value) + except GraphQLError as error: + on_error(list(path), value, error) + except Exception as error: # a scalar's own refusal, as graphql-js wraps a thrown Error + on_error( + list(path), + value, + GraphQLError(f'Expected type "{type_.name}". {error}', original_error=error), + ) + + +def _print_path(path: list) -> str: + return "".join(f"[{key}]" if isinstance(key, int) else f".{key}" for key in path) + + +class _TooMany(Exception): + pass + + +def coerce_variable_values( + schema: GraphQLSchema, definitions: list[VariableDefinitionNode], inputs: Any +) -> list[GraphQLError]: + """The refusals graphql-js's ``getVariableValues`` makes of the variables ``inputs`` for an + operation declaring ``definitions``, as ``execute`` asks it: at most fifty, then one saying + so. An empty list is a request whose variables graphql-js coerces.""" + inputs = inputs if isinstance(inputs, dict) else {} + errors: list[GraphQLError] = [] + + def report(error: GraphQLError) -> None: + if len(errors) >= _MAX_ERRORS: + raise _TooMany + errors.append(error) + + try: + for definition in definitions: + name = definition.variable.name.value + type_ = type_from_ast(schema, definition.type) + if type_ is None or not is_input_type(type_): + report( + GraphQLError( + f'Variable "${name}" expected value of type ' + f'"{print_ast(definition.type)}" which cannot be used as an input type.', + definition.type, + ) + ) + continue + if name not in inputs: + if definition.default_value is None and isinstance(type_, GraphQLNonNull): + report( + GraphQLError( + f'Variable "${name}" of required type "{_type_name(type_)}" ' + "was not provided.", + definition, + ) + ) + continue + value = inputs[name] + if value is None and isinstance(type_, GraphQLNonNull): + report( + GraphQLError( + f'Variable "${name}" of non-null type "{_type_name(type_)}" ' + "must not be null.", + definition, + ) + ) + continue + + def on_error(path: list, invalid: Any, error: GraphQLError, name=name, node=definition): + prefix = f'Variable "${name}" got invalid value {inspect(invalid)}' + if path: + prefix += f' at "{name}{_print_path(path)}"' + report(GraphQLError(f"{prefix}; {error.message}", node)) + + coerce_input_value(value, type_, on_error) + except _TooMany: + errors.append( + GraphQLError( + "Too many errors processing variables, error limit reached. Execution aborted." + ) + ) + return errors diff --git a/backlot/graphql/linear_resolvers.py b/backlot/graphql/linear_resolvers.py index 1606b83c9..121c160a6 100644 --- a/backlot/graphql/linear_resolvers.py +++ b/backlot/graphql/linear_resolvers.py @@ -2015,13 +2015,18 @@ def resolve_issue_labels( RESOLVERS["IssueSearchResult"] = RESOLVERS["Issue"] +# The root types api.linear.app's introspection names beside `Query` (2026-10-25, UTC), none of +# whose fields Backlot serves. +ROOTS = ("Mutation", "Subscription") + + def build_engine(): """The Linear engine, over the SDL beside this module. ``SCALARS`` binds Linear's date-operand grammar onto ``DateTimeOrDuration`` / ``TimelessDateOrDuration``, so a malformed filter value is a validation error (a 400) exactly where the real API makes it one.""" from backlot.graphql import engine - return engine.from_sdl(__file__, "linear", RESOLVERS, SCALARS) + return engine.from_sdl(__file__, "linear", RESOLVERS, SCALARS, ROOTS) __all__ = ["RESOLVERS", "build_engine", "PAGE_DEFAULT", "PAGE_MAX"] diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index c45cbd5b7..0d7a715b3 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -26,10 +26,17 @@ from fastapi import APIRouter, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import JSONResponse, Response +from graphql import ( + DocumentNode, + FragmentDefinitionNode, + FragmentSpreadNode, + OperationDefinitionNode, +) from backlot import auth from backlot.config import get_settings from backlot.graphql import body as request_body +from backlot.graphql import engine from backlot.graphql.fireflies_resolvers import build_engine router = APIRouter(prefix="/fireflies", tags=["fireflies"]) @@ -131,28 +138,95 @@ async def graphql(request: Request): # scale measured) and almost none of them have a Fireflies account. See resolve_users. "roster": frozenset(state.acl.email_to_token()) if getattr(state, "acl", None) else None, } - result = ENGINE.execute_payload(payload, context=context) + result = ENGINE.execute_payload(payload, context=context, on_document=_fragments_unreached) request.state.graphql_raised = result.raised - # Fireflies' invalid_arguments and INTERNAL_SERVER_ERROR errors carry two more members than a - # field error, in this order (api.fireflies.ai: the refusal on 2026-10-21, the - # INTERNAL_SERVER_ERROR on 2026-10-22). - errors = result.payload.get("errors", []) - for i, error in enumerate(errors): - code = error.get("extensions", {}).get("code") - if code in _FRIENDLY: - errors[i] = { - "friendly": _FRIENDLY[code], - **{key: value for key, value in error.items() if key != "extensions"}, - "code": code, - "extensions": error["extensions"], - } return Response( - request_body.node_json(result.payload), - status_code=400 if result.request_error else 200, + request_body.node_json(_envelope(result)), + status_code=result.status, media_type="application/json", ) +def _entry(error: dict, code: str, friendly: bool, extensions: dict) -> dict: + """``error`` in Fireflies' members, in this order: ``friendly`` first, its own members, then + ``code`` and ``extensions``.""" + return { + "friendly": friendly, + **{key: value for key, value in error.items() if key != "extensions"}, + "code": code, + "extensions": extensions, + } + + +def _envelope(result: engine.Result) -> dict: + """``result`` as Fireflies writes it, as api.fireflies.ai answered on 2026-10-25 (UTC): each + request error, and each error graphql-js made as the operation ran, with ``friendly: false``, + Apollo Server's code beside the message and ``extensions`` empty. Its ``invalid_arguments`` and + ``INTERNAL_SERVER_ERROR`` field errors carry the extensions their resolver gave, the refusal as + real answered it on 2026-10-21 and the INTERNAL_SERVER_ERROR on 2026-10-22.""" + if result.refusals: + errors = [] + for refusal in result.refusals: + error: dict = {"message": refusal.message} + if refusal.locations: + error["locations"] = list(refusal.locations) + errors.append(_entry(error, refusal.code, False, {})) + return {"errors": errors} + errors = result.payload.get("errors") + if not errors: + return result.payload + shaped = [] + for i, error in enumerate(errors): + code = error.get("extensions", {}).get("code") + if i in result.assigned: + shaped.append(_entry(error, code, False, {})) + elif code in _FRIENDLY: + shaped.append(_entry(error, code, _FRIENDLY[code], error["extensions"])) + else: + shaped.append(error) + return {**result.payload, "errors": shaped} + + +def _fragments_unreached(doc: DocumentNode) -> engine.Result | None: + """Fireflies' 500 for a document whose operations reach a fragment it does not define, or a + fragment that reaches itself, ahead of validation: api.fireflies.ai answered ``{ ...F }``, a + spread of an undefined fragment inside an inline fragment, inside a fragment an operation uses + and inside an operation ``operationName`` did not choose, and ``fragment F on Query { ...F } + { ...F }``, with it on 2026-10-25 (UTC), beside another validation error too. The same spread + in a fragment no operation uses was the validation's ``Unknown fragment`` and the same cycle + its ``Cannot spread fragment``.""" + fragments = {d.name.value: d for d in doc.definitions if isinstance(d, FragmentDefinitionNode)} + done: set[str] = set() + + def reaches_a_fault(selections, path: tuple[str, ...]) -> bool: + for selection in selections: + if isinstance(selection, FragmentSpreadNode): + name = selection.name.value + if name not in fragments or name in path: + return True + if name not in done: + if reaches_a_fault(fragments[name].selection_set.selections, (*path, name)): + return True + done.add(name) + elif selection.selection_set is not None: + if reaches_a_fault(selection.selection_set.selections, path): + return True + return False + + for definition in doc.definitions: + if isinstance(definition, OperationDefinitionNode): + if reaches_a_fault(definition.selection_set.selections, ()): + return engine.Result( + payload={}, + refusals=( + engine.Refusal( + "Internal server error", engine.INTERNAL_SERVER_ERROR, status=500 + ), + ), + ) + return None + + def _read(raw: bytes) -> Any: """The payload Fireflies reads from the body ``raw``, or :func:`_body_refusal`'s refusal of it.""" diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index f11bed4ea..03e14025a 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -18,12 +18,17 @@ looked at, for any credential but an API key under ``Bearer``: with no credential, a body that is not JSON drew its 400 from api.linear.app on 2026-10-18, not the 401. :func:`_express` is that 400, and each refusal ``body-parser`` makes before it has the text -(:func:`backlot.graphql.body.body_parser_text`); :func:`_read` names the bodies answered in -graphql-js's words instead. An API key under ``Bearer`` is refused ahead of the body: with -``Bearer ``, ``{``, an empty body and a body holding ``NaN`` drew :func:`_api_key_as_bearer`'s -400 the same day, and ``charset=latin1``, ``Content-Encoding: bogus`` and a body past the size -limit did on 2026-10-19, when a key in an API key's shape that Linear never issued drew it under -``Bearer`` with ``{``, ``NaN``, ``5``, ``charset=latin1`` and an empty body. +(:func:`backlot.graphql.body.body_parser_text`). Apollo Server's checks come ahead of the credential +too, up to the choice of operation (:meth:`backlot.graphql.engine.Engine.resolve_payload`): with no +credential and with ``nope``, an empty body, ``{}``, an array, a ``query`` of ``5`` or ``null``, a +string ``variables``, an ``extensions`` or ``operationName`` of ``5``, a persisted query's hash +alone or one that does not match, a document that does not parse or validate and one of two +operations with no ``operationName`` drew what the key drew on 2026-10-25 (UTC), and a document +missing a variable the 401. An API key under ``Bearer`` is refused ahead of the body: with +``Bearer ``, ``{``, an empty body and a body holding ``NaN`` drew +:func:`_api_key_as_bearer`'s 400 on 2026-10-18, and ``charset=latin1``, ``Content-Encoding: bogus`` +and a body past the size limit did on 2026-10-19, when a key in an API key's shape that Linear never +issued drew it under ``Bearer`` with ``{``, ``NaN``, ``5``, ``charset=latin1`` and an empty body. """ from __future__ import annotations @@ -42,6 +47,9 @@ router = APIRouter(prefix="/linear", tags=["linear"]) +# Linear's code for a body with no query, where Apollo Server's is `BAD_REQUEST`. +_INVALID_INPUT = "INVALID_INPUT" + # Built once at import: the SDL is parsed and validated and the resolver map bound, so a broken # schema fails at startup instead of on the first request. ENGINE = build_engine() @@ -112,6 +120,9 @@ async def graphql(request: Request): return payload # The telemetry middleware names the call by this document (backlot.telemetry.naming). request.state.graphql_payload = payload + resolved = ENGINE.resolve_payload(_variables_read(payload)) + if isinstance(resolved, engine.Result): + return _answer(request, resolved, payload) caller = auth.resolve_api_key(request) if caller is None: # Real Linear answers a bad credential with a GraphQL error envelope and a 401, not a @@ -144,21 +155,130 @@ async def graphql(request: Request): # entities with no table of their own through `linear_entities` # (store.linear_entity_by_id, called by the resolvers' own `_by_id`). } - result = ENGINE.execute_payload(payload, context=context) + return _answer(request, ENGINE.run(resolved, context=context), payload) + + +def _answer(request: Request, result: engine.Result, payload: Any) -> Response: request.state.graphql_raised = result.raised return Response( - request_body.node_json(result.payload), - status_code=400 if result.request_error else 200, + request_body.node_json(_envelope(result, payload)), + status_code=result.status, media_type="application/json", ) +def _variables_read(payload: Any) -> Any: + """``payload`` with a string ``variables`` read as JSON where ``JSON.parse`` reads it: Linear + served ``{"variables": "{}"}``, ``" {} "``, ``"null"`` and a string holding an object its + operation used on 2026-10-25 (UTC), and answered one ``JSON.parse`` refuses (``"{"``, + ``""``, ``"NaN"``) or reads as a string with Apollo Server's refusal of a string, and one it + reads as an array or a number with the refusal of a value that is not an object.""" + if isinstance(payload, dict) and isinstance(payload.get("variables"), str): + try: + return {**payload, "variables": request_body.parse(payload["variables"])} + except request_body.NotJson: + pass + return payload + + +# What Linear's errors carry beside `code`, by code, as api.linear.app answered on 2026-10-25 (UTC): +# Apollo Server's `http` member on each request error but a variable's refusal, `type` and +# `userError`. +_TYPE = {engine.INTERNAL_SERVER_ERROR: "internal error", _INVALID_INPUT: "invalid input"} + + +def _apollo(message: str, code: str, status: int, locations: Any = ()) -> dict: + entry: dict = {"message": message} + if locations: + entry["locations"] = list(locations) + extensions: dict = ( + {} if code == engine.BAD_USER_INPUT else {"http": {"status": status, "headers": {}}} + ) + extensions["code"] = code + extensions["type"] = _TYPE.get(code, "graphql error") + extensions["userError"] = code != engine.INTERNAL_SERVER_ERROR + entry["extensions"] = extensions + return entry + + +def _internal(error: dict) -> dict: + """Linear's ``Internal server error`` in place of an error graphql-js made as the operation + ran, at the error's ``path`` and ``locations``, as api.linear.app answered a ``null`` variable + a non-null argument refused on 2026-10-21 (UTC).""" + entry: dict = {"message": "Internal server error"} + for key in ("path", "locations"): + if key in error: + entry[key] = error[key] + entry["extensions"] = { + "code": engine.INTERNAL_SERVER_ERROR, + "type": "internal error", + "userError": False, + } + return entry + + +def _operation_missing(name: Any) -> dict: + """Linear's own refusal of a document whose operation cannot be chosen: two operations and no + ``operationName``, or a name no operation has, as api.linear.app answered on 2026-10-25 (UTC); + an empty name is answered as no name.""" + presentable = ( + f"The operation '{name}' does not exist on the query." + if name + else "The operation does not exist on the query." + ) + return { + "message": "operation does not exist", + "extensions": { + "type": "invalid input", + "code": "INPUT_ERROR", + "statusCode": 400, + "userError": True, + "userPresentableMessage": presentable, + "http": {"status": 400}, + }, + } + + +def _envelope(result: engine.Result, payload: Any) -> dict: + """``result`` as Linear writes it. A request error is Apollo Server's in Linear's members + (:func:`_apollo`): a body with no query carries Linear's ``INVALID_INPUT``, a refused persisted + query Linear's ``Internal server error``, and an operation that cannot be chosen Linear's own + refusal (:func:`_operation_missing`). An error graphql-js made as the operation ran is + :func:`_internal`.""" + if result.refusals: + errors = [] + for refusal in result.refusals: + if refusal.code == engine.OPERATION_RESOLUTION_FAILURE: + errors.append(_operation_missing(payload.get("operationName"))) + elif refusal.message in ( + engine.PERSISTED_QUERY_VERSION, + engine.PERSISTED_QUERY_MISMATCH, + ): + errors.append( + _apollo("Internal server error", engine.INTERNAL_SERVER_ERROR, refusal.status) + ) + elif refusal.message == engine.NO_QUERY: + errors.append(_apollo(refusal.message, _INVALID_INPUT, refusal.status)) + else: + errors.append( + _apollo(refusal.message, refusal.code, refusal.status, refusal.locations) + ) + return {"errors": errors} + if not result.assigned: + return result.payload + errors = [ + _internal(error) if i in result.assigned else error + for i, error in enumerate(result.payload["errors"]) + ] + return {**result.payload, "errors": errors} + + def _read(raw: bytes, content_type: str | None, content_encoding: str) -> Any: """The payload Linear reads from the body ``raw``, or the response this router refuses it with. - A body with no text once read keeps graphql-js's words here, after the ``Bearer`` check and - before the key is resolved, as Linear's refusal comes: Linear answers it with Apollo Server's - ``POST body missing, invalid Content-Type, or JSON object has no keys.``""" + A body with no text once read is read as an empty object, which Apollo Server refuses as it + refuses one sent: Linear answered both with ``POST body missing, invalid Content-Type, or JSON + object has no keys.`` on 2026-10-25 (UTC).""" try: text = request_body.body_parser_text(raw, content_type, content_encoding) except request_body.Unread as unread: @@ -166,9 +286,9 @@ def _read(raw: bytes, content_type: str | None, content_encoding: str) -> Any: violation = request_body.strict_violation(text) if text else None if violation is not None: return _express(violation) + if not text: + return {} try: return request_body.parse(text) except request_body.NotJson: - if not text: - return JSONResponse(engine.not_json().payload, status_code=400) return _express(v8_json.error(text)) diff --git a/backlot/telemetry/errors.py b/backlot/telemetry/errors.py index dd15606dc..81f4ce71b 100644 --- a/backlot/telemetry/errors.py +++ b/backlot/telemetry/errors.py @@ -2,13 +2,12 @@ An error code is the vendor's own identifier where its envelope has one: Slack's ``error``, S3's ````, Google's ``reason`` or else its ``status``, Notion's ``code``, HubSpot's ``category``, -Microsoft Graph's ``error.code``, a GraphQL ``extensions.code``. GitHub, Jira, Confluence, the -GraphQL engine and Graph's OData validator answer with a message only; those messages are Backlot's -own text, so each maps to a fixed slug here and the message itself, which can carry a key, a sha or -a field name from the request, is never sent. A message this table does not know is -``unrecognized``, except at 400 from Linear or Fireflies: the GraphQL engine answers 400 only for a -request it refuses before any resolver runs, and the refusals no row names are graphql-core's -validation of the document and its variables, so they are ``graphql_validation``. +Microsoft Graph's ``error.code``, a GraphQL error's ``extensions.code``, or the ``code`` Fireflies +writes beside the message. GitHub, Jira, Confluence, Graph's OData validator, Linear's 401 and the +Linear resolvers' refusals that carry no code answer with a message only; those messages are +Backlot's own text, so each maps to a fixed slug here and the message itself, which can carry a +key, a sha or a field name from the request, is never sent. A message this table does not know is +``unrecognized``. The HTTP layer in front of Linear's and Fireflies' GraphQL refuses a body in an envelope of its own, with no ``errors``. Fireflies' HTTP layer names its code in ``code``. Linear's is Express, which @@ -21,7 +20,7 @@ | Class | The call failed because | |---|---| | ``unsupported`` | Backlot does not do this yet: the baseline records the operation as ``missing`` or ``refused``, or the GraphQL engine refused a filter | -| ``internal`` | of a Backlot bug: Backlot's own failure (see below), or a 5xx other than 501 | +| ``internal`` | of a Backlot bug: its own failure, or a 5xx other than a vendor's answer (below) | | ``rate_limit`` | the vendor's rate limit was reached: a 429, or a 403 with ``x-ratelimit-remaining: 0`` as GitHub sends | | ``auth`` | the credential was missing, wrong, or not allowed: a 401 or 403, or Slack's auth errors, which it sends with a 200 | | ``client`` | the request was wrong: any other failure | @@ -31,7 +30,7 @@ the failure is Backlot's own, the status, the baseline's support state, the error code and the ``x-ratelimit-remaining`` header, so a code no rule names is classed by its status. A 501 from an operation Backlot serves is the vendor's own answer (Google's ``notImplemented``), so it is -``client``. +``client``, and so is each 5xx in ``_ANSWERED_5XX``, a vendor's answer to a wrong request. """ from __future__ import annotations @@ -93,20 +92,9 @@ (r"Cannot find custom content type", "content_type_not_found"), ] _GRAPHQL = [ - (r"^Syntax Error", "graphql_parse"), (r"^Entity not found", "entity_not_found"), (r"^unsupported ", "unsupported_filter"), (r"^Authentication required", "authentication_required"), - ( - r"^(Must provide |Variables are invalid JSON|POST body sent invalid JSON|Unknown operation named )", - "graphql_request", - ), - ( - r"^(Cannot query field|Unknown argument|Unknown type|Unknown fragment|Unknown directive|Field " - r"|Variable |Argument |Schema is not configured|There can be only one operation|Expected value" - r"|String cannot represent)", - "graphql_validation", - ), ] # Graph's OData validator answers with an empty `error.code`; its message names the option. _MSGRAPH = [(r"^The query specified in the URI is not valid\.", "invalid_query_option")] @@ -152,10 +140,11 @@ def error_code(source: str, status: int, body: bytes) -> str: return "" extensions = errors[0].get("extensions") code = extensions.get("code") if isinstance(extensions, dict) else None + if not code: + code = errors[0].get("code") if isinstance(code, str) and code: return code - slug = _slug(errors[0].get("message"), _GRAPHQL) - return "graphql_validation" if slug == "unrecognized" and status == 400 else slug + return _slug(errors[0].get("message"), _GRAPHQL) if status < 400: return "" if source == "github": @@ -193,6 +182,10 @@ def error_code(source: str, status: int, body: bytes) -> str: # Slack sends its auth errors with a 200. _SLACK_AUTH = frozenset({"account_inactive", "invalid_auth", "not_authed"}) +# Each 5xx a vendor answers a wrong request with, by source and error code, and Backlot answers too: +# Fireflies' 500 for a document that reaches a fragment it does not define +# (`backlot.routers.fireflies._fragments_unreached`). +_ANSWERED_5XX = frozenset({("fireflies", "INTERNAL_SERVER_ERROR")}) def error_class( @@ -208,6 +201,6 @@ def error_class( return "rate_limit" if status in (401, 403) or (source == "slack" and code in _SLACK_AUTH): return "auth" - if status >= 500 and status != 501: + if status >= 500 and status != 501 and (source, code) not in _ANSWERED_5XX: return "internal" return "client" diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index 04b510024..562a345d7 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -420,7 +420,7 @@ def recording(*args): assert where == ["a worker thread"] -# Half a surrogate pair where graphql-core quotes the client's text without escaping it: the `\u` +# Half a surrogate pair where a message quotes the client's text without escaping it: the `\u` # escape sent, the text the message names, and the bytes that text is on the wire. A pair is the # control, the one character it makes. _ISSUE_FILTER = "query($f: IssueFilter) { issues(filter: $f) { nodes { id } } }" @@ -479,10 +479,13 @@ def test_a_graphql_vendor_writes_a_lone_surrogate_as_an_escape( admin = tokens_yaml["admin_token"] if where == "key": body = '{"query":"%s","variables":{"f":{"%s":1}}}' % (_ISSUE_FILTER, sent) - quoted = f"'{named}' is not defined by type" + quoted, member = f'"{named}" is not defined by type', "message" + elif vendor == "linear": + body = '{"query":"%s","operationName":"%s"}' % (_TWO_OPERATIONS, sent) + quoted, member = f"The operation '{named}' does not", "userPresentableMessage" else: body = '{"query":"%s","operationName":"%s"}' % (_TWO_OPERATIONS, sent) - quoted = f"operation named '{named}'" + quoted, member = f'operation named "{named}"', "message" credential = admin if vendor == "linear" else f"Bearer {admin}" r = client.post( f"/{vendor}/graphql", @@ -490,7 +493,8 @@ def test_a_graphql_vendor_writes_a_lone_surrogate_as_an_escape( headers={"Authorization": credential, "Content-Type": "application/json"}, ) assert r.status_code == 400 - assert quoted in r.json()["errors"][0]["message"] + error = r.json()["errors"][0] + assert quoted in error.get("extensions", {}).get(member, error.get(member)) assert wire in r.content diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index 0c98c5db9..cd1b32db0 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -118,10 +118,31 @@ def test_fireflies_reads_a_credential_as_fireflies_does(client, tokens_yaml, val # The headers a body was sent with beyond `Content-Type: application/json`, the body, and Fireflies' # answer to it with the key: `_EMPTY`, `_NOT_JSON` or `_TOO_LARGE`, its HTTP layer's refusals # `backlot.routers.fireflies._body_refusal` sends at the status their `statusCode` names, or None -# where it served the query. `_REQUEST_ERROR` is a row of JSON that is not an object: past that -# layer it reaches the credential, one of `_auth_failed`'s 500s without the key, and with the key -# it is a 400, in Apollo Server's words on real and graphql-js's here. -_REQUEST_ERROR = object() +# where it served the query. A `_WithTheKey` row is a body that layer passes, answered past the +# credential: one of `_auth_failed`'s 500s without the key. Those rows are as api.fireflies.ai +# answered them on 2026-10-25 (UTC), less the newline it ends each with. +class _WithTheKey(bytes): + """Fireflies' answer with the key, at ``status``, to a body Apollo Server refuses or runs.""" + + def __new__(cls, answer: bytes, status: int = 400): + self = super().__new__(cls, answer) + self.status = status + return self + + +def _apollo( + message: bytes, code: bytes = b"BAD_REQUEST", locations: bytes = b"", status: int = 400 +) -> _WithTheKey: + """Apollo Server's refusal as Fireflies writes it around ``message``, already JSON-escaped.""" + return _WithTheKey( + b'{"errors":[{"friendly":false,"message":"%s",%s"code":"%s","extensions":{}}]}' + % (message, locations, code), + status, + ) + + +_NO_KEYS = _apollo(b"POST body missing, invalid Content-Type, or JSON object has no keys.") +_BATCHING = _apollo(b"Operation batching disabled.") _EMPTY = ( b'{"statusCode":400,"status":false,"code":"http","message":"Body cannot be empty when' b' content-type is set to \'application/json\'","errorMessage":"Body cannot be empty when' @@ -300,11 +321,173 @@ def test_fireflies_reads_a_credential_as_fireflies_does(client, tokens_yaml, val pytest.param(b"\xef\xbb\xbf", _NOT_JSON, id="BOM only"), pytest.param(b'{"x":Infinity,"query":"{ __typename }"}', _NOT_JSON, id="Infinity"), pytest.param(b'{"x":-Infinity,"query":"{ __typename }"}', _NOT_JSON, id="-Infinity"), - pytest.param(b"[]", _REQUEST_ERROR, id="an array"), - pytest.param(b"null", _REQUEST_ERROR, id="null"), - pytest.param(b'"x"', _REQUEST_ERROR, id="a string"), - pytest.param(b"5", _REQUEST_ERROR, id="a number"), - pytest.param(b'[{"query":"{ __typename }"}]', _REQUEST_ERROR, id="a batch of one query"), + pytest.param(b"[]", _BATCHING, id="an array"), + pytest.param(b"null", _NO_KEYS, id="null"), + pytest.param(b'"x"', _NO_KEYS, id="a string"), + pytest.param(b"5", _NO_KEYS, id="a number"), + pytest.param(b"true", _NO_KEYS, id="true"), + pytest.param(b"{}", _NO_KEYS, id="an empty object"), + pytest.param(b'[{"query":"{ __typename }"}]', _BATCHING, id="a batch of one query"), + pytest.param(b'{"query":5}', _apollo(b"GraphQL queries must be strings."), id="a query of 5"), + pytest.param( + b'{"query":"{ __typename }","variables":"x"}', + _apollo( + b"`variables` in a POST body should be provided as an object, not a recursively " + b"JSON-encoded string." + ), + id="a string of variables", + ), + pytest.param( + b'{"query":"{ __typename }","extensions":5}', + _apollo(b"`extensions` in a POST body must be an object if provided."), + id="extensions of 5", + ), + pytest.param( + b'{"query":"{ __typename }","operationName":5}', + _apollo(b"`operationName` in a POST body must be a string if provided."), + id="an operationName of 5", + ), + pytest.param( + b'{"query":"{ __typename }","extensions":{"persistedQuery":{"version":1,"sha256Hash":"' + + b"0" * 64 + + b'"}}}', + _apollo(b"provided sha does not match query", b"INTERNAL_SERVER_ERROR"), + id="a persisted query's hash that does not match", + ), + pytest.param( + b'{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"' + b"1" * 64 + b'"}}}', + _apollo(b"PersistedQueryNotFound", b"PERSISTED_QUERY_NOT_FOUND", status=200), + id="a persisted query's hash alone", + ), + pytest.param( + b'{"query":null}', + _apollo( + b"GraphQL operations must contain a non-empty `query` or a `persistedQuery` extension." + ), + id="a query of null", + ), + pytest.param( + b'{"query":"{"}', + _apollo( + b"Syntax Error: Expected Name, found .", + b"GRAPHQL_PARSE_FAILED", + b'"locations":[{"line":1,"column":2}],', + ), + id="a document that does not parse", + ), + pytest.param( + b'{"query":"{ nosuch }"}', + _apollo( + b'Cannot query field \\"nosuch\\" on type \\"Query\\".', + b"GRAPHQL_VALIDATION_FAILED", + b'"locations":[{"line":1,"column":3}],', + ), + id="a document that does not validate", + ), + pytest.param( + b'{"query":"query A { __typename } query B { __typename }"}', + _apollo( + b"Must provide operation name if query contains multiple operations.", + b"OPERATION_RESOLUTION_FAILURE", + ), + id="two operations and no operationName", + ), + pytest.param( + b'{"query":"query($b: Boolean!) { __typename @include(if: $b) }"}', + _apollo( + b'Variable \\"$b\\" of required type \\"Boolean!\\" was not provided.', + b"BAD_USER_INPUT", + b'"locations":[{"line":1,"column":7}],', + ), + id="a variable not provided", + ), + pytest.param( + b'{"query":"{ __typename }","operationName":"Z"}', + _WithTheKey( + b'{"errors":[{"friendly":false,"message":"Unknown operation named \\"Z\\".","code":"OPERATION_RESOLUTION_FAILURE","extensions":{}}]}' + ), + id="an operationName no operation has", + ), + pytest.param( + b'{"query":"query A { __typename }","operationName":""}', + _WithTheKey( + b'{"errors":[{"friendly":false,"message":"Unknown operation named \\"\\".","code":"OPERATION_RESOLUTION_FAILURE","extensions":{}}]}' + ), + id="an empty operationName", + ), + pytest.param( + b'{"query":{"kind":"Document"}}', + _WithTheKey( + b'{"errors":[{"friendly":false,"message":"GraphQL queries must be strings. It looks like you\'re sending the internal graphql-js representation of a parsed query in your request instead of a request in the GraphQL query language. You can convert an AST to a string using the `print` function from `graphql`, or use a client like `apollo-client` which converts the internal representation to a string for you.","code":"BAD_REQUEST","extensions":{}}]}' + ), + id="a parsed document", + ), + pytest.param( + b'{"query":false}', + _WithTheKey( + b'{"errors":[{"friendly":false,"message":"GraphQL operations must contain a non-empty `query` or a `persistedQuery` extension.","code":"BAD_REQUEST","extensions":{}}]}' + ), + id="a query of false", + ), + pytest.param( + b'{"query":"{ __typename }","variables":"null"}', + _WithTheKey( + b'{"errors":[{"friendly":false,"message":"`variables` in a POST body should be provided as an object, not a recursively JSON-encoded string.","code":"BAD_REQUEST","extensions":{}}]}' + ), + id="variables of 'null'", + ), + pytest.param( + b'{"query":"query($b: Boolean!) { __typename @include(if: $b) }","variables":"{\\"b\\":true}"}', + _WithTheKey( + b'{"errors":[{"friendly":false,"message":"`variables` in a POST body should be provided as an object, not a recursively JSON-encoded string.","code":"BAD_REQUEST","extensions":{}}]}' + ), + id="variables of a string its operation takes", + ), + pytest.param( + b'{"query":"mutation { __typename }"}', + _WithTheKey(b'{"data":{"__typename":"Mutation"}}', 200), + id="a mutation of __typename", + ), + pytest.param( + b'{"query":"subscription S { __typename }"}', + _WithTheKey( + b'{"errors":[{"friendly":false,"message":"Schema is not configured to execute subscription operation.","locations":[{"line":1,"column":1}],"code":"INTERNAL_SERVER_ERROR","extensions":{}}],"data":null}', + 200, + ), + id="a subscription of __typename", + ), + pytest.param( + b'{"query":"query($x: String = \\"a\\") { transcript(id: $x) { id } }","variables":{"x":null}}', + _WithTheKey( + b'{"errors":[{"friendly":false,"message":"Argument \\"id\\" of non-null type \\"String!\\" must not be null.","locations":[{"line":1,"column":42}],"path":["transcript"],"code":"INTERNAL_SERVER_ERROR","extensions":{}}],"data":{"transcript":null}}', + 200, + ), + id="null for a non-null argument", + ), + pytest.param( + b'{"query":"query($v: Boolean = true) { __typename @include(if: $v) }","variables":{"v":null}}', + _WithTheKey( + b'{"errors":[{"friendly":false,"message":"Argument \\"if\\" of non-null type \\"Boolean!\\" must not be null.","locations":[{"line":1,"column":53}],"code":"INTERNAL_SERVER_ERROR","extensions":{}}],"data":null}', + 200, + ), + id="null for a directive's argument at the root", + ), + pytest.param( + b'{"query":"{ ...F }"}', + _WithTheKey( + b'{"errors":[{"friendly":false,"message":"Internal server error","code":"INTERNAL_SERVER_ERROR","extensions":{}}]}', + 500, + ), + id="a fragment not defined", + ), + pytest.param( + b'{"query":"fragment F on Query { ...F } { ...F }"}', + _WithTheKey( + b'{"errors":[{"friendly":false,"message":"Internal server error","code":"INTERNAL_SERVER_ERROR","extensions":{}}]}', + 500, + ), + id="a fragment that spreads itself", + ), ] @@ -328,13 +511,12 @@ def test_fireflies_reads_the_body_before_the_credential( key = admin_h["Authorization"] if credential == "key" else credential headers = (*headers, ("Authorization", key)) r = client.post("/fireflies/graphql", content=body, headers=list(headers)) - if answer is not None and answer is not _REQUEST_ERROR: + if answer is not None and not isinstance(answer, _WithTheKey): assert (r.status_code, r.content) == (json.loads(answer)["statusCode"], answer) elif credential != "key": assert (r.status_code, r.content) == (500, _BODY_CREDENTIALS[credential]) - elif answer is _REQUEST_ERROR: - assert r.json() == {"errors": [{"message": "POST body sent invalid JSON."}]} - assert r.status_code == 400 + elif answer is not None: + assert (r.status_code, r.content) == (answer.status, answer) else: assert (r.status_code, r.json()) == (200, {"data": {"__typename": "Query"}}) diff --git a/tests/test_graphql.py b/tests/test_graphql.py index c4ae65b74..e70c2cca5 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -10,6 +10,7 @@ import base64 import gzip +import hashlib import json import math import random @@ -180,10 +181,9 @@ def test_unknown_field_returns_a_validation_error(gql): r = gql.execute('{ widget(id: "w1") { nope } }') assert r.request_error is True assert "data" not in r.payload - # graphql-core quotes identifiers with '' where graphql-js uses ""; assert the substance, - # which is the part a client acts on. - msg = r.payload["errors"][0]["message"] - assert msg.startswith("Cannot query field") and "nope" in msg and "Widget" in msg + assert r.payload["errors"][0]["message"] == ( + 'Cannot query field "nope" on type "Widget". Did you mean "name"?' + ) def test_resolver_error_nulls_the_field_and_reports_its_path(gql): @@ -381,9 +381,10 @@ def test_a_body_is_read_as_node_reads_it(gql, body, served): { "errors": [ { - "message": "Variable '$x' got invalid value 1.5; " + "message": 'Variable "$x" got invalid value 1.5; ' "ID cannot represent value: 1.5", "locations": [{"line": 1, "column": 7}], + "extensions": {"code": "BAD_USER_INPUT"}, } ] }, @@ -405,9 +406,10 @@ def test_a_body_is_read_as_node_reads_it(gql, body, served): { "errors": [ { - "message": "Variable '$x' got invalid value {'nope': 1e+21}; " - "Field 'nope' is not defined by type 'F'.", + "message": 'Variable "$x" got invalid value { nope: 1e+21 }; ' + 'Field "nope" is not defined by type "F".', "locations": [{"line": 1, "column": 7}], + "extensions": {"code": "BAD_USER_INPUT"}, } ] }, @@ -419,9 +421,10 @@ def test_a_body_is_read_as_node_reads_it(gql, body, served): { "errors": [ { - "message": "Variable '$x' got invalid value 5; " - "Expected type 'F' to be a mapping.", + "message": 'Variable "$x" got invalid value 5; ' + 'Expected type "F" to be an object.', "locations": [{"line": 1, "column": 7}], + "extensions": {"code": "BAD_USER_INPUT"}, } ] }, @@ -801,16 +804,181 @@ def test_a_body_with_no_byte_order_mark_is_read_in_the_order_iconv_lite_picks( assert request_body._byte_order(bytes.fromhex(hex_bytes), width) == order -def test_execute_payload_requires_a_query_string(gql): - r = gql.execute_payload({"variables": {}}) - assert r.request_error is True - assert r.payload["errors"][0]["message"] == "Must provide query string." +_TYPENAME = "{ __typename }" +_HASH = hashlib.sha256(_TYPENAME.encode()).hexdigest() + + +def _persisted(version=1, sha256=_HASH, **payload) -> dict: + return {**payload, "extensions": {"persistedQuery": {"version": version, "sha256Hash": sha256}}} + + +def _refused(message, code="BAD_REQUEST", status=400, locations=()) -> tuple: + """Apollo Server's refusal ``message``: its status and body, each location (line, column).""" + error: dict = {"message": message} + if locations: + error["locations"] = [{"line": line, "column": column} for line, column in locations] + error["extensions"] = {"code": code} + return status, {"errors": [error]} + + +_SERVED = (200, {"data": {"__typename": "Query"}}) +_NOT_A_STRING = _refused("GraphQL queries must be strings.") +_NO_KEYS = _refused(engine.NO_KEYS) +_NO_QUERY = _refused(engine.NO_QUERY) +_MISMATCH = _refused("provided sha does not match query", "INTERNAL_SERVER_ERROR") +_VERSION = _refused("Unsupported persisted query version", "INTERNAL_SERVER_ERROR") +_NOT_FOUND = _refused("PersistedQueryNotFound", "PERSISTED_QUERY_NOT_FOUND", 200) + + +# A body, and Apollo Server's answer to it in Apollo Server's own envelope: a refusal with the +# message, code, locations and status api.fireflies.ai answered it with on 2026-10-25 (UTC), or +# `_SERVED` where it ran the query. Where two checks apply, the row's answer is the one that came +# first. +_APOLLO_CHECKS = [ + pytest.param([{"query": _TYPENAME}], _refused("Operation batching disabled."), id="a batch"), + pytest.param({}, _NO_KEYS, id="an empty object"), + pytest.param(None, _NO_KEYS, id="null"), + pytest.param(5, _NO_KEYS, id="a number"), + pytest.param({"query": 5, "variables": [1]}, _NOT_A_STRING, id="query 5, variables [1]"), + pytest.param({"query": 5, "extensions": 5}, _NOT_A_STRING, id="query 5, extensions 5"), + pytest.param({"query": 5, "variables": "{"}, _NOT_A_STRING, id="query 5, variables '{'"), + pytest.param( + {"query": {"kind": "Document"}}, + _refused( + "GraphQL queries must be strings. It looks like you're sending the internal graphql-js representation " + "of a parsed query in your request instead of a request in the GraphQL query language. " + "You can convert an AST to a string using the `print` function from `graphql`, or use " + "a client like `apollo-client` which converts the internal representation to a string " + "for you." + ), + id="a parsed document", + ), + pytest.param( + {"query": _TYPENAME, "variables": "x", "extensions": 5}, + _refused(engine.RECURSIVELY_ENCODED.format("variables")), + id="variables 'x', extensions 5", + ), + pytest.param( + {"query": _TYPENAME, "extensions": "x"}, + _refused(engine.RECURSIVELY_ENCODED.format("extensions")), + id="extensions 'x'", + ), + pytest.param( + {"query": _TYPENAME, "extensions": 5, "variables": [1]}, + _refused("`extensions` in a POST body must be an object if provided."), + id="extensions 5, variables [1]", + ), + pytest.param( + {"query": _TYPENAME, "variables": [1], "operationName": 1}, + _refused("`variables` in a POST body must be an object if provided."), + id="variables [1], operationName 1", + ), + pytest.param( + {"query": None, "operationName": 1}, + _refused("`operationName` in a POST body must be a string if provided."), + id="query null, operationName 1", + ), + pytest.param({"variables": {}}, _NO_QUERY, id="no query"), + pytest.param({"query": ""}, _NO_QUERY, id="an empty query"), + pytest.param({"extensions": {"persistedQuery": False}}, _NO_QUERY, id="pq false"), + pytest.param( + {"query": " "}, + _refused("Syntax Error: Unexpected .", "GRAPHQL_PARSE_FAILED", locations=[(1, 4)]), + id="a query of spaces", + ), + pytest.param(_persisted(2, query=5), _NOT_A_STRING, id="pq version 2, query 5"), + pytest.param(_persisted(2, query="{"), _VERSION, id="pq version 2, a query that cannot parse"), + pytest.param( + _persisted(sha256="0" * 64, query="{"), + _MISMATCH, + id="pq mismatch, a query that cannot parse", + ), + pytest.param({"extensions": {"persistedQuery": 5}}, _VERSION, id="pq 5"), + pytest.param(_persisted("1", query=_TYPENAME), _VERSION, id="pq version '1'"), + pytest.param( + {"extensions": {"persistedQuery": {"version": 1}}}, + _NOT_FOUND, + id="pq with no hash", + ), + pytest.param( + {"query": _TYPENAME, "extensions": {"persistedQuery": {"version": 1}}}, + _MISMATCH, + id="pq with no hash, a query", + ), + pytest.param(_persisted(sha256=_HASH.upper(), query=_TYPENAME), _MISMATCH, id="pq upper hash"), + pytest.param(_persisted(sha256=5, query=_TYPENAME), _MISMATCH, id="pq hash 5"), + pytest.param(_persisted(query=""), _MISMATCH, id="pq, an empty query"), + pytest.param( + _persisted(query=None), + _NOT_FOUND, + id="pq, query null", + ), + pytest.param(_persisted(query=_TYPENAME), _SERVED, id="pq matching its query"), + pytest.param( + _persisted( + sha256=hashlib.sha256(b"{ __typename } # \xef\xbf\xbd").hexdigest(), + query="{ __typename } # \ud800", + ), + _refused( + "Syntax Error: Invalid character: U+D800.", "GRAPHQL_PARSE_FAILED", locations=[(1, 18)] + ), + id="pq hashed with U+FFFD for a lone surrogate", + ), + pytest.param( + _persisted( + sha256=hashlib.sha256(b"{ __typename } # \xed\xa0\x80").hexdigest(), + query="{ __typename } # \ud800", + ), + _MISMATCH, + id="pq hashed with a lone surrogate's own bytes", + ), + pytest.param( + {"query": _TYPENAME, "extensions": {"persistedQuery": None}}, _SERVED, id="pq null" + ), + pytest.param( + {"query": "{", "operationName": "Z"}, + _refused( + "Syntax Error: Expected Name, found .", "GRAPHQL_PARSE_FAILED", locations=[(1, 2)] + ), + id="a query that cannot parse, operationName Z", + ), + pytest.param( + {"query": "query A { nosuch }", "operationName": "Z"}, + _refused( + 'Cannot query field "nosuch" on type "Query".', + "GRAPHQL_VALIDATION_FAILED", + locations=[(1, 11)], + ), + id="a query that does not validate, operationName Z", + ), + pytest.param( + {"query": "query A($b: Boolean!) { __typename @include(if: $b) } query B { __typename }"}, + _refused( + "Must provide operation name if query contains multiple operations.", + engine.OPERATION_RESOLUTION_FAILURE, + ), + id="two operations, a variable not provided", + ), + pytest.param( + {"query": "query A($b: Boolean!) { __typename @include(if: $b) }", "operationName": "Z"}, + _refused('Unknown operation named "Z".', engine.OPERATION_RESOLUTION_FAILURE), + id="a variable not provided, operationName Z", + ), + pytest.param( + {"query": "query A { __typename }", "operationName": ""}, + _refused('Unknown operation named "".', engine.OPERATION_RESOLUTION_FAILURE), + id="operationName ''", + ), + pytest.param({"query": _TYPENAME, "variables": None}, _SERVED, id="variables null"), + pytest.param({"query": _TYPENAME, "operationName": None}, _SERVED, id="operationName null"), + pytest.param({"query": _TYPENAME, "extensions": None}, _SERVED, id="extensions null"), +] -def test_execute_payload_rejects_non_object_variables(gql): - r = gql.execute_payload({"query": "{ widgets { id } }", "variables": "nope"}) - assert r.request_error is True - assert r.payload["errors"][0]["message"] == "Variables are invalid JSON." +@pytest.mark.parametrize("payload, answer", _APOLLO_CHECKS) +def test_apollo_server_reads_a_body_in_its_order(gql, payload, answer): + r = gql.execute_payload(payload) + assert (r.status, r.payload) == answer # --- resolver binding ----------------------------------------------------------- @@ -881,13 +1049,9 @@ async def graphql_endpoint(request: Request): {"errors": [{"message": "Authentication required"}]}, status_code=401 ) context = {"conn": auth.conn(request), "visible_ids": auth.visible_ids(request, caller)} - try: - payload = request_body.parse(request_body.decode(await request.body())) - except request_body.NotJson: - result = engine.not_json() - else: - result = ACME.execute_payload(payload, context=context) - return JSONResponse(result.payload, status_code=400 if result.request_error else 200) + payload = request_body.parse(request_body.decode(await request.body())) + result = ACME.execute_payload(payload, context=context) + return JSONResponse(result.payload, status_code=result.status) app.include_router(router) return TestClient(app) @@ -932,18 +1096,6 @@ def test_mounted_endpoint_returns_a_graphql_envelope_for_a_malformed_document( assert "Syntax Error" in body["errors"][0]["message"] -def test_mounted_endpoint_returns_a_graphql_envelope_for_a_non_json_body( - acme_client, sample_settings -): - r = acme_client.post( - "/acme/graphql", - content=b"not json", - headers={"Authorization": sample_settings.admin_token, "Content-Type": "application/json"}, - ) - assert r.status_code == 400 - assert r.json()["errors"][0]["message"] == "POST body sent invalid JSON." - - def test_acl_filters_results_through_the_resolver_context(acme_client, tokens): """Same query, two callers: each sees only what the ACL grants them.""" ava = acme_client.post( diff --git a/tests/test_linear.py b/tests/test_linear.py index a3721a550..848f45ec7 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -710,14 +710,39 @@ def test_linear_unauthenticated_is_401(client): # --- the body, and where the credential is read against it --------------------------------------- # The headers a body was sent with beyond `Content-Type: application/json`, the body, and the bytes -# Linear refused it with, at the status their `code` names (400 where there is none), or None where -# Linear served the query, as `backlot.graphql.body` and `backlot.graphql.v8_json` read them. -# `_GRAPHQL_JS_NOT_JSON` marks the rows `backlot.routers.linear._read` answers in graphql-js's -# words for the key, no credential and `nope` alike: a body with no text. `_REQUEST_ERROR` marks a -# JSON array, the 400 with the key; Linear answers it with `Operation batching disabled.` with or -# without one. -_GRAPHQL_JS_NOT_JSON = b'{"errors":[{"message":"POST body sent invalid JSON."}]}' -_REQUEST_ERROR = object() +# Linear refused it with for the key, no credential and `nope` alike, at the status their `code` or +# `extensions.http.status` names (`_status`), or None where Linear served the query with the key and +# answered 401 without one, as `backlot.graphql.body` and `backlot.graphql.v8_json` read them. A +# `_WithTheKey` row is answered past the credential. Apollo Server's envelopes are as api.linear.app +# answered them on 2026-10-25 (UTC), less the newline it ends each with. +class _WithTheKey(bytes): + """Linear's answer with the key, at ``status``, to a document it refuses or runs once the + credential is read: without one, the 401.""" + + def __new__(cls, answer: bytes, status: int = 400): + self = super().__new__(cls, answer) + self.status = status + return self + + +def _apollo(message: bytes) -> bytes: + """Apollo Server's refusal of a body as Linear writes it around ``message``, already + JSON-escaped.""" + return ( + b'{"errors":[{"message":"%s","extensions":{"http":{"status":400,"headers":{}},' + b'"code":"BAD_REQUEST","type":"graphql error","userError":true}}]}' % message + ) + + +def _status(answer: bytes) -> int: + envelope = json.loads(answer) + if "code" in envelope: + return envelope["code"] + return envelope["errors"][0]["extensions"]["http"]["status"] + + +_NO_KEYS = _apollo(b"POST body missing, invalid Content-Type, or JSON object has no keys.") +_BATCHING = _apollo(b"Operation batching disabled.") _LINEAR_BODIES = [ pytest.param( b"{", @@ -1471,10 +1496,165 @@ def test_linear_unauthenticated_is_401(client): b'{"error":"Unexpected token \'}\', \\"{\\r\\n\\"query\\":\\r\\n}\\" is not valid JSON","details":{},"code":400}', id="CRLF then bad", ), - pytest.param(b"", _GRAPHQL_JS_NOT_JSON, id="empty"), - pytest.param(b"\xef\xbb\xbf", _GRAPHQL_JS_NOT_JSON, id="a byte-order mark alone"), - pytest.param(b"[]", _REQUEST_ERROR, id="an array"), - pytest.param(b'[{"query":"{ __typename }"}]', _REQUEST_ERROR, id="a batch of one query"), + pytest.param(b"", _NO_KEYS, id="empty"), + pytest.param(b"\xef\xbb\xbf", _NO_KEYS, id="a byte-order mark alone"), + pytest.param(b"{}", _NO_KEYS, id="an empty object"), + pytest.param(b"[]", _BATCHING, id="an array"), + pytest.param(b'[{"query":"{ __typename }"}]', _BATCHING, id="a batch of one query"), + pytest.param(b'{"query":5}', _apollo(b"GraphQL queries must be strings."), id="a query of 5"), + pytest.param( + b'{"query":"{ __typename }","variables":"x"}', + _apollo( + b"`variables` in a POST body should be provided as an object, not a recursively " + b"JSON-encoded string." + ), + id="a string of variables", + ), + pytest.param( + b'{"query":"{ __typename }","extensions":5}', + _apollo(b"`extensions` in a POST body must be an object if provided."), + id="extensions of 5", + ), + pytest.param( + b'{"query":"{ __typename }","operationName":5}', + _apollo(b"`operationName` in a POST body must be a string if provided."), + id="an operationName of 5", + ), + pytest.param( + b'{"query":"{ __typename }","extensions":{"persistedQuery":{"version":1,"sha256Hash":"' + + b"0" * 64 + + b'"}}}', + b'{"errors":[{"message":"Internal server error","extensions":{"http":{"status":400,' + b'"headers":{}},"code":"INTERNAL_SERVER_ERROR","type":"internal error","userError":false}}]}', + id="a persisted query's hash that does not match", + ), + pytest.param( + b'{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"' + b"1" * 64 + b'"}}}', + b'{"errors":[{"message":"PersistedQueryNotFound","extensions":{"http":{"status":200,' + b'"headers":{}},"code":"PERSISTED_QUERY_NOT_FOUND","type":"graphql error",' + b'"userError":true}}]}', + id="a persisted query's hash alone", + ), + pytest.param( + b'{"query":null}', + b'{"errors":[{"message":"GraphQL operations must contain a non-empty `query` or a ' + b'`persistedQuery` extension.","extensions":{"http":{"status":400,"headers":{}},' + b'"code":"INVALID_INPUT","type":"invalid input","userError":true}}]}', + id="a query of null", + ), + pytest.param( + b'{"query":"{"}', + b'{"errors":[{"message":"Syntax Error: Expected Name, found .","locations":[{"line":1,' + b'"column":2}],"extensions":{"http":{"status":400,"headers":{}},' + b'"code":"GRAPHQL_PARSE_FAILED","type":"graphql error","userError":true}}]}', + id="a document that does not parse", + ), + pytest.param( + b'{"query":"{ nosuch }"}', + b'{"errors":[{"message":"Cannot query field \\"nosuch\\" on type \\"Query\\".",' + b'"locations":[{"line":1,"column":3}],"extensions":{"http":{"status":400,"headers":{}},' + b'"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}', + id="a document that does not validate", + ), + pytest.param( + b'{"query":"query A { __typename } query B { __typename }"}', + b'{"errors":[{"message":"operation does not exist","extensions":{"type":"invalid input",' + b'"code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"The ' + b'operation does not exist on the query.","http":{"status":400}}}]}', + id="two operations and no operationName", + ), + pytest.param( + b'{"query":"query($b: Boolean!) { __typename @include(if: $b) }"}', + _WithTheKey( + b'{"errors":[{"message":"Variable \\"$b\\" of required type \\"Boolean!\\" was not ' + b'provided.","locations":[{"line":1,"column":7}],"extensions":{"code":"BAD_USER_INPUT",' + b'"type":"graphql error","userError":true}}]}' + ), + id="a variable not provided", + ), + pytest.param( + b'{"query":"{ __typename }","operationName":"Z"}', + b'{"errors":[{"message":"operation does not exist","extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"The operation \'Z\' does not exist on the query.","http":{"status":400}}}]}', + id="an operationName no operation has", + ), + pytest.param( + b'{"query":"query A { __typename }","operationName":""}', + b'{"errors":[{"message":"operation does not exist","extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"The operation does not exist on the query.","http":{"status":400}}}]}', + id="an empty operationName", + ), + pytest.param( + b'{"query":{"kind":"Document"}}', + b'{"errors":[{"message":"GraphQL queries must be strings. It looks like you\'re sending the internal graphql-js representation of a parsed query in your request instead of a request in the GraphQL query language. You can convert an AST to a string using the `print` function from `graphql`, or use a client like `apollo-client` which converts the internal representation to a string for you.","extensions":{"http":{"status":400,"headers":{}},"code":"BAD_REQUEST","type":"graphql error","userError":true}}]}', + id="a parsed document", + ), + pytest.param( + b'{"query":false}', + b'{"errors":[{"message":"GraphQL operations must contain a non-empty `query` or a `persistedQuery` extension.","extensions":{"http":{"status":400,"headers":{}},"code":"INVALID_INPUT","type":"invalid input","userError":true}}]}', + id="a query of false", + ), + pytest.param( + b'{"query":"{ __typename }","variables":"[1]"}', + b'{"errors":[{"message":"`variables` in a POST body must be an object if provided.","extensions":{"http":{"status":400,"headers":{}},"code":"BAD_REQUEST","type":"graphql error","userError":true}}]}', + id="variables of '[1]'", + ), + pytest.param( + b'{"query":"{ __typename }","variables":"null"}', + None, + id="variables of 'null'", + ), + pytest.param( + b'{"query":"{ __typename }","variables":" {} "}', + None, + id="variables of ' {} '", + ), + pytest.param( + b'{"query":"query($b: Boolean!) { __typename @include(if: $b) }","variables":"{\\"b\\":5}"}', + _WithTheKey( + b'{"errors":[{"message":"Variable \\"$b\\" got invalid value 5; Boolean cannot represent a non boolean value: 5","locations":[{"line":1,"column":7}],"extensions":{"code":"BAD_USER_INPUT","type":"graphql error","userError":true}}]}' + ), + id="variables of a string its operation refuses", + ), + pytest.param( + b'{"query":"query($b: Boolean!) { __typename @include(if: $b) }","variables":"{\\"b\\":true}"}', + None, + id="variables of a string its operation takes", + ), + pytest.param( + b'{"query":"mutation { __typename }"}', + _WithTheKey(b'{"data":{"__typename":"Mutation"}}', 200), + id="a mutation of __typename", + ), + pytest.param( + b'{"query":"subscription S { __typename }"}', + b'{"errors":[{"message":"Subscription \\"S\\" must not select an introspection top level field.","locations":[{"line":1,"column":18}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}', + id="a subscription of __typename", + ), + pytest.param( + b'{"query":"query($x: String = \\"a\\") { issue(id: $x) { id } }","variables":{"x":null}}', + _WithTheKey( + b'{"errors":[{"message":"Internal server error","path":["issue"],"locations":[{"line":1,"column":37}],"extensions":{"code":"INTERNAL_SERVER_ERROR","type":"internal error","userError":false}}],"data":null}', + 200, + ), + id="null for a non-null argument", + ), + pytest.param( + b'{"query":"query($v: Boolean = true) { __typename @include(if: $v) }","variables":{"v":null}}', + _WithTheKey( + b'{"errors":[{"message":"Internal server error","locations":[{"line":1,"column":53}],"extensions":{"code":"INTERNAL_SERVER_ERROR","type":"internal error","userError":false}}],"data":null}', + 200, + ), + id="null for a directive's argument at the root", + ), + pytest.param( + b'{"query":"{ ...F }"}', + b'{"errors":[{"message":"Unknown fragment \\"F\\".","locations":[{"line":1,"column":6}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}', + id="a fragment not defined", + ), + pytest.param( + b'{"query":"fragment F on Query { ...F } { ...F }"}', + b'{"errors":[{"message":"Cannot spread fragment \\"F\\" within itself.","locations":[{"line":1,"column":23}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}', + id="a fragment that spreads itself", + ), ] @@ -1644,7 +1824,7 @@ def _charset(name: str) -> tuple: pytest.param(_GZIP, gzip.compress(_TYPENAME) + b"xx", _HEADER_CHECK, id="gzip then xx"), pytest.param(_GZIP, gzip.compress(_TYPENAME)[:-5], _CUT_SHORT, id="gzip cut short"), pytest.param(_GZIP, b"", _CUT_SHORT, id="gzip over no bytes"), - pytest.param(_GZIP, gzip.compress(b""), _GRAPHQL_JS_NOT_JSON, id="gzip of no bytes"), + pytest.param(_GZIP, gzip.compress(b""), _NO_KEYS, id="gzip of no bytes"), pytest.param(_GZIP, gzip.compress(b"{"), _EXPECTED_PROPERTY, id="gzip of {"), pytest.param( (("Content-Encoding", "deflate"),), @@ -1703,7 +1883,7 @@ def _charset(name: str) -> tuple: pytest.param( _charset("utf-16le"), "{".encode("utf-16-le"), _EXPECTED_PROPERTY, id="utf-16le {" ), - pytest.param(_charset("utf-16le"), b"", _GRAPHQL_JS_NOT_JSON, id="utf-16le, no bytes"), + pytest.param(_charset("utf-16le"), b"", _NO_KEYS, id="utf-16le, no bytes"), pytest.param(_charset("utf-8; foo=bar"), _TYPENAME, None, id="another parameter"), pytest.param(_charset('"utf\\-8"'), _TYPENAME, None, id="a quoted pair"), pytest.param(_charset('"lat\\in1"'), _TYPENAME, _LATIN1, id="a quoted pair, latin1"), @@ -1822,9 +2002,8 @@ def _charset(name: str) -> tuple: def test_linear_orders_the_body_and_the_credential( client, tokens_yaml, credential, headers, body, answer ): - """Each row answers as the comment over `_LINEAR_BODIES` says, and every refusal but a - `_REQUEST_ERROR` row's is the same for the key, no credential and `nope`; an API key under - `Bearer`, and `_KEY_SHAPED`, are `_API_KEY_AS_BEARER` ahead of every body.""" + """Each row answers as the comment over `_LINEAR_BODIES` says; an API key under `Bearer`, and + `_KEY_SHAPED`, are `_API_KEY_AS_BEARER` ahead of every body.""" admin = tokens_yaml["admin_token"] if not any(name == "Content-Type" for name, _ in headers): headers = (("Content-Type", "application/json"), *headers) @@ -1836,16 +2015,14 @@ def test_linear_orders_the_body_and_the_credential( r = sent_as(client, "POST", "/linear/graphql", headers, content=body) if credential.startswith("Bearer"): assert (r.status_code, r.content) == (400, _API_KEY_AS_BEARER) - elif answer is _REQUEST_ERROR: - if credential == "key": - assert r.content == _GRAPHQL_JS_NOT_JSON - assert r.status_code == (400 if credential == "key" else 401) - elif answer is not None: - assert (r.status_code, r.content) == (json.loads(answer).get("code", 400), answer) - elif credential == "key": + elif credential != "key" and (answer is None or isinstance(answer, _WithTheKey)): + assert r.status_code == 401 + elif answer is None: assert (r.status_code, r.json()) == (200, {"data": {"__typename": "Query"}}) + elif isinstance(answer, _WithTheKey): + assert (r.status_code, r.content) == (answer.status, answer) else: - assert r.status_code == 401 + assert (r.status_code, r.content) == (_status(answer), answer) def test_linear_parent_resolves_and_is_acl_scoped(client, admin_h, tokens_yaml): @@ -2108,7 +2285,7 @@ def test_linear_attachments_take_no_url_argument_and_filter_by_url_instead(clien ) assert r.status_code == 400 and "data" not in r.json() assert r.json()["errors"][0]["message"] == ( - "Unknown argument 'url' on field 'Issue.attachments'." + 'Unknown argument "url" on field "Issue.attachments".' ) one = gql( client, @@ -2428,9 +2605,10 @@ def test_a_malformed_date_is_the_validation_error_linear_answers(fclient, litera assert r.status_code == 400 body = r.json() assert "data" not in body - message = body["errors"][0]["message"] - assert message.startswith("Expected value of type 'DateTimeOrDuration', found ") - assert message.endswith("into a valid date") + assert body["errors"][0]["message"] == ( + f'Expected value of type "DateTimeOrDuration", found {literal}; ' + f"Unable to parse value '{literal[1:-1]}' into a valid date" + ) def test_a_date_operand_must_be_a_string(fclient): @@ -2442,11 +2620,11 @@ def test_a_date_operand_must_be_a_string(fclient): q = "query($d: DateTimeOrDuration) { issues(filter: {createdAt: {gt: $d}}) { nodes { id } } }" r = post(fclient, q, {"d": 1700000000}) assert r.status_code == 400 - assert r.json()["errors"][0]["message"].startswith("Variable '$d' got invalid value 1700000000") + assert r.json()["errors"][0]["message"].startswith('Variable "$d" got invalid value 1700000000') assert "supports only string values" in r.json()["errors"][0]["message"] r = post(fclient, q, {"d": "not-a-date"}) assert r.status_code == 400 - assert "Expected type 'DateTimeOrDuration'" in r.json()["errors"][0]["message"] + assert 'Expected type "DateTimeOrDuration"' in r.json()["errors"][0]["message"] @pytest.mark.parametrize( @@ -2480,9 +2658,10 @@ def test_forms_outside_the_measured_grammar_are_refused(fclient, literal): fclient, "{ issues(filter: {createdAt: {gt: %s}}) { nodes { identifier } } }" % literal ) assert r.status_code == 400 - message = r.json()["errors"][0]["message"] - assert message.startswith("Expected value of type 'DateTimeOrDuration', found ") - assert message.endswith("into a valid date"), message + assert r.json()["errors"][0]["message"] == ( + f'Expected value of type "DateTimeOrDuration", found {literal}; ' + f"Unable to parse value '{literal[1:-1]}' into a valid date" + ) # The other scalar names itself `TimelessDate` and quotes the value (measured). r = post(fclient, "{ issues(filter: {dueDate: {eq: 1}}) { nodes { identifier } } }") assert r.status_code == 400 @@ -2603,7 +2782,7 @@ def test_an_unsupported_filter_field_is_an_error_not_a_dropped_filter(fclient): "/linear/graphql", json={"query": q}, headers={"Authorization": fclient.__dict__["_admin"]} ) assert r.status_code == 400 - assert "not defined by type 'IssueFilter'" in r.json()["errors"][0]["message"] + assert r.json()["errors"][0]["message"] == 'Field "nope" is not defined by type "IssueFilter".' def test_an_unsupported_comparator_is_an_error(fclient): @@ -4244,19 +4423,19 @@ def test_release_stage_and_pipeline_are_non_null_and_stable(fclient): [ ( '{ issues(filter: {branchName: {eq: "x"}}) { nodes { id } } }', - "Field 'branchName' is not defined by type 'IssueFilter'.", + 'Field "branchName" is not defined by type "IssueFilter".', ), ( '{ issue(id: "ENG-2") { releases(filter: {slugId: {eq: "x"}}) { nodes { id } } } }', - "Field 'slugId' is not defined by type 'ReleaseFilter'.", + 'Field "slugId" is not defined by type "ReleaseFilter".', ), ( "{ users(sort: [{createdAt: {order: Descending}}]) { nodes { id } } }", - "Field 'createdAt' is not defined by type 'UserSortInput'.", + 'Field "createdAt" is not defined by type "UserSortInput".', ), ( "{ users(sort: [{updatedAt: {order: Descending}}]) { nodes { id } } }", - "Field 'updatedAt' is not defined by type 'UserSortInput'.", + 'Field "updatedAt" is not defined by type "UserSortInput".', ), ], ids=[ @@ -4360,7 +4539,7 @@ def test_due_date_is_compared_as_a_timeless_date(fclient, now_is_2026_03_01_noon r = post(fclient, '{ issues(filter: {dueDate: {eq: "nope"}}) { nodes { id } } }') assert r.status_code == 400 assert r.json()["errors"][0]["message"].startswith( - "Expected value of type 'TimelessDateOrDuration', found " + 'Expected value of type "TimelessDateOrDuration", found "nope"; ' ) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index c56e1701e..218c2397c 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -235,6 +235,9 @@ def test_each_client_rule_names_the_headers_it_was_measured_from(): ("linear", "implemented", 200, "unsupported_filter", False, "unsupported"), ("gmail", "implemented", 501, "notImplemented", False, "client"), ("gmail", "implemented", 503, "backendError", False, "internal"), + ("fireflies", "unknown", 500, "INTERNAL_SERVER_ERROR", False, "client"), + ("linear", "unknown", 500, "INTERNAL_SERVER_ERROR", False, "internal"), + ("fireflies", "unknown", 500, "", False, "internal"), ], ) def test_a_failure_is_classed_by_its_answer(source, support, status, code, spent, expected): @@ -284,7 +287,20 @@ def test_a_failure_is_classed_by_its_answer(source, support, status, code, spent ), ("linear", 200, b'{"errors":[{"message":"Entity not found: Issue"}]}', "entity_not_found"), ("linear", 200, b'{"errors":[{"message":"x","extensions":[1]}]}', "unrecognized"), - ("linear", 400, b'{"errors":[{"message":"x","extensions":[1]}]}', "graphql_validation"), + ("linear", 400, b'{"errors":[{"message":"x","extensions":[1]}]}', "unrecognized"), + ( + "fireflies", + 400, + b'{"errors":[{"friendly":false,"message":"x","code":"BAD_REQUEST","extensions":{}}]}', + "BAD_REQUEST", + ), + ( + "fireflies", + 500, + b'{"errors":[{"message":"x","code":"A","extensions":{"code":"B"}}]}', + "B", + ), + ("fireflies", 400, b'{"errors":[{"message":"x","code":5}]}', "unrecognized"), ("linear", 200, b'{"errors":[{"message":"x","extensions":{"code":5}}]}', "unrecognized"), ("linear", 200, b'{"errors":[{"message":"x","extensions":{"code":""}}]}', "unrecognized"), ("fireflies", 200, b'{"data":{},"extensions":{"code":"X"}}', ""), @@ -525,6 +541,13 @@ def test_an_error_code_is_the_envelopes_own_identifier(source, status, body, exp {"headers": JSON, "content": b"x" * (1024 * 1024 + 1)}, ("fireflies", "unparsed", "unknown", 413, "http", "client"), ), + # Fireflies' 500 for a fragment it does not define is its answer, not a Backlot bug. + ( + "POST", + "/fireflies/graphql", + {"json": {"query": "{ ...F }"}}, + ("fireflies", "query.unmatched", "unknown", 500, "INTERNAL_SERVER_ERROR", "client"), + ), ( "POST", "/linear/graphql", @@ -592,19 +615,19 @@ def test_a_call_is_named_by_the_baseline(client, method, path, kwargs, expected) ( "{ projects { nodes { id } } }", None, - ("query.projects", "missing", 400, "graphql_validation", "unsupported"), + ("query.projects", "missing", 400, "GRAPHQL_VALIDATION_FAILED", "unsupported"), ), ( "{ notAField }", None, - ("query.unmatched", "unknown", 400, "graphql_validation", "client"), + ("query.unmatched", "unknown", 400, "GRAPHQL_VALIDATION_FAILED", "client"), ), ( 'mutation { issueCreate(input: {title: "x", teamId: "t"}) { success } }', None, - ("mutation", "unknown", 400, "graphql_validation", "client"), + ("mutation", "unknown", 400, "GRAPHQL_VALIDATION_FAILED", "client"), ), - ("{ issues {", None, ("unparsed", "unknown", 400, "graphql_parse", "client")), + ("{ issues {", None, ("unparsed", "unknown", 400, "GRAPHQL_PARSE_FAILED", "client")), ( "{ __schema { queryType { name } } }", None, @@ -738,43 +761,52 @@ def test_each_message_slug_is_one_backlot_sends(client, method, path, kwargs, ex # Linear's rows; Fireflies runs the same engine, so a few of them are sent there as well. The # `transcripts` rows there are Fireflies' own. _GRAPHQL_MESSAGES = [ - ({"query": "{"}, LINEAR, "graphql_parse"), + ({"query": "{"}, LINEAR, "GRAPHQL_PARSE_FAILED"), ({"query": '{ issue(id: "nope") { id } }'}, LINEAR, "entity_not_found"), ({"query": "{ __typename }"}, {}, "authentication_required"), - ({"query": "{ nosuch }"}, LINEAR, "graphql_validation"), - ({"query": '{ issue(id: "x", bogus: 1) { id } }'}, LINEAR, "graphql_validation"), - ({"query": 'query($x: Nope) { issue(id: "x") { id } }'}, LINEAR, "graphql_validation"), - ({"query": "{ issue { id } }"}, LINEAR, "graphql_validation"), - ({"query": "query($x: String!) { issue(id: $x) { id } }"}, LINEAR, "graphql_validation"), + ({"query": "{ nosuch }"}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), + ({"query": '{ issue(id: "x", bogus: 1) { id } }'}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), + ({"query": 'query($x: Nope) { issue(id: "x") { id } }'}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), + ({"query": "{ issue { id } }"}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), + ({"query": "query($x: String!) { issue(id: $x) { id } }"}, LINEAR, "BAD_USER_INPUT"), ( { "query": 'query($x: String = "a") { issue(id: $x) { id } }', "variables": {"x": None}, }, LINEAR, - "graphql_validation", + "INTERNAL_SERVER_ERROR", ), - ({"query": "mutation { x }"}, LINEAR, "graphql_validation"), - ({"query": "{ ...F }"}, LINEAR, "graphql_validation"), - ({"query": "{ issues @nope { nodes { id } } }"}, LINEAR, "graphql_validation"), + ({"query": "mutation { x }"}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), + ({"query": "{ ...F }"}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), + ({"query": "{ issues @nope { nodes { id } } }"}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), ( {"query": "query A { viewer { id } } query A { viewer { id } }"}, LINEAR, - "graphql_validation", + "GRAPHQL_VALIDATION_FAILED", + ), + ({"query": "{ issue(id: 5) { id } }"}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), + ({"query": "{ issue(id: null) { id } }"}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), + ({"query": '{ issues(first: "x") { nodes { id } } }'}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), + ( + {"query": "{ issues(orderBy: createdat) { nodes { id } } }"}, + LINEAR, + "GRAPHQL_VALIDATION_FAILED", ), - ({"query": "{ issue(id: 5) { id } }"}, LINEAR, "graphql_validation"), - ({"query": "{ issue(id: null) { id } }"}, LINEAR, "graphql_validation"), - ({"query": '{ issues(first: "x") { nodes { id } } }'}, LINEAR, "graphql_validation"), - ({"query": "{ issues(orderBy: createdat) { nodes { id } } }"}, LINEAR, "graphql_validation"), - ({"query": "fragment F on User { id } { viewer { id } }"}, LINEAR, "graphql_validation"), - ({}, LINEAR, "graphql_request"), - ([], LINEAR, "graphql_request"), - ({"query": "{ viewer { id } }", "variables": "{"}, LINEAR, "graphql_request"), - ({"query": "query A { viewer { id } }", "operationName": "B"}, LINEAR, "graphql_request"), + ( + {"query": "fragment F on User { id } { viewer { id } }"}, + LINEAR, + "GRAPHQL_VALIDATION_FAILED", + ), + ({}, LINEAR, "BAD_REQUEST"), + ([], LINEAR, "BAD_REQUEST"), + ({"query": "{ viewer { id } }", "variables": "{"}, LINEAR, "BAD_REQUEST"), + ({"variables": {}}, LINEAR, "INVALID_INPUT"), + ({"query": "query A { viewer { id } }", "operationName": "B"}, LINEAR, "INPUT_ERROR"), ( {"query": "query A { viewer { id } } query B { viewer { id } }"}, LINEAR, - "graphql_request", + "INPUT_ERROR", ), ] @@ -783,24 +815,27 @@ def test_each_message_slug_is_one_backlot_sends(client, method, path, kwargs, ex "path, payload, headers, expected", [("/linear/graphql", *row) for row in _GRAPHQL_MESSAGES] + [ - ("/fireflies/graphql", {"query": "{"}, ADMIN, "graphql_parse"), - ("/fireflies/graphql", {"query": "{ nosuch }"}, ADMIN, "graphql_validation"), + ("/fireflies/graphql", {"query": "{"}, ADMIN, "GRAPHQL_PARSE_FAILED"), + ("/fireflies/graphql", {"query": "{ nosuch }"}, ADMIN, "GRAPHQL_VALIDATION_FAILED"), ( "/fireflies/graphql", {"query": '{ transcripts(limit: "x") { id } }'}, ADMIN, - "graphql_validation", + "GRAPHQL_VALIDATION_FAILED", ), ( "/fireflies/graphql", {"query": '{ transcripts(date: "x") { id } }'}, ADMIN, - "graphql_validation", + "GRAPHQL_VALIDATION_FAILED", ), - ("/fireflies/graphql", {}, ADMIN, "graphql_request"), + ("/fireflies/graphql", {}, ADMIN, "BAD_REQUEST"), + ("/fireflies/graphql", {"query": "{ ...F }"}, ADMIN, "INTERNAL_SERVER_ERROR"), ], ) -def test_each_graphql_message_slug_is_one_backlot_sends(client, path, payload, headers, expected): +def test_each_graphql_error_is_counted_by_the_code_backlot_sends( + client, path, payload, headers, expected +): calls = _calls(lambda: client.post(path, headers=headers, json=payload)) assert [c.get("backlot.error_code") for c in calls] == [expected], calls @@ -810,7 +845,7 @@ def test_every_message_slug_has_a_request_that_sends_it(): row[-1] for t in ( test_each_message_slug_is_one_backlot_sends, - test_each_graphql_message_slug_is_one_backlot_sends, + test_each_graphql_error_is_counted_by_the_code_backlot_sends, ) for row in t.pytestmark[0].args[1] } | {"rate_limited", "unsupported_filter"} From 89a61d4108688dce5e9e737cf5f9d559dd1ec95e Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:58:12 +0900 Subject: [PATCH 02/52] graphql: pin graphql-js's words with its own answers, and take out what JSON cannot reach A table of documents over a schema of every input kind holds graphql-js 16.14.2's answers under Node, picked so that each change to js_words a row can tell apart has one. The vendor tables gain the rows Linear and Fireflies answered for the rest: an error's place after a line separator, a persisted query's version, the unreachable fragments, and the body checks two at a time. js_words drops the branches no request reaches: a circular value, a variable whose type validation already refused, a scalar's GraphQLError. --- backlot/graphql/engine.py | 89 ++--- backlot/graphql/fireflies_resolvers.py | 4 +- backlot/graphql/js_words.py | 168 ++++---- backlot/graphql/linear_resolvers.py | 4 +- backlot/routers/fireflies.py | 36 +- backlot/routers/linear.py | 35 +- tests/_helpers.py | 10 + tests/test_fireflies.py | 120 ++++-- tests/test_graphql.py | 525 ++++++++++++++++++++++++- tests/test_linear.py | 68 +++- 10 files changed, 810 insertions(+), 249 deletions(-) diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index 3685925b9..b7a173a02 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -92,8 +92,8 @@ def _ids_as_graphql_js_reads_them(value: Any, type_: Any) -> Any: Scalar = Mapping[str, Callable[..., Any]] -#: Apollo Server's codes for the refusals it and graphql-js make, as both vendors carried them on -#: 2026-10-25 (UTC). +#: Apollo Server's codes for the refusals it and graphql-js make, as api.fireflies.ai carried each +#: on 2026-10-25 (UTC); api.linear.app carried each but ``OPERATION_RESOLUTION_FAILURE``. BAD_REQUEST = "BAD_REQUEST" PARSE_FAILED = "GRAPHQL_PARSE_FAILED" VALIDATION_FAILED = "GRAPHQL_VALIDATION_FAILED" @@ -120,9 +120,9 @@ class Result: ``refusals`` holds the request errors, made before execution, and ``payload`` then carries no ``data``; a router writes them in its vendor's envelope. ``assigned`` holds the index in - ``payload["errors"]`` of each error graphql-core made as the operation ran, which carries the - ``extensions.code`` Apollo Server gives an error with none of its own. ``raised`` marks a field - error that is Backlot's own failure rather than a refusal; :func:`_raised` tells the two apart. + ``payload["errors"]`` of each error graphql-core made as the operation ran (:meth:`Engine.run`). + ``raised`` marks a field error that is Backlot's own failure rather than a refusal; + :func:`_raised` tells the two apart. """ payload: dict @@ -142,9 +142,8 @@ def status(self) -> int: @dataclass(frozen=True) class Operation: - """A request read up to the choice of its operation (:meth:`Engine.resolve`): the parsed - ``document``, the ``operation`` chosen from it, and the ``variables`` and operation ``name`` - sent with it.""" + """What :meth:`Engine.resolve` makes of a request it goes on with: the parsed ``document``, the + ``operation`` chosen from it, and the ``variables`` and operation ``name`` sent with it.""" document: DocumentNode operation: OperationDefinitionNode @@ -185,7 +184,7 @@ def _raised(error: GraphQLError) -> bool: ) -def _refused(*refusals: Refusal) -> Result: +def refused(*refusals: Refusal) -> Result: """The request errors ``refusals``, in Apollo Server's own envelope: ``message``, the ``locations`` where there are any, and ``extensions.code``.""" errors = [] @@ -199,16 +198,13 @@ def _refused(*refusals: Refusal) -> Result: def _refusal(message: str, code: str = BAD_REQUEST, status: int = 400) -> Result: - return _refused(Refusal(message, code, status=status)) + return refused(Refusal(message, code, status=status)) def _js_truthy(value: Any) -> bool: - """Whether JavaScript reads the JSON value ``value`` as true.""" - if value is None or value is False or value == "": - return False - if isinstance(value, (int, float)) and not isinstance(value, bool): - return value == value and value != 0 - return True + """Whether JavaScript reads the JSON value ``value`` as true: all but ``null``, ``false``, + ``""`` and zero.""" + return value not in (None, False, "", 0) _NOT_A_STRING = "GraphQL queries must be strings." @@ -229,11 +225,11 @@ def _js_truthy(value: Any) -> bool: def body_refusal(payload: Any) -> Result | None: """Apollo Server's refusal of a POST body ``payload`` read as JSON, or ``None`` for a body it - goes on with. The checks run in this order, each first where two apply, as both vendors - answered on 2026-10-25 (UTC): an array, an empty object or a value that is not an object, a - truthy `query` that is not a string, `variables` and then `extensions` as a string, then - `extensions`, `variables` and `operationName` of the wrong type. A ``null`` member is one not - sent.""" + goes on with. The checks run in this order, each first where two apply: an array, an empty + object or a value that is not an object, a truthy `query` that is not a string, `variables` and + then `extensions` as a string, then `extensions`, `variables` and `operationName` of the wrong + type. Both vendors kept that order with two wrong at once on 2026-10-25 (UTC). A ``null`` + member is one not sent.""" if isinstance(payload, list): return _refusal("Operation batching disabled.") if not isinstance(payload, dict) or not payload: @@ -271,15 +267,17 @@ def persisted_query_refusal(payload: dict) -> Result | None: if not _js_truthy(persisted): return None version = persisted.get("version") if isinstance(persisted, dict) else None - if isinstance(version, bool) or not isinstance(version, (int, float)) or version != 1: + if isinstance(version, bool) or version != 1: return _refusal(PERSISTED_QUERY_VERSION, INTERNAL_SERVER_ERROR) query = payload.get("query") if query is None: return _refusal("PersistedQueryNotFound", PERSISTED_QUERY_NOT_FOUND, status=200) # Both vendors hashed a query holding U+D800 as the same query with U+FFFD in its place on # 2026-10-25 (UTC), the UTF-8 Node writes for a lone surrogate. - text = js_words.LONE_SURROGATE.sub("\ufffd", query) if isinstance(query, str) else None - digest = hashlib.sha256(text.encode("utf-8")).hexdigest() if text is not None else None + digest = None + if isinstance(query, str): + units = query.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "replace") + digest = hashlib.sha256(units.encode("utf-8")).hexdigest() if digest is None or persisted.get("sha256Hash") != digest: return _refusal(PERSISTED_QUERY_MISMATCH, INTERNAL_SERVER_ERROR) return None @@ -300,8 +298,11 @@ def from_sdl(module_file: str, name: str, resolvers, scalars=None, roots=()) -> def _with_roots(schema: GraphQLSchema, roots: tuple[str, ...]) -> GraphQLSchema: """``schema`` with each of ``roots``, ``Mutation`` or ``Subscription``, as a root type that - has no field. graphql-core refuses a schema with an object type of no field, so this one is - marked as checked; it is only validated against and run for ``__typename``.""" + has no field: the root types a vendor's schema has and its SDL leaves out because Backlot + serves none of their fields. A document validated against it is refused a field of one as a + field the type does not have, and ``__typename`` on one is answered with the type's name. + graphql-core refuses a schema with an object type of no field, so this one is marked as + checked; it is only validated against and run for ``__typename``.""" shadow = GraphQLSchema( query=schema.query_type, mutation=GraphQLObjectType("Mutation", {}) if "Mutation" in roots else None, @@ -321,10 +322,7 @@ class Engine: is a mapping — so a resolver can simply return dicts and let the selection set decide what is emitted. - ``roots`` names the root types, ``Mutation`` or ``Subscription``, the vendor's schema has and - the SDL leaves out because Backlot serves none of their fields. A document is validated as if - each were there with no field, so a field of one is refused as one the type does not have and - ``__typename`` is answered with the type's name. + ``roots`` names the root types :func:`_with_roots` adds. """ def __init__( @@ -338,7 +336,7 @@ def __init__( # validate() would do this on the first request otherwise, surfacing a vendor's broken # SDL as a 500 rather than as an import-time failure. assert_valid_schema(self.schema) - self._checked = _with_roots(self.schema, roots) if roots else self.schema + self._checked = _with_roots(self.schema, roots) for scalar_name, coercion in (scalars or {}).items(): scalar = self.schema.type_map.get(scalar_name) # Same rule as an unknown resolver target below: a misspelt scalar would leave the SDL's @@ -374,20 +372,20 @@ def resolve( """``document`` read as graphql-js reads it under Apollo Server, up to the choice of the operation to run: parse, then ``on_document``, a vendor's look at the parsed document before it is validated, then validation and the choice of operation, each refused with - Apollo Server's code. :meth:`run` takes the :class:`Operation` it returns.""" + Apollo Server's code; :meth:`run` takes the operation it chose.""" try: doc = parse(document) except GraphQLSyntaxError as error: - return _refused( + return refused( Refusal(js_words.requote(error.message), PARSE_FAILED, js_words.locations(error)) ) if on_document is not None: - refused = on_document(doc) - if refused is not None: - return refused + answer = on_document(doc) + if answer is not None: + return answer errors = validate(self._checked, doc) if errors: - return _refused( + return refused( *( Refusal(js_words.requote(e.message), VALIDATION_FAILED, js_words.locations(e)) for e in errors @@ -413,11 +411,11 @@ def resolve_payload( read with :mod:`backlot.graphql.body`, up to the choice of operation: :func:`body_refusal`, then :func:`persisted_query_refusal`, then Apollo Server's refusal of a body with no query, then :meth:`resolve`.""" - refused = body_refusal(payload) - if refused is None: - refused = persisted_query_refusal(payload) - if refused is not None: - return refused + answer = body_refusal(payload) + if answer is None: + answer = persisted_query_refusal(payload) + if answer is not None: + return answer query = payload.get("query") if not _js_truthy(query): return _refusal(NO_QUERY) @@ -436,7 +434,7 @@ def run(self, operation: Operation, *, context: Any = None) -> Result: self.schema, list(operation.operation.variable_definitions), variables ) if coercion: - return _refused( + return refused( *(Refusal(e.message, BAD_USER_INPUT, js_words.locations(e)) for e in coercion) ) if variables: @@ -489,12 +487,9 @@ def execute( variables: Any = None, operation_name: str | None = None, context: Any = None, - on_document: Callable[[DocumentNode], Result | None] | None = None, ) -> Result: """:meth:`resolve`, then :meth:`run` the operation it chose.""" - resolved = self.resolve( - document, variables=variables, operation_name=operation_name, on_document=on_document - ) + resolved = self.resolve(document, variables=variables, operation_name=operation_name) return resolved if isinstance(resolved, Result) else self.run(resolved, context=context) def execute_payload( diff --git a/backlot/graphql/fireflies_resolvers.py b/backlot/graphql/fireflies_resolvers.py index f46b9baea..44a8d3916 100644 --- a/backlot/graphql/fireflies_resolvers.py +++ b/backlot/graphql/fireflies_resolvers.py @@ -709,8 +709,8 @@ def resolve_users(_root, info, **_ignored): } -# The root type api.fireflies.ai's introspection names beside `Query` (2026-10-25, UTC), none of -# whose fields Backlot serves; it names no subscription type. +# Fireflies' one other root type, which its introspection named beside `Query` with no +# subscription type when measured on 2026-10-25 (UTC). Backlot serves no mutation. ROOTS = ("Mutation",) diff --git a/backlot/graphql/js_words.py b/backlot/graphql/js_words.py index 0603e9491..8077ecb91 100644 --- a/backlot/graphql/js_words.py +++ b/backlot/graphql/js_words.py @@ -1,21 +1,22 @@ """graphql-js's words for the errors graphql-core makes with its own. Linear and Fireflies run graphql-js under Apollo Server, and graphql-core is a port of graphql-js -that writes the same messages with two differences: it quotes a name, a type, a token or a -character with ``'`` where graphql-js quotes it with ``"``, and it prints a variable's value with -Python's ``repr`` where graphql-js prints it with its own ``inspect``. Both vendors answered a +whose messages differ from it: graphql-core quotes a name, a type, a token or a character with +``'`` where graphql-js quotes it with ``"``, names a directive's location by its value (``query``) +where graphql-js names it ``QUERY``, prints a variable's value with Python's ``repr`` where +graphql-js prints it with its own ``inspect``, words a variable's refusals its own way (``to be a +mapping``), and counts a line at characters graphql-js does not break at. Both vendors answered a validation error, a syntax error and a variable's refusal in graphql-js's words on 2026-10-25 (UTC), quotes and values as graphql-js 16.14.2 writes them: Linear also refused an array sent for an input object as graphql-js 16.14.2 does, where 16.9.0 reads the array's indices as field names. -:func:`requote` turns a syntax or validation message graphql-core wrote into graphql-js's, and -:func:`coerce_variable_values` makes a variable's refusals as graphql-js does, from the value as -the request sent it, so that no value passes through ``repr``. +Here are :func:`requote` for a message, :func:`locations` for where it points, and +:func:`coerce_variable_values` for a variable's refusals, which it makes from the value as the +request sent it, so that no value passes through ``repr``. """ from __future__ import annotations -import json import math import re from typing import Any, Callable @@ -26,10 +27,7 @@ GraphQLInputObjectType, GraphQLList, GraphQLNonNull, - GraphQLScalarType, GraphQLSchema, - is_input_type, - print_ast, type_from_ast, ) from graphql.language import DirectiveLocation, VariableDefinitionNode @@ -42,19 +40,18 @@ _MAX_RECURSIVE_DEPTH = 2 # graphql-js stops coercing variables at the fiftieth error, as `execute` asks it to. _MAX_ERRORS = 50 -LONE_SURROGATE = re.compile("[\ud800-\udfff]") def stringify(text: str) -> str: - """``text`` as ``JSON.stringify`` writes a string: in double quotes, a lone surrogate as a - lower-case ``\\u`` escape.""" - return LONE_SURROGATE.sub( - lambda m: f"\\u{ord(m.group()):04x}", json.dumps(text, ensure_ascii=False) - ) + """``text`` as ``JSON.stringify`` writes a string (:func:`backlot.graphql.body.node_json`).""" + return request_body.node_json(text).decode("utf-8") + + +_INDEX = re.compile(r"0|[1-9][0-9]*") def _is_array_index(key: str) -> bool: - return key.isascii() and key.isdigit() and str(int(key)) == key and int(key) < 2**32 - 1 + return _INDEX.fullmatch(key) is not None and int(key) < 2**32 - 1 def js_keys(obj: dict) -> list[str]: @@ -66,15 +63,17 @@ def js_keys(obj: dict) -> list[str]: def number(value: int | float) -> str: """A JSON number as JavaScript's ``String`` prints it.""" - return repr(request_body.Double(value)) if isinstance(value, float) else str(value) + return repr(request_body.Double(value)) def inspect(value: Any) -> str: """``value``, a value ``JSON.parse`` could have made, as graphql-js's ``inspect`` prints it.""" - return _format(value, []) + return _format(value, 0) -def _format(value: Any, seen: list) -> str: +def _format(value: Any, depth: int) -> str: + """``value`` nested ``depth`` arrays or objects deep. graphql-js's ``inspect`` also prints + ``[Circular]`` for a value that holds itself, which no value ``JSON.parse`` makes does.""" if value is None: return "null" if isinstance(value, bool): @@ -83,15 +82,13 @@ def _format(value: Any, seen: list) -> str: return number(value) if isinstance(value, str): return stringify(value) - if any(value is item for item in seen): - return "[Circular]" - seen = [*seen, value] + depth += 1 if isinstance(value, list): if not value: return "[]" - if len(seen) > _MAX_RECURSIVE_DEPTH: + if depth > _MAX_RECURSIVE_DEPTH: return "[Array]" - items = [_format(item, seen) for item in value[:_MAX_ARRAY_LENGTH]] + items = [_format(item, depth) for item in value[:_MAX_ARRAY_LENGTH]] remaining = len(value) - _MAX_ARRAY_LENGTH if remaining == 1: items.append("... 1 more item") @@ -100,9 +97,9 @@ def _format(value: Any, seen: list) -> str: return "[" + ", ".join(items) + "]" if not value: return "{}" - if len(seen) > _MAX_RECURSIVE_DEPTH: + if depth > _MAX_RECURSIVE_DEPTH: return "[Object]" - return "{ " + ", ".join(f"{key}: {_format(value[key], seen)}" for key in js_keys(value)) + " }" + return "{ " + ", ".join(f"{key}: {_format(value[key], depth)}" for key in js_keys(value)) + " }" # graphql-js breaks a line at `\r\n`, `\n` or `\r` and nowhere else. @@ -114,7 +111,7 @@ def locations(error: GraphQLError) -> tuple: lines as graphql-js's ``getLocation`` does, where graphql-core's count misses a line that starts at the position and adds one at each character Python's ``splitlines`` reads as a break.""" - if error.source is None or not error.positions: + if not error.positions: return () body = error.source.body out = [] @@ -150,20 +147,20 @@ def did_you_mean(suggestions: list[str], sub_message: str = "") -> str: r"Invalid (?:Unicode|character) escape sequence: )'(.*)'\.$", re.S, ) -# A character graphql-core quotes, at the end: graphql-js quotes it the same way but for `"`, -# which both quote as '"'. +# A character graphql-core quotes with `'` at the end, where graphql-js uses `"`; both write the +# character `"` itself as '"'. _SYNTAX_CHAR = re.compile(r": '(.)'\.$", re.S) _PUNCTUATOR = re.compile(r"'(\.\.\.|[!$&(){}\[\]:=@|])'") # A keyword the parser expected, such as `on`. _KEYWORD = re.compile(r"Expected '([_A-Za-z][_0-9A-Za-z]*)'") -# graphql-core names a directive's location by its value, `query` or `fragment definition`, where -# graphql-js names it by its name, `QUERY` or `FRAGMENT_DEFINITION`. +# The location a directive may not be used on, `fragment definition` here and `FRAGMENT_DEFINITION` +# in graphql-js. _DIRECTIVE_PLACE = re.compile(r"(may not be used on )(.+)(\.)$") _PLACE_NAMES = {place.value: place.name for place in DirectiveLocation} # What a validation or execution message quotes: a name, `$name`, `@name`, `Type.field`, a type # such as `[Int!]!`, or the `name { ... }` a leaf-selection error suggests. _QUOTED = re.compile( - r"'([$@]?[_A-Za-z][_0-9A-Za-z]*(?:\.[_A-Za-z][_0-9A-Za-z]*)?" + r"'([$@]?[_A-Za-z][_0-9A-Za-z]*(?:[.][_A-Za-z][_0-9A-Za-z]*)?" r"|[\[\]!_0-9A-Za-z]+" r"|[_A-Za-z][_0-9A-Za-z]* \{ \.\.\. \})'" ) @@ -172,8 +169,7 @@ def did_you_mean(suggestions: list[str], sub_message: str = "") -> str: def requote(message: str) -> str: - """A syntax, validation or execution message graphql-core wrote, quoted as graphql-js quotes - it.""" + """A syntax, validation or execution message graphql-core wrote, in graphql-js's words.""" if message.startswith("Syntax Error: "): m = _SYNTAX_TAIL.search(message) if m: @@ -186,7 +182,7 @@ def requote(message: str) -> str: ) return _KEYWORD.sub(r'Expected "\1"', _PUNCTUATOR.sub(r'"\1"', message)) place = _DIRECTIVE_PLACE.search(message) - if place and place.group(2) in _PLACE_NAMES: + if place: message = message[: place.start(2)] + _PLACE_NAMES[place.group(2)] + place.group(3) parts = [] last = 0 @@ -216,42 +212,31 @@ def requote(message: str) -> str: # --- a variable's refusals, as graphql-js makes them ----------------------------------------- -def _type_name(type_: Any) -> str: - return str(type_) +_BUILT_IN = frozenset({"Int", "Float", "String", "Boolean", "ID"}) -def _builtin(type_: GraphQLScalarType, value: Any) -> Any: - """graphql-js's ``parseValue`` for a built-in scalar, or ``Undefined`` for any other scalar.""" +def _built_in_refusal(name: str, value: Any) -> str | None: + """The message graphql-js's ``parseValue`` for the built-in scalar ``name`` refuses ``value`` + with, or ``None`` where it takes it.""" is_number = isinstance(value, (int, float)) and not isinstance(value, bool) - integral = is_number and ( - isinstance(value, int) or (math.isfinite(value) and value.is_integer()) - ) - name = type_.name + integral = is_number and float(value).is_integer() if name == "Int": if not integral: - raise GraphQLError(f"Int cannot represent non-integer value: {inspect(value)}") + return f"Int cannot represent non-integer value: {inspect(value)}" if not -(2**31) <= value <= 2**31 - 1: - raise GraphQLError( - f"Int cannot represent non 32-bit signed integer value: {number(value)}" - ) - return value - if name == "Float": + return f"Int cannot represent non 32-bit signed integer value: {number(value)}" + elif name == "Float": if not is_number or not math.isfinite(value): - raise GraphQLError(f"Float cannot represent non numeric value: {inspect(value)}") - return value - if name == "String": + return f"Float cannot represent non numeric value: {inspect(value)}" + elif name == "String": if not isinstance(value, str): - raise GraphQLError(f"String cannot represent a non string value: {inspect(value)}") - return value - if name == "Boolean": + return f"String cannot represent a non string value: {inspect(value)}" + elif name == "Boolean": if not isinstance(value, bool): - raise GraphQLError(f"Boolean cannot represent a non boolean value: {inspect(value)}") - return value - if name == "ID": - if isinstance(value, str) or integral: - return value - raise GraphQLError(f"ID cannot represent value: {inspect(value)}") - return Undefined + return f"Boolean cannot represent a non boolean value: {inspect(value)}" + elif name == "ID" and not (isinstance(value, str) or integral): + return f"ID cannot represent value: {inspect(value)}" + return None def _enum(type_: GraphQLEnumType, value: Any) -> Any: @@ -277,14 +262,14 @@ def coerce_input_value(value: Any, type_: Any, on_error: OnError, path: tuple = """Report each refusal graphql-js's ``coerceInputValue`` makes of ``value`` as a ``type_``, to ``on_error(path, invalid_value, error)``, in the order graphql-js makes them.""" if isinstance(type_, GraphQLNonNull): - if value is not None: + if value is None: + on_error( + list(path), + value, + GraphQLError(f'Expected non-nullable type "{type_}" not to be null.'), + ) + else: coerce_input_value(value, type_.of_type, on_error, path) - return - on_error( - list(path), - value, - GraphQLError(f'Expected non-nullable type "{_type_name(type_)}" not to be null.'), - ) return if value is None: return @@ -311,8 +296,7 @@ def coerce_input_value(value: Any, type_: Any, on_error: OnError, path: tuple = list(path), value, GraphQLError( - f'Field "{name}" of required type "{_type_name(field.type)}" ' - "was not provided." + f'Field "{name}" of required type "{field.type}" was not provided.' ), ) continue @@ -334,12 +318,15 @@ def coerce_input_value(value: Any, type_: Any, on_error: OnError, path: tuple = except GraphQLError as error: on_error(list(path), value, error) return + if type_.name in _BUILT_IN: + message = _built_in_refusal(type_.name, value) + if message is not None: + on_error(list(path), value, GraphQLError(message)) + return try: - if _builtin(type_, value) is Undefined: - type_.parse_value(value) - except GraphQLError as error: - on_error(list(path), value, error) - except Exception as error: # a scalar's own refusal, as graphql-js wraps a thrown Error + type_.parse_value(value) + # A custom scalar's refusal, a ValueError from Linear's, as graphql-js wraps a thrown Error. + except Exception as error: on_error( list(path), value, @@ -358,10 +345,10 @@ class _TooMany(Exception): def coerce_variable_values( schema: GraphQLSchema, definitions: list[VariableDefinitionNode], inputs: Any ) -> list[GraphQLError]: - """The refusals graphql-js's ``getVariableValues`` makes of the variables ``inputs`` for an - operation declaring ``definitions``, as ``execute`` asks it: at most fifty, then one saying - so. An empty list is a request whose variables graphql-js coerces.""" - inputs = inputs if isinstance(inputs, dict) else {} + """The refusals graphql-js's ``getVariableValues`` makes of the variables ``inputs`` for a + validated operation declaring ``definitions``, as ``execute`` asks it: at most fifty, then one + saying so. An empty list is a request whose variables graphql-js coerces.""" + inputs = inputs or {} errors: list[GraphQLError] = [] def report(error: GraphQLError) -> None: @@ -373,21 +360,11 @@ def report(error: GraphQLError) -> None: for definition in definitions: name = definition.variable.name.value type_ = type_from_ast(schema, definition.type) - if type_ is None or not is_input_type(type_): - report( - GraphQLError( - f'Variable "${name}" expected value of type ' - f'"{print_ast(definition.type)}" which cannot be used as an input type.', - definition.type, - ) - ) - continue if name not in inputs: if definition.default_value is None and isinstance(type_, GraphQLNonNull): report( GraphQLError( - f'Variable "${name}" of required type "{_type_name(type_)}" ' - "was not provided.", + f'Variable "${name}" of required type "{type_}" was not provided.', definition, ) ) @@ -396,18 +373,17 @@ def report(error: GraphQLError) -> None: if value is None and isinstance(type_, GraphQLNonNull): report( GraphQLError( - f'Variable "${name}" of non-null type "{_type_name(type_)}" ' - "must not be null.", + f'Variable "${name}" of non-null type "{type_}" must not be null.', definition, ) ) continue - def on_error(path: list, invalid: Any, error: GraphQLError, name=name, node=definition): + def on_error(path: list, invalid: Any, error: GraphQLError) -> None: prefix = f'Variable "${name}" got invalid value {inspect(invalid)}' if path: prefix += f' at "{name}{_print_path(path)}"' - report(GraphQLError(f"{prefix}; {error.message}", node)) + report(GraphQLError(f"{prefix}; {error.message}", definition)) coerce_input_value(value, type_, on_error) except _TooMany: diff --git a/backlot/graphql/linear_resolvers.py b/backlot/graphql/linear_resolvers.py index 121c160a6..f54b3ab33 100644 --- a/backlot/graphql/linear_resolvers.py +++ b/backlot/graphql/linear_resolvers.py @@ -2015,8 +2015,8 @@ def resolve_issue_labels( RESOLVERS["IssueSearchResult"] = RESOLVERS["Issue"] -# The root types api.linear.app's introspection names beside `Query` (2026-10-25, UTC), none of -# whose fields Backlot serves. +# Linear's other root types: its introspection named a mutation and a subscription type on +# 2026-10-25 (UTC), and Backlot serves no field of either. ROOTS = ("Mutation", "Subscription") diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index 0d7a715b3..c29319c39 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -10,8 +10,6 @@ :func:`backlot.auth.fireflies_bearer_token`. A credential Fireflies does not take is :func:`_auth_failed`'s 500, which comes after the body is read. -Status codes follow the GraphQL-over-HTTP split :class:`backlot.graphql.engine.Result` marks. - A body Fireflies cannot read is refused ahead of anything about the credential: with no credential, and with ``Authorization: nope``, an empty body and a body that is not JSON drew :func:`_body_refusal`'s 400 from api.fireflies.ai on 2026-10-18. ``Content-Encoding`` and the @@ -160,10 +158,11 @@ def _entry(error: dict, code: str, friendly: bool, extensions: dict) -> dict: def _envelope(result: engine.Result) -> dict: """``result`` as Fireflies writes it, as api.fireflies.ai answered on 2026-10-25 (UTC): each - request error, and each error graphql-js made as the operation ran, with ``friendly: false``, - Apollo Server's code beside the message and ``extensions`` empty. Its ``invalid_arguments`` and - ``INTERNAL_SERVER_ERROR`` field errors carry the extensions their resolver gave, the refusal as - real answered it on 2026-10-21 and the INTERNAL_SERVER_ERROR on 2026-10-22.""" + request error, and each error in ``result.assigned``, with ``friendly: false``, Apollo Server's + code beside the message and ``extensions`` empty. The ``invalid_arguments`` and + ``INTERNAL_SERVER_ERROR`` errors a resolver raises keep the extensions it gave them, + ``friendly`` as ``_FRIENDLY`` has it, as api.fireflies.ai answered the refusal on 2026-10-21 + and the INTERNAL_SERVER_ERROR on 2026-10-22.""" if result.refusals: errors = [] for refusal in result.refusals: @@ -196,6 +195,8 @@ def _fragments_unreached(doc: DocumentNode) -> engine.Result | None: in a fragment no operation uses was the validation's ``Unknown fragment`` and the same cycle its ``Cannot spread fragment``.""" fragments = {d.name.value: d for d in doc.definitions if isinstance(d, FragmentDefinitionNode)} + + # Each fragment found to reach no fault, so that a fragment many spreads reach is read once. done: set[str] = set() def reaches_a_fault(selections, path: tuple[str, ...]) -> bool: @@ -208,22 +209,17 @@ def reaches_a_fault(selections, path: tuple[str, ...]) -> bool: if reaches_a_fault(fragments[name].selection_set.selections, (*path, name)): return True done.add(name) - elif selection.selection_set is not None: - if reaches_a_fault(selection.selection_set.selections, path): - return True + elif selection.selection_set is not None and reaches_a_fault( + selection.selection_set.selections, path + ): + return True return False - for definition in doc.definitions: - if isinstance(definition, OperationDefinitionNode): - if reaches_a_fault(definition.selection_set.selections, ()): - return engine.Result( - payload={}, - refusals=( - engine.Refusal( - "Internal server error", engine.INTERNAL_SERVER_ERROR, status=500 - ), - ), - ) + operations = [d for d in doc.definitions if isinstance(d, OperationDefinitionNode)] + if any(reaches_a_fault(operation.selection_set.selections, ()) for operation in operations): + return engine.refused( + engine.Refusal("Internal server error", engine.INTERNAL_SERVER_ERROR, status=500) + ) return None diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 03e14025a..108a79b3a 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -11,9 +11,6 @@ access token travels, and what Linear takes for an API key there (:func:`backlot.auth.linear_api_key_under_bearer`) is :func:`_api_key_as_bearer`'s 400. -Status codes follow the GraphQL-over-HTTP split :class:`backlot.graphql.engine.Result` marks; real -Linear draws the line in the same place, and generated clients branch on it. - Express's ``body-parser``, in front of the GraphQL server, reads the body before the credential is looked at, for any credential but an API key under ``Bearer``: with no credential, a body that is not JSON drew its 400 from api.linear.app on 2026-10-18, not the 401. :func:`_express` is that 400, @@ -181,9 +178,8 @@ def _variables_read(payload: Any) -> Any: return payload -# What Linear's errors carry beside `code`, by code, as api.linear.app answered on 2026-10-25 (UTC): -# Apollo Server's `http` member on each request error but a variable's refusal, `type` and -# `userError`. +# The `type` beside each code in Linear's errors of 2026-10-25 (UTC), which also carry Apollo +# Server's `http` member on each request error but a variable's refusal, and `userError`. _TYPE = {engine.INTERNAL_SERVER_ERROR: "internal error", _INVALID_INPUT: "invalid input"} @@ -202,9 +198,9 @@ def _apollo(message: str, code: str, status: int, locations: Any = ()) -> dict: def _internal(error: dict) -> dict: - """Linear's ``Internal server error`` in place of an error graphql-js made as the operation - ran, at the error's ``path`` and ``locations``, as api.linear.app answered a ``null`` variable - a non-null argument refused on 2026-10-21 (UTC).""" + """Linear's ``Internal server error`` in place of ``error``, at its ``path`` and ``locations``: + api.linear.app's answer to a ``null`` variable a non-null argument refused, on 2026-10-21 + (UTC).""" entry: dict = {"message": "Internal server error"} for key in ("path", "locations"): if key in error: @@ -218,9 +214,9 @@ def _internal(error: dict) -> dict: def _operation_missing(name: Any) -> dict: - """Linear's own refusal of a document whose operation cannot be chosen: two operations and no - ``operationName``, or a name no operation has, as api.linear.app answered on 2026-10-25 (UTC); - an empty name is answered as no name.""" + """Linear's own refusal of a document whose operation cannot be chosen, two operations and no + ``operationName`` or a name no operation has, in the words api.linear.app used on 2026-10-25 + (UTC): an empty name in those for no name.""" presentable = ( f"The operation '{name}' does not exist on the query." if name @@ -240,11 +236,10 @@ def _operation_missing(name: Any) -> dict: def _envelope(result: engine.Result, payload: Any) -> dict: - """``result`` as Linear writes it. A request error is Apollo Server's in Linear's members - (:func:`_apollo`): a body with no query carries Linear's ``INVALID_INPUT``, a refused persisted - query Linear's ``Internal server error``, and an operation that cannot be chosen Linear's own - refusal (:func:`_operation_missing`). An error graphql-js made as the operation ran is - :func:`_internal`.""" + """``result`` as Linear writes it: a request error in Linear's members (:func:`_apollo`), but a + body with no query under Linear's ``INVALID_INPUT``, a refused persisted query as Linear's + ``Internal server error`` and an operation that cannot be chosen as :func:`_operation_missing`; + and each error in ``result.assigned`` as :func:`_internal`.""" if result.refusals: errors = [] for refusal in result.refusals: @@ -283,11 +278,11 @@ def _read(raw: bytes, content_type: str | None, content_encoding: str) -> Any: text = request_body.body_parser_text(raw, content_type, content_encoding) except request_body.Unread as unread: return _express(unread.message, unread.status, unread.length) - violation = request_body.strict_violation(text) if text else None - if violation is not None: - return _express(violation) if not text: return {} + violation = request_body.strict_violation(text) + if violation is not None: + return _express(violation) try: return request_body.parse(text) except request_body.NotJson: diff --git a/tests/_helpers.py b/tests/_helpers.py index 13f7fb6ff..b12a0b374 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -288,6 +288,16 @@ def gql(client, path: str, query: str, token: str | None = None, **variables): SWEPT_BYTES = (0x09, *range(0x20, 0x7F), *range(0x80, 0x100)) +class WithTheKey(bytes): + """A GraphQL vendor's answer with the key, at ``status``, to a body it answers once it has read + the credential; without the key the body draws the vendor's refusal of the credential.""" + + def __new__(cls, answer: bytes, status: int = 400): + self = super().__new__(cls, answer) + self.status = status + return self + + def sent_as(client, method: str, path: str, headers, content=None): """``method`` on ``path`` with each header's latin-1 bytes as sent, ``headers`` a mapping or a sequence of pairs, which can name a header twice. The TestClient re-encodes a header's bytes diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index cd1b32db0..cf5e4b41d 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -17,6 +17,7 @@ from backlot.graphql import mcp_tools from tests._helpers import ( SWEPT_BYTES, + WithTheKey, client_for, corpus_client, gql, @@ -114,27 +115,18 @@ def test_fireflies_reads_a_credential_as_fireflies_does(client, tokens_yaml, val assert (r.status_code, r.content) == (500, refusal) -# --- a body Fireflies reads ahead of the credential ---------------------------------------------- +# --- the body and the credential, in the order Fireflies reads them ------------------------------- # The headers a body was sent with beyond `Content-Type: application/json`, the body, and Fireflies' # answer to it with the key: `_EMPTY`, `_NOT_JSON` or `_TOO_LARGE`, its HTTP layer's refusals # `backlot.routers.fireflies._body_refusal` sends at the status their `statusCode` names, or None -# where it served the query. A `_WithTheKey` row is a body that layer passes, answered past the -# credential: one of `_auth_failed`'s 500s without the key. Those rows are as api.fireflies.ai -# answered them on 2026-10-25 (UTC), less the newline it ends each with. -class _WithTheKey(bytes): - """Fireflies' answer with the key, at ``status``, to a body Apollo Server refuses or runs.""" - - def __new__(cls, answer: bytes, status: int = 400): - self = super().__new__(cls, answer) - self.status = status - return self - - +# where it served the query. A `WithTheKey` row is a body that layer passes, which draws one of +# `_auth_failed`'s 500s without the key; its bytes are api.fireflies.ai's without the newline real +# ends them with. def _apollo( message: bytes, code: bytes = b"BAD_REQUEST", locations: bytes = b"", status: int = 400 -) -> _WithTheKey: +) -> WithTheKey: """Apollo Server's refusal as Fireflies writes it around ``message``, already JSON-escaped.""" - return _WithTheKey( + return WithTheKey( b'{"errors":[{"friendly":false,"message":"%s",%s"code":"%s","extensions":{}}]}' % (message, locations, code), status, @@ -403,54 +395,54 @@ def _apollo( ), pytest.param( b'{"query":"{ __typename }","operationName":"Z"}', - _WithTheKey( + WithTheKey( b'{"errors":[{"friendly":false,"message":"Unknown operation named \\"Z\\".","code":"OPERATION_RESOLUTION_FAILURE","extensions":{}}]}' ), id="an operationName no operation has", ), pytest.param( b'{"query":"query A { __typename }","operationName":""}', - _WithTheKey( + WithTheKey( b'{"errors":[{"friendly":false,"message":"Unknown operation named \\"\\".","code":"OPERATION_RESOLUTION_FAILURE","extensions":{}}]}' ), id="an empty operationName", ), pytest.param( b'{"query":{"kind":"Document"}}', - _WithTheKey( + WithTheKey( b'{"errors":[{"friendly":false,"message":"GraphQL queries must be strings. It looks like you\'re sending the internal graphql-js representation of a parsed query in your request instead of a request in the GraphQL query language. You can convert an AST to a string using the `print` function from `graphql`, or use a client like `apollo-client` which converts the internal representation to a string for you.","code":"BAD_REQUEST","extensions":{}}]}' ), id="a parsed document", ), pytest.param( b'{"query":false}', - _WithTheKey( + WithTheKey( b'{"errors":[{"friendly":false,"message":"GraphQL operations must contain a non-empty `query` or a `persistedQuery` extension.","code":"BAD_REQUEST","extensions":{}}]}' ), id="a query of false", ), pytest.param( b'{"query":"{ __typename }","variables":"null"}', - _WithTheKey( + WithTheKey( b'{"errors":[{"friendly":false,"message":"`variables` in a POST body should be provided as an object, not a recursively JSON-encoded string.","code":"BAD_REQUEST","extensions":{}}]}' ), id="variables of 'null'", ), pytest.param( b'{"query":"query($b: Boolean!) { __typename @include(if: $b) }","variables":"{\\"b\\":true}"}', - _WithTheKey( + WithTheKey( b'{"errors":[{"friendly":false,"message":"`variables` in a POST body should be provided as an object, not a recursively JSON-encoded string.","code":"BAD_REQUEST","extensions":{}}]}' ), id="variables of a string its operation takes", ), pytest.param( b'{"query":"mutation { __typename }"}', - _WithTheKey(b'{"data":{"__typename":"Mutation"}}', 200), + WithTheKey(b'{"data":{"__typename":"Mutation"}}', 200), id="a mutation of __typename", ), pytest.param( b'{"query":"subscription S { __typename }"}', - _WithTheKey( + WithTheKey( b'{"errors":[{"friendly":false,"message":"Schema is not configured to execute subscription operation.","locations":[{"line":1,"column":1}],"code":"INTERNAL_SERVER_ERROR","extensions":{}}],"data":null}', 200, ), @@ -458,7 +450,7 @@ def _apollo( ), pytest.param( b'{"query":"query($x: String = \\"a\\") { transcript(id: $x) { id } }","variables":{"x":null}}', - _WithTheKey( + WithTheKey( b'{"errors":[{"friendly":false,"message":"Argument \\"id\\" of non-null type \\"String!\\" must not be null.","locations":[{"line":1,"column":42}],"path":["transcript"],"code":"INTERNAL_SERVER_ERROR","extensions":{}}],"data":{"transcript":null}}', 200, ), @@ -466,15 +458,23 @@ def _apollo( ), pytest.param( b'{"query":"query($v: Boolean = true) { __typename @include(if: $v) }","variables":{"v":null}}', - _WithTheKey( + WithTheKey( b'{"errors":[{"friendly":false,"message":"Argument \\"if\\" of non-null type \\"Boolean!\\" must not be null.","locations":[{"line":1,"column":53}],"code":"INTERNAL_SERVER_ERROR","extensions":{}}],"data":null}', 200, ), id="null for a directive's argument at the root", ), + pytest.param( + b'{"query":"query($x: String = \\"a\\") # a\xc2\x85b\\n{ transcript(id: $x) { id } }","variables":{"x":null}}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Argument \\"id\\" of non-null type \\"String!\\" must not be null.","locations":[{"line":2,"column":18}],"path":["transcript"],"code":"INTERNAL_SERVER_ERROR","extensions":{}}],"data":{"transcript":null}}', + 200, + ), + id="null for a non-null argument after U+0085 in a comment", + ), pytest.param( b'{"query":"{ ...F }"}', - _WithTheKey( + WithTheKey( b'{"errors":[{"friendly":false,"message":"Internal server error","code":"INTERNAL_SERVER_ERROR","extensions":{}}]}', 500, ), @@ -482,12 +482,64 @@ def _apollo( ), pytest.param( b'{"query":"fragment F on Query { ...F } { ...F }"}', - _WithTheKey( + WithTheKey( b'{"errors":[{"friendly":false,"message":"Internal server error","code":"INTERNAL_SERVER_ERROR","extensions":{}}]}', 500, ), id="a fragment that spreads itself", ), + pytest.param( + b'{"query":"fragment G on Query { ...X } { __typename }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Unknown fragment \\"X\\".","locations":[{"line":1,"column":26}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Fragment \\"G\\" is never used.","locations":[{"line":1,"column":1}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="an undefined spread in a fragment no operation uses", + ), + pytest.param( + b'{"query":"fragment A on Query { ...B } fragment B on Query { ...A } { __typename }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Cannot spread fragment \\"A\\" within itself via \\"B\\".","locations":[{"line":1,"column":23},{"line":1,"column":52}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Fragment \\"A\\" is never used.","locations":[{"line":1,"column":1}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Fragment \\"B\\" is never used.","locations":[{"line":1,"column":30}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="a cycle among fragments no operation uses", + ), + pytest.param( + b'{"query":"{ ... on Query { ...X } }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Internal server error","code":"INTERNAL_SERVER_ERROR","extensions":{}}]}', + 500, + ), + id="an undefined spread in an inline fragment", + ), + pytest.param( + b'{"query":"query A { __typename } query B { ...X }","operationName":"A"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Internal server error","code":"INTERNAL_SERVER_ERROR","extensions":{}}]}', + 500, + ), + id="an undefined spread in an operation not chosen", + ), + pytest.param( + b'{"query":"fragment F on Query { ...X } { ...F }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Internal server error","code":"INTERNAL_SERVER_ERROR","extensions":{}}]}', + 500, + ), + id="an undefined spread in a fragment an operation uses", + ), + pytest.param( + b'{"query":"fragment F on Query { ...F } { __typename }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Cannot spread fragment \\"F\\" within itself.","locations":[{"line":1,"column":23}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Fragment \\"F\\" is never used.","locations":[{"line":1,"column":1}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="a fragment spreading itself that no operation uses", + ), + pytest.param( + b'{"query":"{ ...X"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Syntax Error: Expected Name, found .","locations":[{"line":1,"column":7}],"code":"GRAPHQL_PARSE_FAILED","extensions":{}}]}' + ), + id="an undefined spread beside a document that cannot parse", + ), ] @@ -500,7 +552,7 @@ def _apollo( "headers,body,answer", [pytest.param((), *row.values, id=row.id) for row in _FIREFLIES_BODIES] + _FIREFLIES_READS, ) -def test_fireflies_reads_the_body_before_the_credential( +def test_fireflies_orders_the_body_and_the_credential( client, admin_h, credential, headers, body, answer ): """Each row is answered as the comment over `_FIREFLIES_BODIES` says, and no credential @@ -511,7 +563,7 @@ def test_fireflies_reads_the_body_before_the_credential( key = admin_h["Authorization"] if credential == "key" else credential headers = (*headers, ("Authorization", key)) r = client.post("/fireflies/graphql", content=body, headers=list(headers)) - if answer is not None and not isinstance(answer, _WithTheKey): + if answer is not None and not isinstance(answer, WithTheKey): assert (r.status_code, r.content) == (json.loads(answer)["statusCode"], answer) elif credential != "key": assert (r.status_code, r.content) == (500, _BODY_CREDENTIALS[credential]) @@ -521,6 +573,16 @@ def test_fireflies_reads_the_body_before_the_credential( assert (r.status_code, r.json()) == (200, {"data": {"__typename": "Query"}}) +def test_fireflies_reads_a_fragment_once_however_many_spreads_reach_it(): + """A document whose twenty-six fragments each spread the next twice reaches the last one by + 2**25 paths, and `_fragments_unreached` reads each fragment once rather than once a path.""" + from backlot.routers import fireflies + + document = " ".join(f"fragment F{i} on Query {{ ...F{i + 1} ...F{i + 1} }}" for i in range(25)) + document += " fragment F25 on Query { __typename } { ...F0 }" + assert fireflies._fragments_unreached(parse(document)) is None + + def test_fireflies_admin_crawl_sees_every_stored_transcript(client, admin_h, ro_conn): r = ff_gql(client, "{ transcripts(limit: 50) { id title } }", admin_h) assert r.status_code == 200 diff --git a/tests/test_graphql.py b/tests/test_graphql.py index e70c2cca5..ae5eafaaa 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -216,26 +216,36 @@ def _crashed(_root, _info): @pytest.mark.parametrize( - "query, variables, raised", + "query, variables, raised, assigned", [ - ("{ placed }", None, False), - ("{ crashed }", None, True), - ("{ boom crashed }", None, True), + ("{ placed }", None, False, []), + ("{ crashed }", None, True, []), + ("{ boom crashed }", None, True, []), # graphql-core completing a value the schema refuses - ("{ blank }", None, True), - ("{ count }", None, True), - ("{ many }", None, True), - ("{ either { ... on Widget { id } } }", None, True), + ("{ blank }", None, True, []), + ("{ count }", None, True, [0]), + ("{ many }", None, True, [0]), + ("{ either { ... on Widget { id } } }", None, True, [0]), # graphql-core refusing, as it runs the field, an argument the request sent - ('query W($id: ID = "w1") { widget(id: $id) { name } }', {"id": None}, False), + ('query W($id: ID = "w1") { widget(id: $id) { name } }', {"id": None}, False, [0]), ( 'query W($show: Boolean = true) { widget(id: "w1") { name @include(if: $show) } }', {"show": None}, False, + [0], + ), + ( + 'query W($id: ID = "w1") { boom widget(id: $id) { name } }', + {"id": None}, + False, + [1], ), ], ) -def test_a_field_error_is_marked_raised_unless_it_refuses_the_request(query, variables, raised): +def test_a_field_error_is_marked_by_who_made_it(query, variables, raised, assigned): + """``raised`` marks Backlot's own failure, and ``assigned`` each error graphql-core raised as a + ``GraphQLError`` (:func:`backlot.graphql.engine._made_by_graphql`), which alone carries + ``INTERNAL_SERVER_ERROR``.""" gql = engine.Engine( SDL.replace( "boom: String", @@ -257,6 +267,12 @@ def test_a_field_error_is_marked_raised_unless_it_refuses_the_request(query, var ) r = gql.execute(query, variables=variables) assert (r.request_error, "errors" in r.payload, r.raised) == (False, True, raised) + assert sorted(r.assigned) == assigned + assert [ + i + for i, error in enumerate(r.payload["errors"]) + if error.get("extensions", {}).get("code") == engine.INTERNAL_SERVER_ERROR + ] == assigned # --- operation selection -------------------------------------------------------- @@ -434,7 +450,9 @@ def test_a_body_is_read_as_node_reads_it(gql, body, served): ], ) def test_a_variable_is_read_as_graphql_js_reads_it(type_, value, answer): - """Each value is read by :func:`backlot.graphql.engine._ids_as_graphql_js_reads_them`.""" + """Each value is read as graphql-js reads it: an ``ID`` by + :func:`backlot.graphql.engine._ids_as_graphql_js_reads_them`, a refusal by + :func:`backlot.graphql.js_words.coerce_variable_values`.""" query = "query($x: %s) { %s }" % (type_, _ECHO_FIELDS[type_]) body = '{"query": "%s", "variables": {"x": %s}}' % (query, value) assert _ECHO.execute_payload(request_body.parse(body)).payload == answer @@ -831,9 +849,8 @@ def _refused(message, code="BAD_REQUEST", status=400, locations=()) -> tuple: # A body, and Apollo Server's answer to it in Apollo Server's own envelope: a refusal with the -# message, code, locations and status api.fireflies.ai answered it with on 2026-10-25 (UTC), or -# `_SERVED` where it ran the query. Where two checks apply, the row's answer is the one that came -# first. +# message, code, locations and status api.fireflies.ai answered it with, or `_SERVED` where it ran +# the query. Where two checks apply, the row's answer is the one that came first. _APOLLO_CHECKS = [ pytest.param([{"query": _TYPENAME}], _refused("Operation batching disabled."), id="a batch"), pytest.param({}, _NO_KEYS, id="an empty object"), @@ -841,6 +858,8 @@ def _refused(message, code="BAD_REQUEST", status=400, locations=()) -> tuple: pytest.param(5, _NO_KEYS, id="a number"), pytest.param({"query": 5, "variables": [1]}, _NOT_A_STRING, id="query 5, variables [1]"), pytest.param({"query": 5, "extensions": 5}, _NOT_A_STRING, id="query 5, extensions 5"), + pytest.param({"query": {}}, _NOT_A_STRING, id="a query of an empty object"), + pytest.param({"query": 0}, _NO_QUERY, id="a query of 0"), pytest.param({"query": 5, "variables": "{"}, _NOT_A_STRING, id="query 5, variables '{'"), pytest.param( {"query": {"kind": "Document"}}, @@ -858,6 +877,16 @@ def _refused(message, code="BAD_REQUEST", status=400, locations=()) -> tuple: _refused(engine.RECURSIVELY_ENCODED.format("variables")), id="variables 'x', extensions 5", ), + pytest.param( + {"query": _TYPENAME, "variables": "x", "extensions": "x"}, + _refused(engine.RECURSIVELY_ENCODED.format("variables")), + id="variables 'x', extensions 'x'", + ), + pytest.param( + {"query": _TYPENAME, "extensions": "x", "variables": [1]}, + _refused(engine.RECURSIVELY_ENCODED.format("extensions")), + id="extensions 'x', variables [1]", + ), pytest.param( {"query": _TYPENAME, "extensions": "x"}, _refused(engine.RECURSIVELY_ENCODED.format("extensions")), @@ -895,6 +924,8 @@ def _refused(message, code="BAD_REQUEST", status=400, locations=()) -> tuple: ), pytest.param({"extensions": {"persistedQuery": 5}}, _VERSION, id="pq 5"), pytest.param(_persisted("1", query=_TYPENAME), _VERSION, id="pq version '1'"), + pytest.param(_persisted(True, query=_TYPENAME), _VERSION, id="pq version true"), + pytest.param(_persisted(1.0, query=_TYPENAME), _SERVED, id="pq version 1.0"), pytest.param( {"extensions": {"persistedQuery": {"version": 1}}}, _NOT_FOUND, @@ -981,6 +1012,472 @@ def test_apollo_server_reads_a_body_in_its_order(gql, payload, answer): assert (r.status, r.payload) == answer +_TOO_MANY = "Too many errors processing variables, error limit reached. Execution aborted." +_WORDS = engine.Engine( + """ + enum Color { + RED + GREEN + BLUE + BLACK + } + + enum Size { + S + M + L + XL + } + + input Box { + a: Int + b: Int + c: Int + d: Int! = 1 + } + + input Point { + x: Int! + y: Int = 0 + label: String + tags: [String!] + next: Point + } + + type Query { + int(v: Int): Int + float(v: Float): Float + string(v: String): String + boolean(v: Boolean): Boolean + id(v: ID): ID + color(v: Color): Color + point(v: Point): Int + ints(v: [Int!]): Int + nested(v: [[Int]]): Int + required(v: Int!): Int + pair(a: Int, b: String): Int + size(v: Size): Size + box(v: Box): Int + } + """ +) +_STAGES = { + "parse": engine.PARSE_FAILED, + "validate": engine.VALIDATION_FAILED, + "variables": engine.BAD_USER_INPUT, +} + + +# A document over `_WORDS`, its variables as the body sent them, and graphql-js 16.14.2's answer +# under Node: the stage that refused it and each error's message and locations, or "ok" where it +# coerced every variable. +_GRAPHQL_JS_WORDS = [ + pytest.param( + "# a\r{ nosuch }", + None, + "validate", + [('Cannot query field "nosuch" on type "Query".', [(2, 3)])], + id="a carriage return", + ), + pytest.param( + "# a\r\n{ nosuch }", + None, + "validate", + [('Cannot query field "nosuch" on type "Query".', [(2, 3)])], + id="a carriage return and a line feed", + ), + pytest.param( + "# a\x0c{ nosuch }", + None, + "parse", + [("Syntax Error: Unexpected .", [(1, 15)])], + id="a form feed in a comment", + ), + pytest.param( + "# a\u2028{ nosuch }", + None, + "parse", + [("Syntax Error: Unexpected .", [(1, 15)])], + id="a line separator in a comment", + ), + pytest.param( + '{ int(v: 1.") }', + None, + "parse", + [("Syntax Error: Invalid number, expected digit but got: '\"'.", [(1, 12)])], + id="a quote where a digit was expected", + ), + pytest.param( + "{ int(v: 1.a) }", + None, + "parse", + [('Syntax Error: Invalid number, expected digit but got: "a".', [(1, 12)])], + id="a letter where a digit was expected", + ), + pytest.param( + "{", + None, + "parse", + [("Syntax Error: Expected Name, found .", [(1, 2)])], + id="a document cut short", + ), + pytest.param( + "query($v: Int) # c\nd\n{ int(v: $v) }", + '{"v": "x"}', + "parse", + [('Syntax Error: Expected "{", found Name "d".', [(2, 1)])], + id="a comment's line break", + ), + pytest.param( + "query($v: Int! = null) { required(v: $v) }", + "{}", + "validate", + [('Expected value of type "Int!", found null.', [(1, 18)])], + id="a null default for a non-null variable", + ), + pytest.param( + '{ int(v: "a\x0bb") }', + None, + "validate", + [('Int cannot represent non-integer value: "a\\u000Bb"', [(1, 10)])], + id="a vertical tab in a string", + ), + pytest.param( + "query @include(if: true) { int }", + None, + "validate", + [('Directive "@include" may not be used on QUERY.', [(1, 7)])], + id="a directive where it may not be used", + ), + pytest.param( + "query($v: Size) { size(v: $v) }", + '{"v": ""}', + "variables", + [ + ( + 'Variable "$v" got invalid value ""; Value "" does not exist in "Size" enum. Did you mean the enum value "L", "M", or "S"?', + [(1, 7)], + ) + ], + id="an empty enum value, three suggestions and more", + ), + pytest.param( + "query($v: Size) { size(v: $v) }", + '{"v": false}', + "variables", + [ + ( + 'Variable "$v" got invalid value false; Enum "Size" cannot represent non-string value: false.', + [(1, 7)], + ) + ], + id="false for an enum", + ), + pytest.param( + "query($v: Size) { size(v: $v) }", + '{"v": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}', + "variables", + [ + ( + 'Variable "$v" got invalid value [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ... 1 more item]; Enum "Size" cannot represent non-string value: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ... 1 more item].', + [(1, 7)], + ) + ], + id="eleven items", + ), + pytest.param( + "query($v: Size) { size(v: $v) }", + '{"v": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]}', + "variables", + [ + ( + 'Variable "$v" got invalid value [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ... 2 more items]; Enum "Size" cannot represent non-string value: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ... 2 more items].', + [(1, 7)], + ) + ], + id="twelve items", + ), + pytest.param( + "query($v: Size) { size(v: $v) }", + '{"v": {"x": 1, "tags": ["a", 1, null]}}', + "variables", + [ + ( + 'Variable "$v" got invalid value { x: 1, tags: ["a", 1, null] }; Enum "Size" cannot represent non-string value: { x: 1, tags: ["a", 1, null] }.', + [(1, 7)], + ) + ], + id="an object for an enum", + ), + pytest.param( + "query($v: Box) { box(v: $v) }", + '{"v": [[[1]]]}', + "variables", + [ + ( + 'Variable "$v" got invalid value [[[Array]]]; Expected type "Box" to be an object.', + [(1, 7)], + ) + ], + id="an array three deep for an input object", + ), + pytest.param( + "query($v: Box) { box(v: $v) }", + '{"v": {"b": 1, "1": 2, "0": 3, "01": 4, "²": 5, "4294967294": 6, "4294967295": 7, "a b": 8}}', + "variables", + [ + ( + 'Variable "$v" got invalid value { 0: 3, 1: 2, 4294967294: 6, b: 1, 01: 4, ²: 5, 4294967295: 7, a b: 8 }; Field "0" is not defined by type "Box". Did you mean "a", "b", "c", or "d"?', + [(1, 7)], + ), + ( + 'Variable "$v" got invalid value { 0: 3, 1: 2, 4294967294: 6, b: 1, 01: 4, ²: 5, 4294967295: 7, a b: 8 }; Field "1" is not defined by type "Box". Did you mean "a", "b", "c", or "d"?', + [(1, 7)], + ), + ( + 'Variable "$v" got invalid value { 0: 3, 1: 2, 4294967294: 6, b: 1, 01: 4, ²: 5, 4294967295: 7, a b: 8 }; Field "4294967294" is not defined by type "Box".', + [(1, 7)], + ), + ( + 'Variable "$v" got invalid value { 0: 3, 1: 2, 4294967294: 6, b: 1, 01: 4, ²: 5, 4294967295: 7, a b: 8 }; Field "01" is not defined by type "Box".', + [(1, 7)], + ), + ( + 'Variable "$v" got invalid value { 0: 3, 1: 2, 4294967294: 6, b: 1, 01: 4, ²: 5, 4294967295: 7, a b: 8 }; Field "²" is not defined by type "Box". Did you mean "a", "b", "c", or "d"?', + [(1, 7)], + ), + ( + 'Variable "$v" got invalid value { 0: 3, 1: 2, 4294967294: 6, b: 1, 01: 4, ²: 5, 4294967295: 7, a b: 8 }; Field "4294967295" is not defined by type "Box".', + [(1, 7)], + ), + ( + 'Variable "$v" got invalid value { 0: 3, 1: 2, 4294967294: 6, b: 1, 01: 4, ²: 5, 4294967295: 7, a b: 8 }; Field "a b" is not defined by type "Box". Did you mean "a" or "b"?', + [(1, 7)], + ), + ], + id="the keys an object prints in JavaScript's order", + ), + pytest.param( + "query($v: Box) { box(v: $v) }", + '{"v": {"aa": 1, "b": "x"}}', + "variables", + [ + ( + 'Variable "$v" got invalid value "x" at "v.b"; Int cannot represent non-integer value: "x"', + [(1, 7)], + ), + ( + 'Variable "$v" got invalid value { aa: 1, b: "x" }; Field "aa" is not defined by type "Box". Did you mean "a"?', + [(1, 7)], + ), + ], + id="a field's refusal, then an unknown field", + ), + pytest.param( + "query($v: Float) { float(v: $v) }", + '{"v": 1e400}', + "variables", + [ + ( + 'Variable "$v" got invalid value Infinity; Float cannot represent non numeric value: Infinity', + [(1, 7)], + ) + ], + id="Infinity for a Float", + ), + pytest.param( + "query($v: Float) { float(v: $v) }", + '{"v": true}', + "variables", + [ + ( + 'Variable "$v" got invalid value true; Float cannot represent non numeric value: true', + [(1, 7)], + ) + ], + id="true for a Float", + ), + pytest.param( + "query($v: String) { string(v: $v) }", + '{"v": {"a": {"b": {"c": {}}}}}', + "variables", + [ + ( + 'Variable "$v" got invalid value { a: { b: [Object] } }; String cannot represent a non string value: { a: { b: [Object] } }', + [(1, 7)], + ) + ], + id="an object four deep", + ), + pytest.param( + "query($v: Boolean) { boolean(v: $v) }", + '{"v": [[[]]]}', + "variables", + [ + ( + 'Variable "$v" got invalid value [[[]]]; Boolean cannot represent a non boolean value: [[[]]]', + [(1, 7)], + ) + ], + id="an empty array three deep", + ), + pytest.param( + "query($v: ID) { id(v: $v) }", + '{"v": {"a": {"b": {}}}}', + "variables", + [ + ( + 'Variable "$v" got invalid value { a: { b: {} } }; ID cannot represent value: { a: { b: {} } }', + [(1, 7)], + ) + ], + id="an empty object three deep", + ), + pytest.param( + "query($v: Point) { point(v: $v) }", + '{"v": {}}', + "variables", + [ + ( + 'Variable "$v" got invalid value {}; Field "x" of required type "Int!" was not provided.', + [(1, 7)], + ) + ], + id="a required field not provided", + ), + pytest.param( + "query($v: Point) { point(v: $v) }", + '{"v": {"x": 1, "tags": ["a", 1, null]}}', + "variables", + [ + ( + 'Variable "$v" got invalid value 1 at "v.tags[1]"; String cannot represent a non string value: 1', + [(1, 7)], + ), + ( + 'Variable "$v" got invalid value null at "v.tags[2]"; Expected non-nullable type "String!" not to be null.', + [(1, 7)], + ), + ], + id="a list item refused, and null for a non-null item", + ), + pytest.param( + "query($v: [Int!]) { ints(v: $v) }", + '{"v": 1.5}', + "variables", + [ + ( + 'Variable "$v" got invalid value 1.5; Int cannot represent non-integer value: 1.5', + [(1, 7)], + ) + ], + id="1.5 for a list of Int", + ), + pytest.param( + "query($v: Int!) { required(v: $v) }", + '{"v": null}', + "variables", + [('Variable "$v" of non-null type "Int!" must not be null.', [(1, 7)])], + id="null for a non-null variable", + ), + pytest.param( + "query($v: Int!) { required(v: $v) }", + '{"v": 2147483648}', + "variables", + [ + ( + 'Variable "$v" got invalid value 2147483648; Int cannot represent non 32-bit signed integer value: 2147483648', + [(1, 7)], + ) + ], + id="an Int past 32 bits", + ), + pytest.param( + "query($v: [Int!]) { ints(v: $v) }", + json.dumps({"v": ["a"] * 60}), + "variables", + [ + ( + f'Variable "$v" got invalid value "a" at "v[{n}]"; Int cannot represent non-integer value: "a"', + [(1, 7)], + ) + for n in range(50) + ] + + [(_TOO_MANY, [])], + id="the fifty-first refusal", + ), + pytest.param( + "query($a: Int!, $b: String) { pair(a: $a, b: $b) }", + "{}", + "variables", + [('Variable "$a" of required type "Int!" was not provided.', [(1, 7)])], + id="a non-null variable not provided", + ), + pytest.param( + "query($v: Float) { float(v: $v) }", + '{"v": 0}', + "ok", + [], + id="0 for a Float", + ), + pytest.param( + "query($v: Boolean) { boolean(v: $v) }", + '{"v": true}', + "ok", + [], + id="true for a Boolean", + ), + pytest.param( + "query($v: ID) { id(v: $v) }", + '{"v": 0}', + "ok", + [], + id="0 for an ID", + ), + pytest.param( + "query($v: ID) { id(v: $v) }", + '{"v": ""}', + "ok", + [], + id="an empty string for an ID", + ), + pytest.param( + "query($v: Color) { color(v: $v) }", + '{"v": "RED"}', + "ok", + [], + id="an enum value", + ), + pytest.param( + "query($v: [Int!]) { ints(v: $v) }", + '{"v": null}', + "ok", + [], + id="null for a list", + ), + pytest.param( + "query($v: Int! = 1) { required(v: $v) }", + "{}", + "ok", + [], + id="a non-null variable's default", + ), +] + + +@pytest.mark.parametrize("document, variables, stage, errors", _GRAPHQL_JS_WORDS) +def test_a_refusal_is_in_graphql_js_words(document, variables, stage, errors): + r = _WORDS.execute( + document, variables=None if variables is None else request_body.parse(variables) + ) + got = [ + (refusal.message, [(at["line"], at["column"]) for at in refusal.locations]) + for refusal in r.refusals + ] + assert got == errors + assert {refusal.code for refusal in r.refusals} == ({_STAGES[stage]} if errors else set()) + + # --- resolver binding ----------------------------------------------------------- diff --git a/tests/test_linear.py b/tests/test_linear.py index 848f45ec7..5f1408b1e 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -24,6 +24,7 @@ from backlot.graphql import linear_filters, mcp_tools from tests._helpers import ( SWEPT_BYTES, + WithTheKey, build_corpus, client_for, complete, @@ -713,18 +714,8 @@ def test_linear_unauthenticated_is_401(client): # Linear refused it with for the key, no credential and `nope` alike, at the status their `code` or # `extensions.http.status` names (`_status`), or None where Linear served the query with the key and # answered 401 without one, as `backlot.graphql.body` and `backlot.graphql.v8_json` read them. A -# `_WithTheKey` row is answered past the credential. Apollo Server's envelopes are as api.linear.app -# answered them on 2026-10-25 (UTC), less the newline it ends each with. -class _WithTheKey(bytes): - """Linear's answer with the key, at ``status``, to a document it refuses or runs once the - credential is read: without one, the 401.""" - - def __new__(cls, answer: bytes, status: int = 400): - self = super().__new__(cls, answer) - self.status = status - return self - - +# `WithTheKey` row draws the 401 without the key. Apollo Server's envelopes are api.linear.app's, +# but for the newline that ends each there. def _apollo(message: bytes) -> bytes: """Apollo Server's refusal of a body as Linear writes it around ``message``, already JSON-escaped.""" @@ -1528,6 +1519,13 @@ def _status(answer: bytes) -> int: b'"headers":{}},"code":"INTERNAL_SERVER_ERROR","type":"internal error","userError":false}}]}', id="a persisted query's hash that does not match", ), + pytest.param( + b'{"query":"{ __typename }","extensions":{"persistedQuery":{"version":2,"sha256Hash":"' + + b"0" * 64 + + b'"}}}', + b'{"errors":[{"message":"Internal server error","extensions":{"http":{"status":400,"headers":{}},"code":"INTERNAL_SERVER_ERROR","type":"internal error","userError":false}}]}', + id="a persisted query's version 2", + ), pytest.param( b'{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"' + b"1" * 64 + b'"}}}', b'{"errors":[{"message":"PersistedQueryNotFound","extensions":{"http":{"status":200,' @@ -1565,7 +1563,7 @@ def _status(answer: bytes) -> int: ), pytest.param( b'{"query":"query($b: Boolean!) { __typename @include(if: $b) }"}', - _WithTheKey( + WithTheKey( b'{"errors":[{"message":"Variable \\"$b\\" of required type \\"Boolean!\\" was not ' b'provided.","locations":[{"line":1,"column":7}],"extensions":{"code":"BAD_USER_INPUT",' b'"type":"graphql error","userError":true}}]}' @@ -1609,7 +1607,7 @@ def _status(answer: bytes) -> int: ), pytest.param( b'{"query":"query($b: Boolean!) { __typename @include(if: $b) }","variables":"{\\"b\\":5}"}', - _WithTheKey( + WithTheKey( b'{"errors":[{"message":"Variable \\"$b\\" got invalid value 5; Boolean cannot represent a non boolean value: 5","locations":[{"line":1,"column":7}],"extensions":{"code":"BAD_USER_INPUT","type":"graphql error","userError":true}}]}' ), id="variables of a string its operation refuses", @@ -1621,7 +1619,7 @@ def _status(answer: bytes) -> int: ), pytest.param( b'{"query":"mutation { __typename }"}', - _WithTheKey(b'{"data":{"__typename":"Mutation"}}', 200), + WithTheKey(b'{"data":{"__typename":"Mutation"}}', 200), id="a mutation of __typename", ), pytest.param( @@ -1631,7 +1629,7 @@ def _status(answer: bytes) -> int: ), pytest.param( b'{"query":"query($x: String = \\"a\\") { issue(id: $x) { id } }","variables":{"x":null}}', - _WithTheKey( + WithTheKey( b'{"errors":[{"message":"Internal server error","path":["issue"],"locations":[{"line":1,"column":37}],"extensions":{"code":"INTERNAL_SERVER_ERROR","type":"internal error","userError":false}}],"data":null}', 200, ), @@ -1639,12 +1637,44 @@ def _status(answer: bytes) -> int: ), pytest.param( b'{"query":"query($v: Boolean = true) { __typename @include(if: $v) }","variables":{"v":null}}', - _WithTheKey( + WithTheKey( b'{"errors":[{"message":"Internal server error","locations":[{"line":1,"column":53}],"extensions":{"code":"INTERNAL_SERVER_ERROR","type":"internal error","userError":false}}],"data":null}', 200, ), id="null for a directive's argument at the root", ), + pytest.param( + b'{"query":"query($x: String = \\"a\\") { team(id: $x) { id } }","variables":{"x":null}}', + WithTheKey( + b'{"errors":[{"message":"Internal server error","path":["team"],"locations":[{"line":1,"column":36}],"extensions":{"code":"INTERNAL_SERVER_ERROR","type":"internal error","userError":false}}],"data":null}', + 200, + ), + id="null for team's non-null id", + ), + pytest.param( + b'{"query":"query($v: ID = \\"a\\") { issues(filter: {id: {in: [$v]}}) { nodes { id } } }","variables":{"v":null}}', + WithTheKey( + b'{"errors":[{"message":"Internal server error","path":["issues"],"locations":[{"line":1,"column":38}],"extensions":{"code":"INTERNAL_SERVER_ERROR","type":"internal error","userError":false}}],"data":null}', + 200, + ), + id="null inside a filter's list", + ), + pytest.param( + b'{"query":"query($v: Boolean = true) { viewer { id @include(if: $v) } }","variables":{"v":null}}', + WithTheKey( + b'{"errors":[{"message":"Internal server error","path":["viewer"],"locations":[{"line":1,"column":54}],"extensions":{"code":"INTERNAL_SERVER_ERROR","type":"internal error","userError":false}}],"data":null}', + 200, + ), + id="null for a directive one level down", + ), + pytest.param( + b'{"query":"query($x: String = \\"a\\") # a\xe2\x80\xa8b\\n{ issue(id: $x) { id } }","variables":{"x":null}}', + WithTheKey( + b'{"errors":[{"message":"Internal server error","path":["issue"],"locations":[{"line":2,"column":13}],"extensions":{"code":"INTERNAL_SERVER_ERROR","type":"internal error","userError":false}}],"data":null}', + 200, + ), + id="null for a non-null argument after U+2028 in a comment", + ), pytest.param( b'{"query":"{ ...F }"}', b'{"errors":[{"message":"Unknown fragment \\"F\\".","locations":[{"line":1,"column":6}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}', @@ -2015,11 +2045,11 @@ def test_linear_orders_the_body_and_the_credential( r = sent_as(client, "POST", "/linear/graphql", headers, content=body) if credential.startswith("Bearer"): assert (r.status_code, r.content) == (400, _API_KEY_AS_BEARER) - elif credential != "key" and (answer is None or isinstance(answer, _WithTheKey)): + elif credential != "key" and (answer is None or isinstance(answer, WithTheKey)): assert r.status_code == 401 elif answer is None: assert (r.status_code, r.json()) == (200, {"data": {"__typename": "Query"}}) - elif isinstance(answer, _WithTheKey): + elif isinstance(answer, WithTheKey): assert (r.status_code, r.content) == (answer.status, answer) else: assert (r.status_code, r.content) == (_status(answer), answer) From 2ed42d35b36fdfdd95d0e0f16c20c53c13b2c6f3 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:05:22 +0900 Subject: [PATCH 03/52] graphql: a persisted query sent with a query that is not a string is looked up Both vendors answered a hash sent with `query` false or 0 as with no query at all, PersistedQueryNotFound at 200, and hashed an empty string. --- backlot/graphql/engine.py | 16 +++++++--------- backlot/graphql/js_words.py | 2 +- tests/test_fireflies.py | 5 +++-- tests/test_graphql.py | 10 +++++++--- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index b7a173a02..bd001c404 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -259,9 +259,10 @@ def body_refusal(payload: Any) -> Result | None: def persisted_query_refusal(payload: dict) -> Result | None: """Apollo Server's answer to the ``persistedQuery`` extension of ``payload``, or ``None`` for a request it runs. Both vendors answered on 2026-10-25 (UTC) as a server that keeps no query: - a hash sent alone was ``PersistedQueryNotFound`` at 200 even after the query that hashes to it - had been sent with it and served. A version other than the number 1 is refused, then a hash - that is not the SHA-256 of the query sent, in lower-case hex, before the query is parsed.""" + a hash sent with no ``query``, or with ``null``, ``false`` or ``0``, was + ``PersistedQueryNotFound`` at 200, even after the query that hashes to it had been sent with it + and served. A version other than the number 1 is refused, then a hash that is not the SHA-256 + of the query sent, in lower-case hex, before the query is parsed.""" extensions = payload.get("extensions") persisted = extensions.get("persistedQuery") if isinstance(extensions, dict) else None if not _js_truthy(persisted): @@ -270,15 +271,12 @@ def persisted_query_refusal(payload: dict) -> Result | None: if isinstance(version, bool) or version != 1: return _refusal(PERSISTED_QUERY_VERSION, INTERNAL_SERVER_ERROR) query = payload.get("query") - if query is None: + if not isinstance(query, str): return _refusal("PersistedQueryNotFound", PERSISTED_QUERY_NOT_FOUND, status=200) # Both vendors hashed a query holding U+D800 as the same query with U+FFFD in its place on # 2026-10-25 (UTC), the UTF-8 Node writes for a lone surrogate. - digest = None - if isinstance(query, str): - units = query.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "replace") - digest = hashlib.sha256(units.encode("utf-8")).hexdigest() - if digest is None or persisted.get("sha256Hash") != digest: + units = query.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "replace") + if persisted.get("sha256Hash") != hashlib.sha256(units.encode("utf-8")).hexdigest(): return _refusal(PERSISTED_QUERY_MISMATCH, INTERNAL_SERVER_ERROR) return None diff --git a/backlot/graphql/js_words.py b/backlot/graphql/js_words.py index 8077ecb91..acde641f0 100644 --- a/backlot/graphql/js_words.py +++ b/backlot/graphql/js_words.py @@ -234,7 +234,7 @@ def _built_in_refusal(name: str, value: Any) -> str | None: elif name == "Boolean": if not isinstance(value, bool): return f"Boolean cannot represent a non boolean value: {inspect(value)}" - elif name == "ID" and not (isinstance(value, str) or integral): + elif not (isinstance(value, str) or integral): # ID, the last of `_BUILT_IN` return f"ID cannot represent value: {inspect(value)}" return None diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index cf5e4b41d..062ae3b0a 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -574,8 +574,9 @@ def test_fireflies_orders_the_body_and_the_credential( def test_fireflies_reads_a_fragment_once_however_many_spreads_reach_it(): - """A document whose twenty-six fragments each spread the next twice reaches the last one by - 2**25 paths, and `_fragments_unreached` reads each fragment once rather than once a path.""" + """A document whose first twenty-five fragments each spread the next one twice reaches the + twenty-sixth by 2**25 paths, and `_fragments_unreached` reads each fragment once rather than + once a path.""" from backlot.routers import fireflies document = " ".join(f"fragment F{i} on Query {{ ...F{i + 1} ...F{i + 1} }}" for i in range(25)) diff --git a/tests/test_graphql.py b/tests/test_graphql.py index ae5eafaaa..0166dbe28 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -243,9 +243,6 @@ def _crashed(_root, _info): ], ) def test_a_field_error_is_marked_by_who_made_it(query, variables, raised, assigned): - """``raised`` marks Backlot's own failure, and ``assigned`` each error graphql-core raised as a - ``GraphQLError`` (:func:`backlot.graphql.engine._made_by_graphql`), which alone carries - ``INTERNAL_SERVER_ERROR``.""" gql = engine.Engine( SDL.replace( "boom: String", @@ -939,6 +936,13 @@ def _refused(message, code="BAD_REQUEST", status=400, locations=()) -> tuple: pytest.param(_persisted(sha256=_HASH.upper(), query=_TYPENAME), _MISMATCH, id="pq upper hash"), pytest.param(_persisted(sha256=5, query=_TYPENAME), _MISMATCH, id="pq hash 5"), pytest.param(_persisted(query=""), _MISMATCH, id="pq, an empty query"), + pytest.param(_persisted(query=False), _NOT_FOUND, id="pq, query false"), + pytest.param(_persisted(query=0), _NOT_FOUND, id="pq, query 0"), + pytest.param( + {"query": False, "extensions": {"persistedQuery": {"version": 1}}}, + _NOT_FOUND, + id="pq with no hash, query false", + ), pytest.param( _persisted(query=None), _NOT_FOUND, From d444ad17c5d726795df1d22aaf678d63a5d890a5 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:11:22 +0900 Subject: [PATCH 04/52] graphql: an operation of a root type the schema lacks is a refusal, not Backlot's failure Fireflies has no subscription type, and graphql-core refuses a subscription with the operation as the error's node. That is the request's fault, so the call is counted `client`, as the 200 Fireflies answers with says. --- backlot/graphql/engine.py | 17 +++++++++-------- tests/test_graphql.py | 3 +++ tests/test_telemetry.py | 9 ++++++++- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index bd001c404..9359643f8 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -172,15 +172,16 @@ def _raised(error: GraphQLError) -> bool: cannot serialize. A ``GraphQLError`` a resolver raises (:func:`_marking_refusals` marks it) is a refusal, and so is graphql-core's refusal of an argument the request sent, which it makes as it runs the field, such as a null variable, declared with a default, where the field or a directive - on it takes a non-null argument. graphql-core gives that refusal the argument's value as its - node.""" + on it takes a non-null argument, and its refusal of an operation of a type the schema does not + have, such as a subscription to Fireflies. graphql-core gives the first the argument's value as + its node and the second the operation.""" original = error.original_error if getattr(error, "refusal", False) or getattr(original, "refusal", False): return False + made = original if isinstance(original, GraphQLError) else error return not ( - isinstance(original, GraphQLError) - and original.nodes - and all(isinstance(node, ValueNode) for node in original.nodes) + made.nodes + and all(isinstance(node, (ValueNode, OperationDefinitionNode)) for node in made.nodes) ) @@ -203,8 +204,8 @@ def _refusal(message: str, code: str = BAD_REQUEST, status: int = 400) -> Result def _js_truthy(value: Any) -> bool: """Whether JavaScript reads the JSON value ``value`` as true: all but ``null``, ``false``, - ``""`` and zero.""" - return value not in (None, False, "", 0) + ``""`` and zero, the last of which ``false`` equals in Python.""" + return value not in (None, "", 0) _NOT_A_STRING = "GraphQL queries must be strings." @@ -305,7 +306,7 @@ def _with_roots(schema: GraphQLSchema, roots: tuple[str, ...]) -> GraphQLSchema: query=schema.query_type, mutation=GraphQLObjectType("Mutation", {}) if "Mutation" in roots else None, subscription=GraphQLObjectType("Subscription", {}) if "Subscription" in roots else None, - types=[t for name, t in schema.type_map.items() if not name.startswith("__")], + types=list(schema.type_map.values()), directives=schema.directives, ) shadow._validation_errors = [] diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 0166dbe28..a7f3afa33 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -240,6 +240,9 @@ def _crashed(_root, _info): False, [1], ), + # graphql-core refusing an operation of a type the schema does not have + ("mutation { __typename }", None, False, [0]), + ("subscription { __typename }", None, False, [0]), ], ) def test_a_field_error_is_marked_by_who_made_it(query, variables, raised, assigned): diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 218c2397c..fc73019d8 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -541,7 +541,14 @@ def test_an_error_code_is_the_envelopes_own_identifier(source, status, body, exp {"headers": JSON, "content": b"x" * (1024 * 1024 + 1)}, ("fireflies", "unparsed", "unknown", 413, "http", "client"), ), - # Fireflies' 500 for a fragment it does not define is its answer, not a Backlot bug. + # Fireflies' refusal of a subscription, a 200, and its 500 for a fragment it does not + # define, are its answers, not Backlot bugs. + ( + "POST", + "/fireflies/graphql", + {"json": {"query": "subscription { __typename }"}}, + ("fireflies", "subscription", "unknown", 200, "INTERNAL_SERVER_ERROR", "client"), + ), ( "POST", "/fireflies/graphql", From e1f653f3e4932230a9f667ca12d85b6594e2d589 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:20:29 +0900 Subject: [PATCH 05/52] fireflies: the fragment walk's test bounds its time, so a walk once a path fails it --- tests/test_fireflies.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index 062ae3b0a..b20ed68dc 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -9,6 +9,7 @@ import gzip import json import sqlite3 +import time import pytest from graphql import build_client_schema, parse, validate @@ -581,7 +582,9 @@ def test_fireflies_reads_a_fragment_once_however_many_spreads_reach_it(): document = " ".join(f"fragment F{i} on Query {{ ...F{i + 1} ...F{i + 1} }}" for i in range(25)) document += " fragment F25 on Query { __typename } { ...F0 }" + started = time.perf_counter() assert fireflies._fragments_unreached(parse(document)) is None + assert time.perf_counter() - started < 1 def test_fireflies_admin_crawl_sees_every_stored_transcript(client, admin_h, ro_conn): From 0112ccbd476c6059e4d59e36a9ca0fdb432d6164 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:29:54 +0900 Subject: [PATCH 06/52] graphql: requote keeps the text after the first semicolon outside a literal, and a refusal is read off the field error's own nodes --- backlot/graphql/engine.py | 7 +++---- backlot/graphql/js_words.py | 34 +++++++++++++--------------------- tests/test_graphql.py | 9 ++++++++- 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index 9359643f8..47b1cf500 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -174,14 +174,13 @@ def _raised(error: GraphQLError) -> bool: runs the field, such as a null variable, declared with a default, where the field or a directive on it takes a non-null argument, and its refusal of an operation of a type the schema does not have, such as a subscription to Fireflies. graphql-core gives the first the argument's value as - its node and the second the operation.""" + its node, which the field error keeps, and the second the operation.""" original = error.original_error if getattr(error, "refusal", False) or getattr(original, "refusal", False): return False - made = original if isinstance(original, GraphQLError) else error return not ( - made.nodes - and all(isinstance(node, (ValueNode, OperationDefinitionNode)) for node in made.nodes) + error.nodes + and all(isinstance(node, (ValueNode, OperationDefinitionNode)) for node in error.nodes) ) diff --git a/backlot/graphql/js_words.py b/backlot/graphql/js_words.py index acde641f0..d2922306b 100644 --- a/backlot/graphql/js_words.py +++ b/backlot/graphql/js_words.py @@ -184,29 +184,21 @@ def requote(message: str) -> str: place = _DIRECTIVE_PLACE.search(message) if place: message = message[: place.start(2)] + _PLACE_NAMES[place.group(2)] + place.group(3) - parts = [] - last = 0 - tail = "" + # The text between the string literals in ``message``, each ``(start, end)``. + gaps, last = [], 0 for literal in _LITERAL.finditer(message): - parts.append(message[last : literal.start()]) - parts.append(literal) + gaps.append((last, literal.start())) last = literal.end() - parts.append(message[last:]) - if message.startswith("Expected value of type '"): - # A custom scalar's own refusal of a literal follows the `; ` graphql-js wraps it in, in - # the scalar's words: Linear's date scalars quote with `'` there (`kind 'IntValue'`). - for i, part in enumerate(parts): - if isinstance(part, str) and "; " in part: - head, rest = part.split("; ", 1) - parts[i] = head + "; " - tail = rest + "".join( - p if isinstance(p, str) else p.group() for p in parts[i + 1 :] - ) - parts = parts[: i + 1] - break - return ( - "".join(_QUOTED.sub(r'"\1"', p) if isinstance(p, str) else p.group() for p in parts) + tail - ) + gaps.append((last, len(message))) + # A custom scalar's refusal of a literal follows the first `; ` outside a literal, the one + # message graphql-core writes with a `; `, in the scalar's own words: Linear's date scalars + # quote with `'` there (`kind 'IntValue'`). + cut = next((message.index("; ", a, b) for a, b in gaps if "; " in message[a:b]), len(message)) + head, out, last = message[:cut], [], 0 + for literal in _LITERAL.finditer(head): + out.append(_QUOTED.sub(r'"\1"', head[last : literal.start()]) + literal.group()) + last = literal.end() + return "".join(out) + _QUOTED.sub(r'"\1"', head[last:]) + message[cut:] # --- a variable's refusals, as graphql-js makes them ----------------------------------------- diff --git a/tests/test_graphql.py b/tests/test_graphql.py index a7f3afa33..834d64dc9 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -240,7 +240,7 @@ def _crashed(_root, _info): False, [1], ), - # graphql-core refusing an operation of a type the schema does not have + # an operation on a root type the test schema has none of ("mutation { __typename }", None, False, [0]), ("subscription { __typename }", None, False, [0]), ], @@ -1156,6 +1156,13 @@ def test_apollo_server_reads_a_body_in_its_order(gql, payload, answer): [('Directive "@include" may not be used on QUERY.', [(1, 7)])], id="a directive where it may not be used", ), + pytest.param( + "{ int(v: \"'a'\") }", + None, + "validate", + [("Int cannot represent non-integer value: \"'a'\"", [(1, 10)])], + id="a string literal holding quoted text", + ), pytest.param( "query($v: Size) { size(v: $v) }", '{"v": ""}', From b5e29f13117488dcfb289ed0c1280d505aa1814d Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:10:14 +0900 Subject: [PATCH 07/52] linear: introspection is answered without a credential, the 401 is Linear's, and a not-found carries Linear's members Linear answers a document whose root fields are all __schema or __type with no credential, and its 401 carries seven extensions members. A by-id root given an id it holds nothing for is "Entity not found: " with INPUT_ERROR and the presentable text in extensions, and a field error's members come in Linear's order, path before locations. --- backlot/graphql/linear_resolvers.py | 49 ++++-- backlot/routers/linear.py | 81 ++++++++-- backlot/telemetry/errors.py | 15 +- tests/test_linear.py | 238 +++++++++++++++++++++++++--- tests/test_telemetry.py | 15 +- 5 files changed, 330 insertions(+), 68 deletions(-) diff --git a/backlot/graphql/linear_resolvers.py b/backlot/graphql/linear_resolvers.py index f54b3ab33..10a055efc 100644 --- a/backlot/graphql/linear_resolvers.py +++ b/backlot/graphql/linear_resolvers.py @@ -1175,6 +1175,27 @@ def resolve_documents(_root, info, first=None, after=None, last=None, before=Non return _list_page([], first, after, last, before) +def _not_found(entity: str) -> GraphQLError: + """Linear's error for a by-id root given an id it holds no ``entity`` for, as api.linear.app + answered each of ``issue``, ``team``, ``user``, ``workflowState``, ``project``, ``issueLabel``, + ``cycle``, ``release``, ``issueRelation`` and ``teamMembership`` with an unknown UUID and with + ``nope`` on 2026-10-25 (UTC). ``attachment`` answered in words of its own (``_ATTACHMENT``).""" + return _input_error(f"Entity not found: {entity}", f"Could not find referenced {entity}.") + + +def _input_error(message: str, presentable: str) -> GraphQLError: + return GraphQLError( + message, + extensions={ + "type": "invalid input", + "code": "INPUT_ERROR", + "statusCode": 400, + "userError": True, + "userPresentableMessage": presentable, + }, + ) + + def resolve_issue(_root, info, id): """``issue(id:)`` takes a UUID or a human identifier, as the real API does. Linear declares this non-null, so a miss is an error rather than a null — the same thing the real API does @@ -1185,7 +1206,7 @@ def resolve_issue(_root, info, id): if row is None: row = store.linear_issue_by_identifier(conn, str(id), visible_ids=visible) if row is None: - raise GraphQLError(f"Entity not found: Issue - Could not find referenced Issue. id={id}") + raise _not_found("Issue") return _issue(row, info) @@ -1224,7 +1245,7 @@ def resolve_team(_root, info, id): ): container = None if container is None: - raise GraphQLError(f"Entity not found: Team - Could not find referenced Team. id={id}") + raise _not_found("Team") return _team(container, info) @@ -1384,9 +1405,7 @@ def _by_id(info, kind: str, id_value, entity: str, *, unstored=None): ): found = None # exists in the corpus, but not for this caller — answer as if absent if found is None: - raise GraphQLError( - f"Entity not found: {entity} - Could not find referenced {entity}. id={id_value}" - ) + raise _not_found(entity) return found @@ -1429,15 +1448,21 @@ def resolve_release(_root, info, id) -> dict: return _release(_by_id(info, "release", id, "Release"), info) +# What api.linear.app answered `attachment(id:)` with for an unknown UUID and for `nope` on +# 2026-10-25 (UTC): its message and presentable text speak of a url either way. +_ATTACHMENT = ( + "Entity not found: Attachment: could not find by url.", + "Could not find referenced attachment with this url.", +) + + def resolve_attachment(_root, info, id) -> dict: """Attachments live in their own table, so this resolves by row id and scopes on the parent issue rather than through the name-keyed entities the other roots use.""" ctx = _ctx(info) row = store.linear_attachment_by_id(ctx["conn"], str(id), visible_ids=ctx["visible_ids"]) if row is None: - raise GraphQLError( - f"Entity not found: Attachment - Could not find referenced Attachment. id={id}" - ) + raise _input_error(*_ATTACHMENT) return _attachment(row, info) @@ -1445,9 +1470,7 @@ def resolve_issue_relation(_root, info, id) -> dict: ctx = _ctx(info) row = store.linear_relation_by_id(ctx["conn"], str(id), visible_ids=ctx["visible_ids"]) if row is None: - raise GraphQLError( - f"Entity not found: IssueRelation - Could not find referenced IssueRelation. id={id}" - ) + raise _not_found("IssueRelation") return _relation(row, info) @@ -1582,7 +1605,7 @@ def resolve_team_membership(_root, info, id) -> dict: if synth.linear_team_membership_id(container, user_id) == str(id).lower(): user = _user(row["email"], row["display_name"], info) return _membership(container, user, info) - raise GraphQLError("Entity not found: TeamMembership") + raise _not_found("TeamMembership") def resolve_team_membership_of(team, info, userId) -> dict | None: @@ -1890,7 +1913,7 @@ def _issue_by_id(info, issue_id): ctx = _ctx(info) row = store.get_document(ctx["conn"], "linear", issue_id, visible_ids=ctx["visible_ids"]) if row is None: - raise GraphQLError("Entity not found: Issue - Could not find referenced Issue.") + raise _not_found("Issue") return _issue(row, info) diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 108a79b3a..70cc8986b 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -35,6 +35,7 @@ from fastapi import APIRouter, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import JSONResponse, Response +from graphql import FieldNode, FragmentDefinitionNode, FragmentSpreadNode from backlot import auth from backlot.config import get_settings @@ -122,21 +123,10 @@ async def graphql(request: Request): return _answer(request, resolved, payload) caller = auth.resolve_api_key(request) if caller is None: - # Real Linear answers a bad credential with a GraphQL error envelope and a 401, not a - # framework 403 — clients parse `errors[0].message`. - return JSONResponse( - { - "errors": [ - { - "message": "Authentication required", - "extensions": { - "type": "authentication error", - "userPresentableMessage": "Invalid API key", - }, - } - ] - }, - status_code=401, + if _introspection_only(resolved): + return _answer(request, ENGINE.run(resolved), payload) + return Response( + request_body.node_json(_UNAUTHENTICATED), status_code=401, media_type="application/json" ) state = request.app.state context = { @@ -155,6 +145,54 @@ async def graphql(request: Request): return _answer(request, ENGINE.run(resolved, context=context), payload) +# Linear's 401, as api.linear.app answered `{ viewer { id } }` with no `Authorization`, with +# `Bearer nope`, `nope`, `Bearer` and `Basic Zm9vOmJhcg==` on 2026-10-25 (UTC). +_UNAUTHENTICATED = { + "errors": [ + { + "message": "Authentication required, not authenticated", + "extensions": { + "type": "authentication error", + "code": "AUTHENTICATION_ERROR", + "statusCode": 401, + "userError": True, + "userPresentableMessage": "You need to authenticate to access this operation.", + "meta": {}, + "http": {"status": 401}, + }, + } + ] +} +_INTROSPECTION = frozenset({"__schema", "__type"}) + + +def _introspection_only(operation: engine.Operation) -> bool: + """Whether each root field ``operation`` selects, through its fragments and whatever their + directives say, is ``__schema`` or ``__type``. Linear answered such a document with no + credential on 2026-10-25 (UTC), aliased, in a named or an inline fragment, under ``@skip(if: + false)`` and as the operation ``operationName`` chose, and answered the 401 to one that also + selects ``__typename`` or a field it skips.""" + fragments = { + d.name.value: d + for d in operation.document.definitions + if isinstance(d, FragmentDefinitionNode) + } + + def only(selections) -> bool: + for selection in selections: + if isinstance(selection, FieldNode): + if selection.name.value not in _INTROSPECTION: + return False + elif isinstance(selection, FragmentSpreadNode): + if not only(fragments[selection.name.value].selection_set.selections): + return False + elif not only(selection.selection_set.selections): + return False + return True + + return only(operation.operation.selection_set.selections) + + def _answer(request: Request, result: engine.Result, payload: Any) -> Response: request.state.graphql_raised = result.raised return Response( @@ -259,15 +297,24 @@ def _envelope(result: engine.Result, payload: Any) -> dict: _apollo(refusal.message, refusal.code, refusal.status, refusal.locations) ) return {"errors": errors} - if not result.assigned: + if "errors" not in result.payload: return result.payload errors = [ - _internal(error) if i in result.assigned else error + _internal(error) if i in result.assigned else _in_linears_order(error) for i, error in enumerate(result.payload["errors"]) ] return {**result.payload, "errors": errors} +def _in_linears_order(error: dict) -> dict: + """``error``'s members in the order Linear writes a field error's: api.linear.app put ``path`` + before ``locations`` in each by-id root's not-found and in an ``Argument Validation Error`` on + 2026-10-25 (UTC).""" + return { + key: error[key] for key in ("message", "path", "locations", "extensions") if key in error + } + + def _read(raw: bytes, content_type: str | None, content_encoding: str) -> Any: """The payload Linear reads from the body ``raw``, or the response this router refuses it with. diff --git a/backlot/telemetry/errors.py b/backlot/telemetry/errors.py index 81f4ce71b..f989652e8 100644 --- a/backlot/telemetry/errors.py +++ b/backlot/telemetry/errors.py @@ -3,11 +3,10 @@ An error code is the vendor's own identifier where its envelope has one: Slack's ``error``, S3's ````, Google's ``reason`` or else its ``status``, Notion's ``code``, HubSpot's ``category``, Microsoft Graph's ``error.code``, a GraphQL error's ``extensions.code``, or the ``code`` Fireflies -writes beside the message. GitHub, Jira, Confluence, Graph's OData validator, Linear's 401 and the -Linear resolvers' refusals that carry no code answer with a message only; those messages are -Backlot's own text, so each maps to a fixed slug here and the message itself, which can carry a -key, a sha or a field name from the request, is never sent. A message this table does not know is -``unrecognized``. +writes beside the message. GitHub, Jira, Confluence, Graph's OData validator and the Linear +resolvers' refusals that carry no code answer with a message only; those messages are Backlot's own +text, so each maps to a fixed slug here and the message itself, which can carry a key, a sha or a +field name from the request, is never sent. A message this table does not know is ``unrecognized``. The HTTP layer in front of Linear's and Fireflies' GraphQL refuses a body in an envelope of its own, with no ``errors``. Fireflies' HTTP layer names its code in ``code``. Linear's is Express, which @@ -91,11 +90,7 @@ (r"Failed to convert value|cannot be less than zero", "invalid_parameter"), (r"Cannot find custom content type", "content_type_not_found"), ] -_GRAPHQL = [ - (r"^Entity not found", "entity_not_found"), - (r"^unsupported ", "unsupported_filter"), - (r"^Authentication required", "authentication_required"), -] +_GRAPHQL = [(r"^unsupported ", "unsupported_filter")] # Graph's OData validator answers with an empty `error.code`; its message names the option. _MSGRAPH = [(r"^The query specified in the URI is not valid\.", "invalid_query_option")] _S3_CODE = re.compile(rb"([A-Za-z0-9_.-]{1,64})") diff --git a/tests/test_linear.py b/tests/test_linear.py index 5f1408b1e..67a67fcc4 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -22,6 +22,7 @@ from backlot import synth from backlot.fidelity.graphql_diff import backlot_schema from backlot.graphql import linear_filters, mcp_tools +from backlot.graphql.body import node_json from tests._helpers import ( SWEPT_BYTES, WithTheKey, @@ -180,19 +181,6 @@ def test_linear_comment_url_is_the_issue_url_with_the_ids_first_eight(client, ad assert same[0]["url"] == from_comment["url"] -def test_linear_missing_issue_is_a_field_error_not_a_400(client, admin_h): - """Linear declares `issue` non-null, so a miss nulls `data` and reports an error — but the - request itself was fine, so the status stays 200.""" - r = gql(client, '{ issue(id: "NOPE-1") { identifier } }', admin_h) - assert r.status_code == 200 - assert r.content.startswith(b'{"errors":[') - assert r.json()["data"] is None - assert "Entity not found" in r.json()["errors"][0]["message"] - # control: an issue that exists still opens with "data" - ok = gql(client, '{ issue(id: "ENG-101") { identifier } }', admin_h) - assert ok.content.startswith(b'{"data":') - - def test_linear_team_resolves_by_key_and_uuid(client, admin_h, ro_conn): key = gql(client, '{ team(id: "ENG") { id key name } }', admin_h).json()["data"]["team"] assert (key["key"], key["name"]) == ("ENG", "engineering") @@ -593,6 +581,113 @@ def test_linear_by_id_relation_roots_answer(client, admin_h): assert label["groupType"] is None +# Each by-id root, an id it holds nothing for, and the message and presentable text of Linear's +# error for it (`linear_resolvers._not_found`, `linear_resolvers._ATTACHMENT`). +_UNKNOWN_IDS = [ + pytest.param( + "issue", + "00000000-0000-0000-0000-000000000000", + "Entity not found: Issue", + "Could not find referenced Issue.", + id="issue 00000000", + ), + pytest.param( + "issue", + "NOPE-1", + "Entity not found: Issue", + "Could not find referenced Issue.", + id="issue NOPE-1", + ), + pytest.param( + "team", "nope", "Entity not found: Team", "Could not find referenced Team.", id="team nope" + ), + pytest.param( + "user", + "00000000-0000-0000-0000-000000000000", + "Entity not found: User", + "Could not find referenced User.", + id="user 00000000", + ), + pytest.param( + "workflowState", + "nope", + "Entity not found: WorkflowState", + "Could not find referenced WorkflowState.", + id="workflowState nope", + ), + pytest.param( + "project", + "00000000-0000-0000-0000-000000000000", + "Entity not found: Project", + "Could not find referenced Project.", + id="project 00000000", + ), + pytest.param( + "issueLabel", + "nope", + "Entity not found: IssueLabel", + "Could not find referenced IssueLabel.", + id="issueLabel nope", + ), + pytest.param( + "cycle", + "00000000-0000-0000-0000-000000000000", + "Entity not found: Cycle", + "Could not find referenced Cycle.", + id="cycle 00000000", + ), + pytest.param( + "release", + "nope", + "Entity not found: Release", + "Could not find referenced Release.", + id="release nope", + ), + pytest.param( + "issueRelation", + "nope", + "Entity not found: IssueRelation", + "Could not find referenced IssueRelation.", + id="issueRelation nope", + ), + pytest.param( + "teamMembership", + "00000000-0000-0000-0000-000000000000", + "Entity not found: TeamMembership", + "Could not find referenced TeamMembership.", + id="teamMembership 00000000", + ), + pytest.param( + "attachment", + "00000000-0000-0000-0000-000000000000", + "Entity not found: Attachment: could not find by url.", + "Could not find referenced attachment with this url.", + id="attachment 00000000", + ), +] + + +@pytest.mark.parametrize("root, id_, message, presentable", _UNKNOWN_IDS) +def test_linear_answers_an_id_it_does_not_hold_as_linear_does( + client, admin_h, root, id_, message, presentable +): + body = {"query": "query($x: String!) { %s(id: $x) { id } }" % root, "variables": {"x": id_}} + r = client.post("/linear/graphql", json=body, headers=admin_h) + error = { + "message": message, + "path": [root], + "locations": [{"line": 1, "column": 22}], + "extensions": { + "type": "invalid input", + "code": "INPUT_ERROR", + "statusCode": 400, + "userError": True, + "userPresentableMessage": presentable, + }, + } + assert (r.status_code, r.content) == (200, node_json({"errors": [error], "data": None})) + + def test_linear_workflow_states_are_per_team(client, admin_h): """Two teams' identically-named states are different objects in Linear, so their ids differ. The corpus has no shared state name, so assert the construction directly instead.""" @@ -703,15 +798,9 @@ def test_linear_malformed_document_is_a_400_with_a_graphql_envelope(client, admi assert "Syntax Error" in body["errors"][0]["message"] -def test_linear_unauthenticated_is_401(client): - r = client.post("/linear/graphql", json={"query": "{ viewer { email } }"}) - assert r.status_code == 401 - assert r.json()["errors"][0]["message"] == "Authentication required" - - # --- the body, and where the credential is read against it --------------------------------------- # The headers a body was sent with beyond `Content-Type: application/json`, the body, and the bytes -# Linear refused it with for the key, no credential and `nope` alike, at the status their `code` or +# Linear answered it with for the key, no credential and `nope` alike, at the status their `code` or # `extensions.http.status` names (`_status`), or None where Linear served the query with the key and # answered 401 without one, as `backlot.graphql.body` and `backlot.graphql.v8_json` read them. A # `WithTheKey` row draws the 401 without the key. Apollo Server's envelopes are api.linear.app's, @@ -729,7 +818,9 @@ def _status(answer: bytes) -> int: envelope = json.loads(answer) if "code" in envelope: return envelope["code"] - return envelope["errors"][0]["extensions"]["http"]["status"] + if "errors" not in envelope: + return 200 + return envelope["errors"][0]["extensions"].get("http", {}).get("status", 400) _NO_KEYS = _apollo(b"POST body missing, invalid Content-Type, or JSON object has no keys.") @@ -1675,6 +1766,97 @@ def _status(answer: bytes) -> int: ), id="null for a non-null argument after U+2028 in a comment", ), + pytest.param( + b'{"query":"{ __schema { queryType { name } } }"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', + id="introspection", + ), + pytest.param( + b'{"query":"{ __type(name: \\"Query\\") { name } }"}', + b'{"data":{"__type":{"name":"Query"}}}', + id="__type", + ), + pytest.param( + b'{"query":"{ s: __schema { queryType { name } } }"}', + b'{"data":{"s":{"queryType":{"name":"Query"}}}}', + id="an alias of __schema", + ), + pytest.param( + b'{"query":"{ __schema { queryType { name } } __type(name: \\"Issue\\") { name } }"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}},"__type":{"name":"Issue"}}}', + id="__schema and __type", + ), + pytest.param( + b'{"query":"{ ...F } fragment F on Query { __schema { queryType { name } } }"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', + id="__schema in a fragment", + ), + pytest.param( + b'{"query":"{ ... on Query { __schema { queryType { name } } } }"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', + id="__schema in an inline fragment", + ), + pytest.param( + b'{"query":"{ __schema @skip(if: false) { queryType { name } } }"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', + id="__schema under @skip(if: false)", + ), + pytest.param( + b'{"query":"query Q { __schema { queryType { name } } }"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', + id="a named query of __schema", + ), + pytest.param( + b'{"query":"query A { __schema { queryType { name } } } query B { viewer { id } }","operationName":"A"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', + id="the introspection operationName chose", + ), + pytest.param( + b'{"query":"query($b: Boolean!) { __schema @include(if: $b) { queryType { name } } }"}', + b'{"errors":[{"message":"Variable \\"$b\\" of required type \\"Boolean!\\" was not provided.","locations":[{"line":1,"column":7}],"extensions":{"code":"BAD_USER_INPUT","type":"graphql error","userError":true}}]}', + id="introspection with a variable not provided", + ), + pytest.param( + b'{"query":"{ __typename __schema { queryType { name } } }"}', + WithTheKey( + b'{"data":{"__typename":"Query","__schema":{"queryType":{"name":"Query"}}}}', 200 + ), + id="__typename beside __schema", + ), + pytest.param( + b'{"query":"{ __schema { queryType { name } } viewer @skip(if: true) { id } }"}', + WithTheKey(b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', 200), + id="__schema beside a skipped field", + ), + pytest.param( + b'{"query":"{ viewer @skip(if: true) { id } }"}', + WithTheKey(b'{"data":{}}', 200), + id="a skipped field alone", + ), + pytest.param( + b'{"query":"{ ...F } fragment F on Query { __typename }"}', + None, + id="__typename in a fragment", + ), + pytest.param( + b'{"query":"{ ... on Query { __typename } }"}', + None, + id="__typename in an inline fragment", + ), + pytest.param( + b'{"query":"{ __schema { queryType { name } } ...F } fragment F on Query { __typename }"}', + WithTheKey( + b'{"data":{"__schema":{"queryType":{"name":"Query"}},"__typename":"Query"}}', 200 + ), + id="__schema and __typename in a fragment", + ), + pytest.param( + b'{"query":"{ ... on Query { __schema { queryType { name } } } ...F } fragment F on Query { __typename }"}', + WithTheKey( + b'{"data":{"__schema":{"queryType":{"name":"Query"}},"__typename":"Query"}}', 200 + ), + id="__typename in a fragment beside an inline __schema", + ), pytest.param( b'{"query":"{ ...F }"}', b'{"errors":[{"message":"Unknown fragment \\"F\\".","locations":[{"line":1,"column":6}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}', @@ -2046,7 +2228,7 @@ def test_linear_orders_the_body_and_the_credential( if credential.startswith("Bearer"): assert (r.status_code, r.content) == (400, _API_KEY_AS_BEARER) elif credential != "key" and (answer is None or isinstance(answer, WithTheKey)): - assert r.status_code == 401 + assert (r.status_code, r.content) == (401, _UNAUTHENTICATED) elif answer is None: assert (r.status_code, r.json()) == (200, {"data": {"__typename": "Query"}}) elif isinstance(answer, WithTheKey): @@ -4968,8 +5150,13 @@ def test_linear_field_error_is_a_200_and_a_syntax_error_is_a_400(tmp_path): b' Remove the Bearer prefix from the Authorization header.","extensions":{"type":"invalid' b' input","code":"INPUT_ERROR","statusCode":400,"userError":true}}]}' ) +_UNAUTHENTICATED = ( + b'{"errors":[{"message":"Authentication required, not authenticated","extensions":{"type":"a' + b'uthentication error","code":"AUTHENTICATION_ERROR","statusCode":401,"userError":true,"user' + b'PresentableMessage":"You need to authenticate to access this operation.","meta":{},"http":{"status":401}}}]}' +) # `Authorization` values, `{t}` for an API key that resolves, and the status Linear gave each: 200 -# serving the query and 400 being `_API_KEY_AS_BEARER`. Backlot's 401 keeps its own body. +# serving the query, 400 being `_API_KEY_AS_BEARER` and 401 `_UNAUTHENTICATED`. _LINEAR_SPELLINGS = [ pytest.param("{t}", 200, id="bare"), pytest.param(" {t}", 200, id="a space before"), @@ -5008,6 +5195,9 @@ def test_linear_field_error_is_a_200_and_a_syntax_error_is_a_400(tmp_path): pytest.param("Bearer nope", 401, id="Bearer nope"), pytest.param("bearer nope", 401, id="bearer nope"), pytest.param("Token {t}", 401, id="Token"), + pytest.param("nope", 401, id="nope"), + pytest.param("Bearer", 401, id="Bearer alone"), + pytest.param("Basic Zm9vOmJhcg==", 401, id="Basic"), ] @@ -5057,6 +5247,8 @@ def test_linear_reads_a_credential_as_linear_does(client, tokens_yaml, value, st assert r.json()["data"]["viewer"]["id"] elif status == 400: assert r.content == _API_KEY_AS_BEARER + else: + assert r.content == _UNAUTHENTICATED def test_linear_issue_asserts_rather_than_re_derive_a_missing_identifier(): diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index fc73019d8..2d9dc0bf2 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -285,7 +285,12 @@ def test_a_failure_is_classed_by_its_answer(source, support, status, code, spent b'{"errors":[{"message":"x","extensions":{"code":"invalid_argument"}}]}', "invalid_argument", ), - ("linear", 200, b'{"errors":[{"message":"Entity not found: Issue"}]}', "entity_not_found"), + ( + "linear", + 200, + b'{"errors":[{"message":"unsupported comparator \'x\'"}]}', + "unsupported_filter", + ), ("linear", 200, b'{"errors":[{"message":"x","extensions":[1]}]}', "unrecognized"), ("linear", 400, b'{"errors":[{"message":"x","extensions":[1]}]}', "unrecognized"), ( @@ -617,7 +622,7 @@ def test_a_call_is_named_by_the_baseline(client, method, path, kwargs, expected) ( '{ issue(id: "x") { id } teams { nodes { id } } }', None, - ("query.issue,teams", "implemented", 200, "entity_not_found", "client"), + ("query.issue,teams", "implemented", 200, "INPUT_ERROR", "client"), ), ( "{ projects { nodes { id } } }", @@ -769,8 +774,8 @@ def test_each_message_slug_is_one_backlot_sends(client, method, path, kwargs, ex # `transcripts` rows there are Fireflies' own. _GRAPHQL_MESSAGES = [ ({"query": "{"}, LINEAR, "GRAPHQL_PARSE_FAILED"), - ({"query": '{ issue(id: "nope") { id } }'}, LINEAR, "entity_not_found"), - ({"query": "{ __typename }"}, {}, "authentication_required"), + ({"query": '{ issue(id: "nope") { id } }'}, LINEAR, "INPUT_ERROR"), + ({"query": "{ __typename }"}, {}, "AUTHENTICATION_ERROR"), ({"query": "{ nosuch }"}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), ({"query": '{ issue(id: "x", bogus: 1) { id } }'}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), ({"query": 'query($x: Nope) { issue(id: "x") { id } }'}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), @@ -998,7 +1003,7 @@ def boom(): "path, headers, broken, query, expected", [ ("/linear/graphql", LINEAR, "linear_by_id", '{ issue(id: "x") { id } }', "internal_error"), - ("/linear/graphql", LINEAR, None, '{ issue(id: "nope") { id } }', "entity_not_found"), + ("/linear/graphql", LINEAR, None, '{ issue(id: "nope") { id } }', "INPUT_ERROR"), ( "/fireflies/graphql", ADMIN, From 45216278d8c00111e763436c53cd086bb74a1a70 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:15:02 +0900 Subject: [PATCH 08/52] graphql: the engine's docstring names Apollo Server without a major version it cannot show --- backlot/graphql/engine.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index 47b1cf500..8d8ea02ed 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -6,15 +6,14 @@ context dict (connection, caller, ``visible_ids``) that every resolver receives as ``info.context``. -Both vendors serve GraphQL through Apollo Server 4 over graphql-js, so the engine answers as that -pair does: Apollo Server's checks of the body and of a persisted query come first, then -graphql-js's parse, validation and choice of operation (:meth:`Engine.resolve_payload`), then the -coercion of variables and the run itself (:meth:`Engine.run`), each refusal a :class:`Refusal` -carrying Apollo Server's ``code`` and the message in graphql-js's words -(:mod:`backlot.graphql.js_words`). A router that reads the credential between the two stages calls -them apart. A refusal made before execution carries no ``data`` entry; an error made as the -operation runs comes back beside ``data``, at 200. Each router turns the refusals into its -vendor's envelope. +Both vendors serve GraphQL through Apollo Server over graphql-js, so the engine answers as that pair +does: Apollo Server's checks of the body and of a persisted query come first, then graphql-js's +parse, validation and choice of operation (:meth:`Engine.resolve_payload`), then the coercion of +variables and the run itself (:meth:`Engine.run`), each refusal a :class:`Refusal` carrying Apollo +Server's ``code`` and the message in graphql-js's words (:mod:`backlot.graphql.js_words`). A router +that reads the credential between the two stages calls them apart. A refusal made before execution +carries no ``data`` entry; an error made as the operation runs comes back beside ``data``, at 200. +Each router turns the refusals into its vendor's envelope. """ from __future__ import annotations From 79a15debf2f17d12f46c5f0b0df78521967dfad1 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:27:52 +0900 Subject: [PATCH 09/52] graphql: each method, content type and Accept is answered by the layer that answers it at the vendor Linear runs Express, cors and body-parser in front of Apollo Server, and Fireflies Fastify and @fastify/cors; Cloudflare stands in front of both. Each layer now answers what it answers there: Cloudflare's TRACE, CONNECT and PURGE, Linear's load balancer's 501, each vendor's CORS preflight, body-parser's and Fastify's reading of a body by its content type, Fastify's 404 for a method its route does not take, and Apollo Server's CSRF check, its 405, a GET's query string, its 415 for a GET's content type on Fireflies, its landing page on Fireflies and its choice of media type from Accept, with its 406. Fireflies' auth_failed 500 is counted as a refusal of the credential. --- backlot/graphql/apollo_http.py | 345 +++++++++++++ backlot/graphql/apollo_landing_page.html | 85 +++ backlot/graphql/body.py | 120 +++++ backlot/graphql/engine.py | 56 +- backlot/main.py | 56 ++ backlot/routers/fireflies.py | 233 +++++++-- backlot/routers/linear.py | 237 ++++++--- backlot/telemetry/errors.py | 12 +- docs/fidelity.md | 2 +- docs/supported-sources.md | 14 +- pyproject.toml | 1 + scripts/gen_docs.py | 2 +- tests/test_cross_vendor.py | 4 +- tests/test_fireflies.py | 625 +++++++++++++++++++++++ tests/test_graphql.py | 166 +++++- tests/test_linear.py | 591 ++++++++++++++++++++- tests/test_telemetry.py | 63 +++ 17 files changed, 2480 insertions(+), 132 deletions(-) create mode 100644 backlot/graphql/apollo_http.py create mode 100644 backlot/graphql/apollo_landing_page.html diff --git a/backlot/graphql/apollo_http.py b/backlot/graphql/apollo_http.py new file mode 100644 index 000000000..6568d05da --- /dev/null +++ b/backlot/graphql/apollo_http.py @@ -0,0 +1,345 @@ +"""Apollo Server's HTTP layer, in front of the request pipeline :mod:`backlot.graphql.engine` runs: +what it answers before it reads a document, and the media type of what it answers. + +Both vendors run it. :func:`csrf_refusal` is its check that a request was preflighted, +:func:`search_payload` its reading of a GET's query string, :func:`method_refusal` its 405 for a +method it does not serve, :func:`get_content_type_refusal` the 415 Apollo Server 5.5 added for a +GET carrying a content type, :func:`response_type` its choice of media type from ``Accept`` and +:data:`NOT_ACCEPTABLE` its 406 when there is none, and :func:`prefers_html` and +:func:`landing_page` its page for a browser. Each refusal is thrown before the request pipeline, so +it takes whichever media type ``Accept`` allows and ``application/json`` where it allows none: a +:class:`~backlot.graphql.engine.Refusal` that is ``thrown``. A refusal the pipeline makes, from a +persisted query's to a variable's, is refused for the ``Accept`` in turn, with :data:`NOT_ACCEPTABLE`. + +The code is read off Apollo Server 5.5.1's ``runHttpQuery.ts``, ``preventCsrf.ts`` and +``ApolloServer.ts``, and the libraries they call: ``whatwg-mimetype`` 4.0.0 (:func:`mime_essence`) +and ``negotiator`` 1.0.0 (:func:`preferred_media_type`). ``negotiator`` 1.1.0 parses ``Accept`` +anew; api.linear.app and api.fireflies.ai each answered nine values that 1.0.0 and 1.1.0 negotiate +apart as 1.0.0 does on 2026-10-25 (UTC), among them ``application/json;q``, which 1.0.0 refuses. +""" + +from __future__ import annotations + +import functools +import math +import re +import secrets +from pathlib import Path +from typing import Any, Mapping +from urllib.parse import parse_qsl + +from backlot.graphql import body as request_body +from backlot.graphql.engine import BAD_REQUEST, Refusal + +#: The methods uvicorn passes on to the application under httptools 0.9.0: 35 of the 49 sent to it +#: on 2026-10-25 (UTC), the IANA registry's, Node's, ``FOO`` and ``get``. It answered the other 14 +#: with its own 400 before any route ran. A route that answers whatever method its vendor's server +#: answers takes these. +METHODS = ( + "ACL", "BIND", "CHECKOUT", "CONNECT", "COPY", "DELETE", "GET", "HEAD", "LINK", "LOCK", + "M-SEARCH", "MERGE", "MKACTIVITY", "MKCALENDAR", "MKCOL", "MOVE", "NOTIFY", "OPTIONS", "PATCH", + "POST", "PROPFIND", "PROPPATCH", "PURGE", "PUT", "QUERY", "REBIND", "REPORT", "SEARCH", "SOURCE", + "SUBSCRIBE", "TRACE", "UNBIND", "UNLINK", "UNLOCK", "UNSUBSCRIBE", +) # fmt: skip + +#: The media types Apollo Server answers a single result in, its ``MEDIA_TYPES``. +JSON = "application/json; charset=utf-8" +GRAPHQL_RESPONSE = "application/graphql-response+json; charset=utf-8" +CALLBACK = "application/json; callbackSpec=1.0; charset=utf-8" +_HTML = "text/html" +# The incremental-delivery types `prefersHTML` puts ahead of `text/html`. +_MULTIPART = ( + "multipart/mixed; deferSpec=20220824", + "multipart/mixed; incrementalSpec=v0.2", + "multipart/mixed", +) + +_JS_SPACE = request_body.JS_SPACE +# A character JavaScript's `.` matches: any but a LineTerminator. +_JS_ANY = r"[^\n\r\u2028\u2029]" +# negotiator's `simpleMediaTypeRegExp`. +_S = re.escape(_JS_SPACE) +_ACCEPT_ENTRY = re.compile(rf"[{_S}]*([^{_S}/;]+)/([^;{_S}]+)[{_S}]*(?:;({_JS_ANY}*))?\Z") +# The literal JavaScript's `parseFloat` reads, past the white space it skips. +_FLOAT = re.compile(r"[+-]?(?:Infinity|(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)") + + +def _js_or(*values: float) -> float: + """``a || b || ...`` over numbers, as JavaScript reads it: the first that is neither zero nor + ``NaN``, or else the last.""" + for value in values[:-1]: + if value and not math.isnan(value): + return value + return values[-1] + + +def _parse_float(text: str | None) -> float: + """JavaScript's ``parseFloat``: the longest decimal literal at the start of ``text`` past its + white space, or ``NaN``.""" + match = _FLOAT.match((text or "").lstrip(_JS_SPACE)) + if text is None or match is None: + return math.nan + literal = match.group() + if literal.lstrip("+-") == "Infinity": + return -math.inf if literal.startswith("-") else math.inf + return float(literal) + + +def _split_quoted(text: str, separator: str) -> list[str]: + """``text`` split at ``separator`` but where the piece so far holds an odd number of ``"``, as + negotiator's ``splitMediaTypes`` and ``splitParameters`` split.""" + pieces = text.split(separator) + joined = [pieces[0]] + for piece in pieces[1:]: + if joined[-1].count('"') % 2 == 0: + joined.append(piece) + else: + joined[-1] += separator + piece + return joined + + +def _media_range(text: str, index: int | None) -> dict | None: + """negotiator's ``parseMediaType``: the type, subtype, parameters and quality of one entry of + ``Accept``, read up to the first ``q`` parameter, or ``None`` for one it cannot read.""" + match = _ACCEPT_ENTRY.match(text) + if match is None: + return None + params: dict[str, str | None] = {} + q = 1.0 + if match.group(3): + for parameter in _split_quoted(match.group(3), ";"): + key, equals, value = parameter.strip(_JS_SPACE).partition("=") + value = value if equals else None + if value and value[0] == '"' and value[-1] == '"': + value = value[1:-1] + if key.lower() == "q": + q = _parse_float(value) + break + params[key.lower()] = value + return {"type": match.group(1), "subtype": match.group(2), "params": params, "q": q, "i": index} + + +def _specificity(provided: dict, spec: dict, index: int) -> dict | None: + """negotiator's ``specify``: how closely the range ``spec`` names the type ``provided``, or + ``None`` where it does not take it.""" + s = 0 + if spec["type"].lower() == provided["type"].lower(): + s |= 4 + elif spec["type"] != "*": + return None + if spec["subtype"].lower() == provided["subtype"].lower(): + s |= 2 + elif spec["subtype"] != "*": + return None + if spec["params"]: + if all( + value == "*" or (value or "").lower() == (provided["params"].get(key) or "").lower() + for key, value in spec["params"].items() + ): + s |= 1 + else: + return None + return {"i": index, "o": spec["i"], "q": spec["q"], "s": s} + + +def preferred_media_type(accept: str, provided: tuple[str, ...]) -> str | None: + """negotiator 1.0.0's ``mediaType``: the type in ``provided`` that ``accept`` ranks first, the + earlier in ``provided`` among equals, or ``None`` where it takes none of them.""" + ranges = [ + r + for i, e in enumerate(_split_quoted(accept, ",")) + if (r := _media_range(e.strip(_JS_SPACE), i)) + ] + priorities = [] + for index, type_ in enumerate(provided): + target = _media_range(type_, None) + priority = {"o": -1, "q": 0.0, "s": 0} + for spec in ranges: + found = _specificity(target, spec, index) + if ( + found is not None + and _js_or( + priority["s"] - found["s"], + priority["q"] - found["q"], + priority["o"] - found["o"], + ) + < 0 + ): + priority = found + priorities.append(priority) + accepted = [p for p in priorities if p["q"] > 0] + accepted.sort( + key=functools.cmp_to_key( + lambda a, b: _js_or( + b["q"] - a["q"], b["s"] - a["s"], a["o"] - b["o"], a["i"] - b["i"], 0 + ) + ) + ) + return ( + provided[next(i for i, p in enumerate(priorities) if p is accepted[0])] + if accepted + else None + ) + + +def response_type(accept: str | None) -> str | None: + """``chooseContentTypeForSingleResultResponse``: the media type Apollo Server answers in under + ``accept``, :data:`JSON` for none, or ``None`` where it takes none of its three.""" + if not accept: + return JSON + return preferred_media_type(accept, (JSON, GRAPHQL_RESPONSE, CALLBACK)) + + +def prefers_html(method: str, accept: str | None) -> bool: + """``prefersHTML``: whether a GET's ``accept`` puts ``text/html`` ahead of the media types + Apollo Server answers in, which it lists first so that a tie goes to them.""" + return ( + method == "GET" + and bool(accept) + and preferred_media_type(accept, (JSON, GRAPHQL_RESPONSE, *_MULTIPART, _HTML)) == _HTML + ) + + +#: Apollo Server's 406, thrown once the request pipeline has answered, where ``Accept`` takes none +#: of :func:`response_type`'s types. Its ``http`` extension carries no ``headers``. +NOT_ACCEPTABLE = Refusal( + "An 'accept' header was provided for this request which does not accept " + f"{JSON} or {GRAPHQL_RESPONSE}", + BAD_REQUEST, + status=406, + thrown=True, + http_headers=False, +) + +# An HTTP token, as `whatwg-mimetype` reads one. +_TOKEN = re.compile(r"[-!#$%&'*+.^_`|~A-Za-z0-9]+") +_HTTP_SPACE = " \t\n\r" + + +def mime_essence(value: str) -> str | None: + """``whatwg-mimetype``'s ``MIMEType.parse(value).essence``: the type and subtype in lower case, + or ``None`` where it parses none. The parameters cannot make it ``None``.""" + value = value.strip(_HTTP_SPACE) + type_, slash, rest = value.partition("/") + if not slash or not _TOKEN.fullmatch(type_): + return None + subtype = rest.partition(";")[0].rstrip(_HTTP_SPACE) + if not _TOKEN.fullmatch(subtype): + return None + return f"{type_}/{subtype}".translate(_ASCII_LOWER) + + +_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz") + +_NOT_PREFLIGHTED = ("application/x-www-form-urlencoded", "multipart/form-data", "text/plain") +_PREFLIGHT_HEADERS = ("x-apollo-operation-name", "apollo-require-preflight") +CSRF = ( + "This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). Please " + "either specify a 'content-type' header (with a type that is not one of " + f"{', '.join(_NOT_PREFLIGHTED)}) or provide a non-empty value for one of the following " + f"headers: {', '.join(_PREFLIGHT_HEADERS)}\n" +) + + +def headers_seen(raw: list[tuple[bytes, bytes]]) -> dict[str, str]: + """The request headers as Apollo Server reads them behind Node, from the ASGI ``raw`` list: + each name in lower case, the values of a name sent more than once joined with ``", "`` but + ``content-type``'s, of which Node keeps the first, and a header with no value left out. Both + vendors read an empty ``Content-Type`` as none on 2026-10-25 (UTC): Linear refused a POST + carrying one as a POST with none, and Fireflies served a GET carrying one with the preflight + header, where it refuses a GET carrying a content type that is not JSON.""" + seen: dict[str, str] = {} + for name, value in raw: + key, text = name.decode("latin-1").lower(), value.decode("latin-1") + if key in seen: + if key != "content-type": + seen[key] = f"{seen[key]}, {text}" + else: + seen[key] = text + return {key: value for key, value in seen.items() if value} + + +def csrf_refusal(headers: Mapping[str, str]) -> Refusal | None: + """``preventCsrf``: the 400 for a request that may not have been preflighted, one with no + content type or one ``whatwg-mimetype`` reads as a type a browser sends without a preflight, + and none of :data:`_PREFLIGHT_HEADERS` with a value; ``None`` for a request it lets through, + a content type ``whatwg-mimetype`` cannot parse among them.""" + content_type = headers.get("content-type") + if content_type is not None and mime_essence(content_type) not in _NOT_PREFLIGHTED: + return None + if any(headers.get(name) for name in _PREFLIGHT_HEADERS): + return None + return Refusal(CSRF, BAD_REQUEST, thrown=True) + + +def method_refusal() -> Refusal: + """The 405 Apollo Server answers a method other than GET and POST with.""" + return Refusal( + "Apollo Server supports only GET/POST requests.", + BAD_REQUEST, + status=405, + headers=(("allow", "GET, POST"),), + thrown=True, + ) + + +def get_content_type_refusal(headers: Mapping[str, str]) -> Refusal | None: + """Apollo Server 5.5's 415 for a GET whose content type ``whatwg-mimetype`` does not read as + ``application/json``, or ``None``. Apollo Server 5.4 and before make no such check.""" + content_type = headers.get("content-type") + if content_type is None or mime_essence(content_type) == "application/json": + return None + return Refusal( + "GET requests may not have a content-type header other than application/json.", + BAD_REQUEST, + status=415, + thrown=True, + ) + + +def search_payload(query_string: bytes) -> dict | Refusal: + """A GET's request as Apollo Server reads it off the query string, with ``URLSearchParams``: + ``query``, ``operationName``, ``variables`` and ``extensions``, each at most once, the last two + read with ``JSON.parse`` as objects; or the refusal of the first that is not, in that order.""" + params: dict[str, list[str]] = {} + for name, value in parse_qsl( + query_string.decode("latin-1"), keep_blank_values=True, errors="replace" + ): + params.setdefault(name, []).append(value) + payload: dict[str, Any] = {} + for name in ("query", "operationName", "variables", "extensions"): + values = params.get(name, []) + if len(values) > 1: + return Refusal( + f"The '{name}' search parameter may only be specified once.", + BAD_REQUEST, + thrown=True, + ) + if not values: + continue + value: Any = values[0] + if name in ("variables", "extensions"): + try: + value = request_body.parse(value) + except request_body.NotJson: + return Refusal( + f"The {name} search parameter contains invalid JSON.", BAD_REQUEST, thrown=True + ) + if not isinstance(value, dict): + return Refusal( + f"The {name} search parameter should contain a JSON-encoded object.", + BAD_REQUEST, + thrown=True, + ) + payload[name] = value + return payload + + +_LANDING_PAGE = (Path(__file__).parent / "apollo_landing_page.html").read_text(encoding="utf-8") + + +def landing_page() -> str: + """Apollo Server 5.5.1's page for a browser, the embedded Sandbox: the page api.fireflies.ai + answered each of twelve GETs preferring ``text/html`` with on 2026-10-25 (UTC), the same 3522 + bytes each time but the nonce, 64 hexadecimal digits of its own each time.""" + return _LANDING_PAGE.replace("NONCE", secrets.token_hex(32)) diff --git a/backlot/graphql/apollo_landing_page.html b/backlot/graphql/apollo_landing_page.html new file mode 100644 index 000000000..f7193cb98 --- /dev/null +++ b/backlot/graphql/apollo_landing_page.html @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + Apollo Server + + + +
+ + +
+

Welcome to Apollo Server

+

Apollo Sandbox cannot be loaded; it appears that you might be offline.

+
+ +
+ + + +
+ + + \ No newline at end of file diff --git a/backlot/graphql/body.py b/backlot/graphql/body.py index 44188f9ac..fe4af8918 100644 --- a/backlot/graphql/body.py +++ b/backlot/graphql/body.py @@ -64,6 +64,12 @@ def decode(raw: bytes) -> str: _QUOTED_PAIR = re.compile(r"\\([\x0b\x20-\xff])") +#: What JavaScript's ``String.prototype.trim`` removes and its regular expressions' ``\s`` matches: +#: WhiteSpace and LineTerminator. Python's ``str.strip`` also removes ``\x1c`` to ``\x1f`` and +#: ``\x85``, which these do not, and keeps ``\ufeff``, which these remove. +JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff" + + class Unread(Exception): """A body Linear's ``body-parser`` refuses before it has the text: the status and the message Linear's error handler answers with, and for the 413 an uncompressed body draws, its length. @@ -161,6 +167,120 @@ def _charset(content_type: str | None) -> str: return charset.lower() or "utf-8" +# content-type 1.0.5's `TYPE_REGEXP`, and media-typer 1.1.0's, which type-is 2.0.1 also asks. +_CONTENT_TYPE = re.compile(rf"{_TOKEN}/{_TOKEN}") +_MEDIA_TYPER = re.compile( + r" *[A-Za-z0-9][A-Za-z0-9!#$&^_-]{0,126}/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126} *" +) + + +def express_reads_json(content_type: str | None) -> bool: + """Whether Linear's ``body-parser`` reads a body under ``content_type`` as JSON, which + ``type-is`` 2.0.1 decides as ``content-type`` 1.0.5 parses the header: the type before the + first ``;``, trimmed, is ``application/json`` in any case, and each parameter after it is a + token, ``=`` and a token or a quoted string, to the end. A body under any other is not read, + and reaches Apollo Server as none. api.linear.app answered as these two packages do 71 content + types on 2026-10-25 (UTC), where ``content-type`` 2.1.0, which ``type-is`` 2.1.0 asks, reads + ``application/json;``, ``application/json; foo`` and five more as JSON that Linear did not.""" + if not content_type: + return False + index = content_type.find(";") + type_ = (content_type if index == -1 else content_type[:index]).strip(JS_SPACE) + if not _CONTENT_TYPE.fullmatch(type_): + return False + if index != -1: + while (parameter := PARAMETER.match(content_type, index)) is not None: + index = parameter.end() + if index != len(content_type): + return False + type_ = type_.lower() + return _MEDIA_TYPER.fullmatch(type_) is not None and type_ == "application/json" + + +# Fastify's `typeNameReg` and `subtypeNameReg`, the latter with no white space to end it once +# `trimEnd` has removed it. +_FASTIFY_TOKEN = re.compile(r"[\w!#$%&'*+.^`|~-]+", re.ASCII) + + +def fastify_media_type(content_type: str) -> str | None: + """The type and subtype in lower case Fastify's ``ContentType`` reads off a ``Content-Type`` + header, or ``None`` for one it calls invalid: read off ``lib/content-type.js`` in Fastify + 5.10.0. The parameters are not read for it: a type is before the first ``;`` or, with none, + is the whole value, and splits at its first ``/`` into two tokens, white space allowed before + the first and after the second. An empty value and ``undefined`` are invalid. + + Fastify 5.7.4 to 5.10.0 answered alike each of 2,138 content types sent to them on + 2026-10-25 (UTC), and api.fireflies.ai answered as they do each sent to it, where 5.5.0 refuses + ``application/json\\t;``, 5.7.0 serves ``application/json ,`` and 5.11.0 reads the body of a + ``QUERY``, which Fireflies answered as it answers a ``PUT`` with none.""" + if content_type in ("", "undefined"): + return None + index = content_type.find(";") + if index == -1: + index = content_type.find("/") + if index == -1: + return None + type_ = content_type[:index].lstrip(JS_SPACE).lower() + subtype = content_type[index + 1 :].rstrip(JS_SPACE).lower() + else: + media = content_type[:index].lower() + index = media.find("/") + if index == -1: + return None + type_, subtype = media[:index].lstrip(JS_SPACE), media[index + 1 :].rstrip(JS_SPACE) + if not (_FASTIFY_TOKEN.fullmatch(type_) and _FASTIFY_TOKEN.fullmatch(subtype)): + return None + return f"{type_}/{subtype}" + + +_PERCENT_RUN = re.compile(r"(?:%[0-9A-Fa-f]{2})+") + + +def _fast_decode(text: str) -> str | None: + """``fast-decode-uri-component``: ``text`` with each run of ``%XX`` read as UTF-8, or ``None`` + where a ``%`` is not followed by two hexadecimal digits or a run is not UTF-8.""" + if "%" not in text: + return text + out, last = [], 0 + for run in _PERCENT_RUN.finditer(text): + if "%" in text[last : run.start()]: + return None + try: + decoded = bytes.fromhex(run.group().replace("%", "")).decode("utf-8") + except UnicodeDecodeError: + return None + out.extend((text[last : run.start()], decoded)) + last = run.end() + if "%" in text[last:]: + return None + return "".join(out) + text[last:] + + +def form_fields(text: str) -> dict[str, str | list[str]]: + """A form body as ``@fastify/formbody`` 8 reads it with ``fast-querystring`` 1.1.2: the pairs + between each ``&``, a name alone given ``""``, each ``+`` a space and each name and value + percent-decoded, left as sent where it cannot be (:func:`_fast_decode`), and a name sent more + than once given the list of its values. api.fireflies.ai served ``query=%7B%20__typename%20%7D`` + with the preflight header on 2026-10-25 (UTC).""" + fields: dict[str, str | list[str]] = {} + for pair in text.split("&"): + name, equals, value = pair.partition("=") + if not equals and not name: + continue + name = name.replace("+", " ") + name = _fast_decode(name) or name + value = value.replace("+", " ") + value = _fast_decode(value) or value + held = fields.get(name) + if held is None: + fields[name] = value + elif isinstance(held, list): + held.append(value) + else: + fields[name] = [held, value] + return fields + + def _byte_order(raw: bytes, width: int) -> str: """``le`` or ``be`` for a body whose charset names no byte order, as iconv-lite's ``detectEncoding`` picks: a byte-order mark first, else which order reads more of the first 100 diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index 8d8ea02ed..93e588c13 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -105,12 +105,19 @@ def _ids_as_graphql_js_reads_them(value: Any, type_: Any) -> Any: @dataclass(frozen=True) class Refusal: """One request error as Apollo Server answers it: ``message`` in graphql-js's words, the - ``locations`` graphql-js gives it, Apollo Server's ``code``, and the status of the answer.""" + ``locations`` graphql-js gives it, Apollo Server's ``code``, the status of the answer and the + ``headers`` it sets on it. ``thrown`` marks a refusal Apollo Server throws before its request + pipeline, which ``Accept`` cannot turn into its 406 (:mod:`backlot.graphql.apollo_http`), and + ``http_headers`` whether the ``http`` extension it carries names its ``headers``, as all but + the 406's do.""" message: str code: str locations: tuple = () status: int = 400 + headers: tuple[tuple[str, str], ...] = () + thrown: bool = False + http_headers: bool = True @dataclass(frozen=True) @@ -138,6 +145,11 @@ def status(self) -> int: """The status of the answer: a refusal's, or 200 for an operation that ran.""" return self.refusals[0].status if self.refusals else 200 + @property + def headers(self) -> tuple[tuple[str, str], ...]: + """The headers Apollo Server sets on the answer: a refusal's, or none.""" + return self.refusals[0].headers if self.refusals else () + @dataclass(frozen=True) class Operation: @@ -196,8 +208,10 @@ def refused(*refusals: Refusal) -> Result: return Result(payload={"errors": errors}, refusals=refusals) -def _refusal(message: str, code: str = BAD_REQUEST, status: int = 400) -> Result: - return refused(Refusal(message, code, status=status)) +def _refusal( + message: str, code: str = BAD_REQUEST, status: int = 400, thrown: bool = False +) -> Result: + return refused(Refusal(message, code, status=status, thrown=thrown)) def _js_truthy(value: Any) -> bool: @@ -230,22 +244,22 @@ def body_refusal(payload: Any) -> Result | None: type. Both vendors kept that order with two wrong at once on 2026-10-25 (UTC). A ``null`` member is one not sent.""" if isinstance(payload, list): - return _refusal("Operation batching disabled.") + return _refusal("Operation batching disabled.", thrown=True) if not isinstance(payload, dict) or not payload: - return _refusal(NO_KEYS) + return _refusal(NO_KEYS, thrown=True) query = payload.get("query") if _js_truthy(query) and not isinstance(query, str): document = isinstance(query, dict) and query.get("kind") == "Document" - return _refusal(_A_DOCUMENT if document else _NOT_A_STRING) + return _refusal(_A_DOCUMENT if document else _NOT_A_STRING, thrown=True) for name in ("variables", "extensions"): if isinstance(payload.get(name), str): - return _refusal(RECURSIVELY_ENCODED.format(name)) + return _refusal(RECURSIVELY_ENCODED.format(name), thrown=True) for name in ("extensions", "variables"): if payload.get(name) is not None and not isinstance(payload[name], dict): - return _refusal(f"`{name}` in a POST body must be an object if provided.") + return _refusal(f"`{name}` in a POST body must be an object if provided.", thrown=True) name = payload.get("operationName") if name is not None and not isinstance(name, str): - return _refusal("`operationName` in a POST body must be a string if provided.") + return _refusal("`operationName` in a POST body must be a string if provided.", thrown=True) return None @@ -365,11 +379,13 @@ def resolve( variables: Any = None, operation_name: str | None = None, on_document: Callable[[DocumentNode], Result | None] | None = None, + get: bool = False, ) -> Result | Operation: """``document`` read as graphql-js reads it under Apollo Server, up to the choice of the operation to run: parse, then ``on_document``, a vendor's look at the parsed document before it is validated, then validation and the choice of operation, each refused with - Apollo Server's code; :meth:`run` takes the operation it chose.""" + Apollo Server's code; :meth:`run` takes the operation it chose. Sent by ``get``, an + operation that is not a query is Apollo Server's 405.""" try: doc = parse(document) except GraphQLSyntaxError as error: @@ -396,6 +412,16 @@ def resolve( else f'Unknown operation named "{operation_name}".' ) return _refusal(message, OPERATION_RESOLUTION_FAILURE) + kind = operation.operation.value + if get and kind != "query": + return refused( + Refusal( + f"GET requests only support query operations, not {kind} operations", + BAD_REQUEST, + status=405, + headers=(("allow", "POST"),), + ) + ) return Operation(doc, operation, variables, operation_name) def resolve_payload( @@ -403,12 +429,14 @@ def resolve_payload( payload: Any, *, on_document: Callable[[DocumentNode], Result | None] | None = None, + get: bool = False, ) -> Result | Operation: """A GraphQL-over-HTTP POST body (``{query, variables, operationName}``) its router has read with :mod:`backlot.graphql.body`, up to the choice of operation: :func:`body_refusal`, then :func:`persisted_query_refusal`, then Apollo Server's refusal of a body with no query, - then :meth:`resolve`.""" - answer = body_refusal(payload) + then :meth:`resolve`. With ``get``, ``payload`` is a GET's query string as + :func:`backlot.graphql.apollo_http.search_payload` reads it, which no body check runs on.""" + answer = None if get else body_refusal(payload) if answer is None: answer = persisted_query_refusal(payload) if answer is not None: @@ -421,6 +449,7 @@ def resolve_payload( variables=payload.get("variables"), operation_name=payload.get("operationName"), on_document=on_document, + get=get, ) def run(self, operation: Operation, *, context: Any = None) -> Result: @@ -495,9 +524,10 @@ def execute_payload( *, context: Any = None, on_document: Callable[[DocumentNode], Result | None] | None = None, + get: bool = False, ) -> Result: """:meth:`resolve_payload`, then :meth:`run` the operation it chose.""" - resolved = self.resolve_payload(payload, on_document=on_document) + resolved = self.resolve_payload(payload, on_document=on_document, get=get) return resolved if isinstance(resolved, Result) else self.run(resolved, context=context) diff --git a/backlot/main.py b/backlot/main.py index cc8c8d79c..606e58e8d 100644 --- a/backlot/main.py +++ b/backlot/main.py @@ -1224,6 +1224,62 @@ async def warn_slack_post_charset(request: Request, call_next): ) +#: Cloudflare's answer to each method it refuses in front of Linear and Fireflies, as +#: api.linear.app and api.fireflies.ai answered each on `/graphql`, `/graphql/`, `/nosuch` and `/` +#: on 2026-10-25 (UTC): a page with CRLF line ends, or a line of text, and the headers it puts on it. +_CLOUDFLARE_METHOD = { + "TRACE": ( + 405, + "\r\n405 Not Allowed\r\n\r\n" + "

405 Not Allowed

\r\n
cloudflare
\r\n" + "\r\n\r\n", + {"content-type": "text/html"}, + ), + "CONNECT": ( + 400, + "error code: 1045\n", + { + "content-type": "text/plain; charset=UTF-8", + "cache-control": "private, max-age=0, no-store, no-cache, must-revalidate, " + "post-check=0, pre-check=0", + "expires": "Thu, 01 Jan 1970 00:00:01 GMT", + "referrer-policy": "same-origin", + "x-frame-options": "SAMEORIGIN", + }, + ), + "PURGE": ( + 400, + "\r\n400 Bad Request\r\n\r\n" + "

400 Bad Request

\r\n
cloudflare
\r\n" + "\r\n\r\n", + { + "content-type": "text/html; charset=UTF-8", + "x-frame-options": "SAMEORIGIN", + "cache-control": "private, max-age=0, no-store, no-cache, must-revalidate, " + "post-check=0, pre-check=0", + "expires": "Thu, 01 Jan 1970 00:00:01 GMT", + "referrer-policy": "same-origin", + }, + ), +} + + +@_middleware( + lambda scope: ( + scope["method"] in _CLOUDFLARE_METHOD + and _at_or_under(_url_path(scope), ("/linear", "/fireflies")) + ) +) +async def refuse_cloudflares_methods_on_linear_and_fireflies(request: Request, call_next): + """Answer a `/linear` or `/fireflies` request by one of :data:`_CLOUDFLARE_METHOD`'s methods + with Cloudflare's answer, ahead of the route and the credential. Registered before + :func:`refuse_a_repeated_authorization_on_linear_and_fireflies`, which answers first: a + `TRACE`, `CONNECT` or `PURGE` carrying `Authorization` twice drew its 400 from both vendors on + 2026-10-25 (UTC).""" + status, body, headers = _CLOUDFLARE_METHOD[request.method] + return Response(body.encode(), status_code=status, headers=headers) + + #: The page Cloudflare, in front of Linear and Fireflies, answers a request carrying `Authorization` #: more than once with: `text/html` with no charset, CRLF line ends, 155 bytes. _CLOUDFLARE_REPEATED_AUTHORIZATION = ( diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index c29319c39..7c838ca02 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -1,8 +1,8 @@ -"""Fireflies.ai's GraphQL API, served at ``POST /fireflies/graphql``. +"""Fireflies.ai's GraphQL API, served at ``/fireflies/graphql``. Fireflies is **GraphQL only** — there is no REST surface to emulate — so this router is one endpoint. The schema and the resolvers live in ``backlot/graphql/`` (``fireflies.graphql`` + -``fireflies_resolvers.py``); everything here is HTTP: a body and credentials in, a GraphQL +``fireflies_resolvers.py``); everything here is HTTP: a request and credentials in, a GraphQL envelope out, or its HTTP layer's for a body it cannot read (:func:`_body_refusal`). Auth is the bearer path (``Authorization: Bearer ``), which is what the vendor's own @@ -23,7 +23,7 @@ from fastapi import APIRouter, Request from fastapi.concurrency import run_in_threadpool -from fastapi.responses import JSONResponse, Response +from fastapi.responses import Response from graphql import ( DocumentNode, FragmentDefinitionNode, @@ -33,8 +33,8 @@ from backlot import auth from backlot.config import get_settings +from backlot.graphql import apollo_http, engine from backlot.graphql import body as request_body -from backlot.graphql import engine from backlot.graphql.fireflies_resolvers import build_engine router = APIRouter(prefix="/fireflies", tags=["fireflies"]) @@ -43,7 +43,10 @@ # schema fails at startup instead of on the first request. ENGINE = build_engine() +# The media type of every answer Fastify writes from an object. +_JSON = "application/json; charset=utf-8" _EMPTY = "Body cannot be empty when content-type is set to 'application/json'" +_UNSUPPORTED = "Unsupported Media Type" _NOT_JSON = "Body is not valid JSON but content-type is set to 'application/json'" _TOO_LARGE = "Request body is too large" #: The most bytes Fireflies reads: api.fireflies.ai served a body of 1048576 bytes and answered one @@ -52,22 +55,22 @@ _LIMIT = 1024 * 1024 -def _body_refusal(message: str, status: int = 400) -> JSONResponse: +def _body_refusal(message: str, status: int = 400, code: str = "http") -> Response: """Fireflies' answer to a body it cannot read: an envelope of its HTTP layer, not a GraphQL one, with the message twice. :data:`_EMPTY` is the 400 for a body of no bytes and :data:`_NOT_JSON` the 400 for every other one :mod:`backlot.graphql.body` cannot read or finds :func:`~backlot.graphql.body.prototype_poisoned`, as api.fireflies.ai answered on 2026-10-18; - :data:`_TOO_LARGE` is the 413 for one over :data:`_LIMIT`.""" - return JSONResponse( - { - "statusCode": status, - "status": False, - "code": "http", - "message": message, - "errorMessage": message, - }, - status_code=status, - ) + :data:`_TOO_LARGE` is the 413 for one over :data:`_LIMIT` and :data:`_UNSUPPORTED` the 415 + for one of a type it does not read (:func:`_fastify_read`). :func:`_not_found` is the same + envelope.""" + envelope = { + "statusCode": status, + "status": False, + "code": code, + "message": message, + "errorMessage": message, + } + return Response(request_body.node_json(envelope), status_code=status, media_type=_JSON) # The two messages Fireflies refuses a credential with: a value with no `Bearer` prefix, and the @@ -83,47 +86,87 @@ def _body_refusal(message: str, status: int = 400) -> JSONResponse: ) -def _auth_failed(message: str) -> JSONResponse: +def _auth_failed(seen: dict[str, str], message: str) -> Response: """Fireflies' answer to a credential it does not take: a 500 carrying an ``auth_failed`` entry, as api.fireflies.ai answered on 2026-10-18 with no ``Authorization``, with ``nope``, ``Basic …``, ``token ``, the bare key, ``Bearer nope`` and ``Bearer extra``. The - document is not read first: ``{"query":"{"}``, ``[]`` and ``5`` drew the same 500.""" - return JSONResponse( - { - "errors": [ - { - "friendly": True, - "code": "auth_failed", - "message": message, - "extensions": {"code": "auth_failed"}, - } - ] - }, + document is not read first: ``{"query":"{"}``, ``[]`` and ``5`` drew the same 500. Apollo + Server makes it from the error its context function throws, so it is in the media type + ``Accept`` takes, as a refusal thrown before the request pipeline is (:func:`_answer`).""" + return Response( + request_body.node_json( + { + "errors": [ + { + "friendly": True, + "code": "auth_failed", + "message": message, + "extensions": {"code": "auth_failed"}, + } + ] + } + ), status_code=500, + media_type=apollo_http.response_type(seen.get("accept")) or apollo_http.JSON, ) _FRIENDLY = {"invalid_arguments": True, "INTERNAL_SERVER_ERROR": False} +# The methods Fastify reads a body for and has no route for at this path: it reads the body, then +# answers its not-found handler's 404 (:func:`_not_found`). +_READ_THEN_NOT_FOUND = ("DELETE", "PATCH", "PUT") + -@router.post("/graphql", include_in_schema=False) +@router.api_route("/graphql", methods=list(apollo_http.METHODS), include_in_schema=False) async def graphql(request: Request): - """The single Fireflies endpoint. Not in the OpenAPI document on purpose: this is a GraphQL - service, and describing one POST route that accepts an arbitrary query would tell an - OpenAPI→MCP bridge nothing useful (hence no ``SOURCE_PREFIXES`` entry either — see - ``backlot/openapi.py``). Introspection is the schema description for this endpoint.""" - # Off the event loop, as a body of `_LIMIT` bytes can keep `_read` busy a third of a second. - payload = await run_in_threadpool(_read, await request.body()) - if isinstance(payload, JSONResponse): - return payload - # The telemetry middleware names the call by this document (backlot.telemetry.naming). - request.state.graphql_payload = payload + """The single Fireflies endpoint, in front of which Fireflies runs Fastify and + ``@fastify/cors``. Not in the OpenAPI document on purpose: this is a GraphQL service, and + describing one POST route that accepts an arbitrary query would tell an OpenAPI→MCP bridge + nothing useful (hence no ``SOURCE_PREFIXES`` entry either — see ``backlot/openapi.py``). + Introspection is the schema description for this endpoint. + + The layers answer in this order, each measured against api.fireflies.ai on 2026-10-25 (UTC) + with the one after it wrong too: ``@fastify/cors``'s preflight (:func:`_preflight`); Fastify's + reading of the body (:func:`_fastify_read`) and its 404 for a method its route does not take + (:func:`_not_found`); then Apollo Server: its page for a browser, its CSRF check + (:func:`~backlot.graphql.apollo_http.csrf_refusal`), the credential (:func:`_auth_failed`), + its 405 for a `HEAD`, then the request as a GET's query string or a POST's body carries it, + and the run. Its answer is in the media type ``Accept`` takes, or its 406 (:func:`_answer`).""" + method = request.method + if method == "OPTIONS": + return _preflight(request) + if method not in ("GET", "HEAD", "POST"): + if method in _READ_THEN_NOT_FOUND: + read = await _fastify_read(request, routed=False) + if isinstance(read, Response): + return read + return _not_found(request) + payload: Any = None + if method == "POST": + payload = await _fastify_read(request, routed=True) + if isinstance(payload, Response): + return payload + # The telemetry middleware names the call by this document (backlot.telemetry.naming). + request.state.graphql_payload = payload + seen = apollo_http.headers_seen(request.headers.raw) + if apollo_http.prefers_html(method, seen.get("accept")): + return Response(apollo_http.landing_page(), headers={"content-type": "text/html"}) + refusal = apollo_http.csrf_refusal(seen) + if refusal is not None: + return _answer(seen, engine.refused(refusal)) token = auth.fireflies_bearer_token(request) - if token is None: - return _auth_failed(_NO_PREFIX) - caller = auth.acl(request).resolve(token) + caller = None if token is None else auth.acl(request).resolve(token) if caller is None: - return _auth_failed(_BAD_KEY) + return _auth_failed(seen, _NO_PREFIX if token is None else _BAD_KEY) + if method == "HEAD": + return _answer(seen, engine.refused(apollo_http.method_refusal())) + if method == "GET": + refusal = apollo_http.get_content_type_refusal(seen) + payload = refusal or apollo_http.search_payload(request.scope["query_string"]) + if isinstance(payload, engine.Refusal): + return _answer(seen, engine.refused(payload)) + request.state.graphql_payload = payload state = request.app.state context = { "conn": auth.conn(request), @@ -136,15 +179,111 @@ async def graphql(request: Request): # scale measured) and almost none of them have a Fireflies account. See resolve_users. "roster": frozenset(state.acl.email_to_token()) if getattr(state, "acl", None) else None, } - result = ENGINE.execute_payload(payload, context=context, on_document=_fragments_unreached) - request.state.graphql_raised = result.raised + result = ENGINE.execute_payload( + payload, context=context, on_document=_fragments_unreached, get=method == "GET" + ) + if not result.refusals: + request.state.graphql_raised = result.raised + return _answer(seen, result) + + +def _answer(seen: dict[str, str], result: engine.Result) -> Response: + """Apollo Server's answer to ``result`` in Fireflies' envelope (:func:`_envelope`). A refusal + thrown before the request pipeline is in the media type ``Accept`` takes or + ``application/json``; any other answer is Apollo Server's 406 where it takes none + (:data:`~backlot.graphql.apollo_http.NOT_ACCEPTABLE`), as api.fireflies.ai answered each of + the two kinds on 2026-10-25 (UTC).""" + media_type = apollo_http.response_type(seen.get("accept")) + if media_type is None and not (result.refusals and result.refusals[0].thrown): + result = engine.refused(apollo_http.NOT_ACCEPTABLE) return Response( request_body.node_json(_envelope(result)), status_code=result.status, - media_type="application/json", + headers=dict(result.headers), + media_type=media_type or apollo_http.JSON, ) +#: The headers ``@fastify/cors`` writes on each preflight, as api.fireflies.ai answered every one +#: sent with `Origin` and `Access-Control-Request-Method` on 2026-10-25 (UTC). +_PREFLIGHT = ( + ("access-control-allow-origin", "*"), + ("access-control-allow-methods", "GET,HEAD,POST"), + ("vary", "Access-Control-Request-Headers"), +) + + +def _preflight(request: Request) -> Response: + """``@fastify/cors``'s answer to an `OPTIONS`, ahead of the body and the credential: its 204, + with :data:`_PREFLIGHT` and `Access-Control-Request-Headers` echoed back as sent, to one + carrying both `Origin` and `Access-Control-Request-Method`, and its 400 to any other. + api.fireflies.ai answered so with no credential and `Bearer nope`, with + `Access-Control-Request-Method` naming `POST` or `PUT`, with `Origin: null`, and with a JSON + body or `{`.""" + if "origin" not in request.headers or "access-control-request-method" not in request.headers: + return Response( + b"Invalid Preflight Request", status_code=400, headers={"content-type": "text/plain"} + ) + response = Response(status_code=204) + asked = request.headers.get("access-control-request-headers") + # Node's parser drops the spaces and tabs around a header's value, where uvicorn keeps those + # after it. + echoed = () if asked is None else (("access-control-allow-headers", asked.strip(" \t")),) + response.raw_headers += [(k.encode(), v.encode("latin-1")) for k, v in (*_PREFLIGHT, *echoed)] + return response + + +def _not_found(request: Request) -> Response: + """Fireflies' 404 for a method its route does not take, the method and the path quoted in it as + a client puts them after ``https://api.fireflies.ai``, the query string as sent. api.fireflies.ai + answered so each of the 28 methods of :data:`apollo_http.METHODS` but GET, HEAD, POST, OPTIONS + and Cloudflare's three on 2026-10-25 (UTC), `PUT`, `DELETE` and `PATCH` once Fastify had read + their body (:func:`_fastify_read`), and quoted `/graphql?x=%7B%20y&z&a=b+c` as sent.""" + raw = request.scope.get("raw_path", request.url.path.encode()).decode("latin-1") + path = raw.removeprefix(router.prefix) if raw.startswith(f"{router.prefix}/") else "/graphql" + query = request.scope["query_string"].decode("latin-1") + message = f"Cannot {request.method} {path}{'?' if query else ''}{query}" + return _body_refusal(message, 404, "not_found") + + +_FASTIFY_PARSED = ("application/json", "text/plain", "application/x-www-form-urlencoded") + + +async def _fastify_read(request: Request, routed: bool) -> Any: + """The body Fastify 5.10.0 hands a request's handler, or its refusal of it, read off + ``lib/handle-request.js`` and ``lib/content-type-parser.js``. With no ``Content-Type``, a + request with no ``Content-Length`` or one of 0, and no ``Transfer-Encoding``, has none, and + any other is the 415 (:data:`_UNSUPPORTED`); a type it cannot parse + (:func:`~backlot.graphql.body.fastify_media_type`) is the 415. It reads JSON, plain text and a + form (:func:`~backlot.graphql.body.form_fields`): each up to :data:`_LIMIT` bytes, the first as + :func:`_read` does. On a request its route does not take, ``routed`` false, any other type + and no type are left unread, where a routed request gets the 415. + + api.fireflies.ai answered so each body sent on 2026-10-25 (UTC) to `POST`, `PUT`, `DELETE` + and `PATCH`: no type with a body was the 415 by `POST` and the 404 by `PUT`, `a/b/c` and an + empty type the 415 by both, `application/graphql` the 415 by `POST` and the 404 by `PUT`, also + past the limit, and a form or plain text past the limit the 413 by both.""" + content_type = request.headers.get("content-type") + if content_type is None: + length = request.headers.get("content-length") + if "transfer-encoding" not in request.headers and length in (None, "0"): + return None + return _body_refusal(_UNSUPPORTED, 415) if routed else None + media_type = request_body.fastify_media_type(content_type) + if media_type is None: + return _body_refusal(_UNSUPPORTED, 415) + if media_type not in _FASTIFY_PARSED: + return _body_refusal(_UNSUPPORTED, 415) if routed else None + raw = await request.body() + if media_type == "application/json": + # Off the event loop, as a body of `_LIMIT` bytes can keep `_read` busy a third of a second. + return await run_in_threadpool(_read, raw) + if len(raw) > _LIMIT: + return _body_refusal(_TOO_LARGE, 413) + text = raw.decode("utf-8", "replace") + return text if media_type == "text/plain" else request_body.form_fields(text) + + def _entry(error: dict, code: str, friendly: bool, extensions: dict) -> dict: """``error`` in Fireflies' members, in this order: ``friendly`` first, its own members, then ``code`` and ``extensions``.""" diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 70cc8986b..e65dabce7 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -1,9 +1,9 @@ -"""Linear's GraphQL API, served at ``POST /linear/graphql``. +"""Linear's GraphQL API, served at ``/linear/graphql``. Linear is **GraphQL only** — there is no REST surface to emulate — so this router is one endpoint. The schema and the resolvers live in ``backlot/graphql/`` (``linear.graphql`` + -``linear_resolvers.py``); everything here is HTTP: a body and credentials in, a GraphQL envelope -out, or Express's for the bodies :func:`_express` answers. +``linear_resolvers.py``); everything here is HTTP: a request and credentials in, a GraphQL +envelope out, or Express's for the bodies :func:`_express` answers. Auth is the one place Linear differs from every other source in this repo. A personal API key travels as the **bare** header value (``Authorization: lin_api_…``, no scheme), read by @@ -34,17 +34,20 @@ from fastapi import APIRouter, Request from fastapi.concurrency import run_in_threadpool -from fastapi.responses import JSONResponse, Response +from fastapi.responses import Response from graphql import FieldNode, FragmentDefinitionNode, FragmentSpreadNode from backlot import auth from backlot.config import get_settings +from backlot.graphql import apollo_http, engine, v8_json from backlot.graphql import body as request_body -from backlot.graphql import engine, v8_json from backlot.graphql.linear_resolvers import build_engine router = APIRouter(prefix="/linear", tags=["linear"]) +# The media type of Express's `res.json`, which writes each answer made ahead of Apollo Server. +_JSON = "application/json; charset=utf-8" + # Linear's code for a body with no query, where Apollo Server's is `BAD_REQUEST`. _INVALID_INPUT = "INVALID_INPUT" @@ -53,26 +56,29 @@ ENGINE = build_engine() -def _api_key_as_bearer() -> JSONResponse: +def _api_key_as_bearer() -> Response: """Linear's 400 for an API key under ``Bearer``, which :func:`backlot.auth.linear_api_key_under_bearer` finds, as api.linear.app sent it on 2026-10-18.""" - return JSONResponse( - { - "errors": [ - { - "message": "It looks like you're trying to use an API key as a Bearer token. " - "Remove the Bearer prefix from the Authorization header.", - "extensions": { - "type": "invalid input", - "code": "INPUT_ERROR", - "statusCode": 400, - "userError": True, - }, - } - ] - }, + return Response( + request_body.node_json( + { + "errors": [ + { + "message": "It looks like you're trying to use an API key as a Bearer " + "token. Remove the Bearer prefix from the Authorization header.", + "extensions": { + "type": "invalid input", + "code": "INPUT_ERROR", + "statusCode": 400, + "userError": True, + }, + } + ] + } + ), status_code=400, + media_type=_JSON, ) @@ -93,41 +99,80 @@ def _express(message: str, status: int = 400, length: int | None = None) -> Resp "length": length, } envelope["code"] = status - return Response( - request_body.node_json(envelope), status_code=status, media_type="application/json" - ) + return Response(request_body.node_json(envelope), status_code=status, media_type=_JSON) + +#: The methods Linear's load balancer answers with an empty 501 before Linear sees them, as +#: api.linear.app answered each on 2026-10-25 (UTC). Of the rest of :data:`apollo_http.METHODS`, +#: Cloudflare answers `TRACE`, `CONNECT` and `PURGE` (``backlot.main``) and Apollo Server the others. +_LOAD_BALANCER_501 = frozenset( + {"BIND", "LINK", "M-SEARCH", "MKCALENDAR", "REBIND", "SOURCE", "UNBIND", "UNLINK"} +) -@router.post("/graphql", include_in_schema=False) + +@router.api_route("/graphql", methods=list(apollo_http.METHODS), include_in_schema=False) async def graphql(request: Request): - """The single Linear endpoint. Not in the OpenAPI document on purpose: this is a GraphQL - service, and describing one POST route that accepts an arbitrary query would tell an - OpenAPI→MCP bridge nothing useful (hence no ``SOURCE_PREFIXES`` entry either — see - ``backlot/openapi.py``). Introspection is the schema description for this endpoint.""" - if auth.linear_api_key_under_bearer(request): + """The single Linear endpoint, in front of which Linear runs Express and ``cors``. Not in the + OpenAPI document on purpose: this is a GraphQL service, and describing one POST route that + accepts an arbitrary query would tell an OpenAPI→MCP bridge nothing useful (hence no + ``SOURCE_PREFIXES`` entry either — see ``backlot/openapi.py``). Introspection is the schema + description for this endpoint. + + The layers answer in this order, each measured against api.linear.app on 2026-10-25 (UTC) with + the one after it wrong too: ``cors``'s preflight (:func:`_preflight`), the API key under + ``Bearer`` on any method but `OPTIONS`, ``body-parser`` on a body under a JSON type + (:func:`~backlot.graphql.body.express_reads_json`) whatever the method, then Apollo Server: its + CSRF check (:func:`~backlot.graphql.apollo_http.csrf_refusal`), its 405 for a method but GET and + POST, then the request as a GET's query string or a POST's body carries it, up to the choice of + operation; then the credential, and the run. Its answer is in the media type ``Accept`` takes, + or its 406 (:func:`_answer`).""" + method = request.method + if method in _LOAD_BALANCER_501: + return Response(status_code=501) + if method == "OPTIONS" and "origin" in request.headers: + return _preflight(request) + if method != "OPTIONS" and auth.linear_api_key_under_bearer(request): return _api_key_as_bearer() - # In a worker thread: `_read` can take seconds over a body near the 10 MiB limit, which would - # hold up every other request on the event loop. - payload = await run_in_threadpool( - _read, - await request.body(), - request.headers.get("content-type"), - request_body.joined_header(request.headers.getlist("content-encoding")), - ) - if isinstance(payload, Response): - return payload - # The telemetry middleware names the call by this document (backlot.telemetry.naming). - request.state.graphql_payload = payload - resolved = ENGINE.resolve_payload(_variables_read(payload)) + payload: Any = None + content_type = request.headers.get("content-type") + has_body = "transfer-encoding" in request.headers or "content-length" in request.headers + if has_body and request_body.express_reads_json(content_type): + # In a worker thread: `_read` can take seconds over a body near the 10 MiB limit, which + # would hold up every other request on the event loop. + payload = await run_in_threadpool( + _read, + await request.body(), + content_type, + request_body.joined_header(request.headers.getlist("content-encoding")), + ) + if isinstance(payload, Response): + return payload + if method == "POST": + # The telemetry middleware names the call by this document (backlot.telemetry.naming). + request.state.graphql_payload = payload + seen = apollo_http.headers_seen(request.headers.raw) + refusal = apollo_http.csrf_refusal(seen) + if refusal is None and method not in ("GET", "POST"): + refusal = apollo_http.method_refusal() + if refusal is None and method == "GET": + payload = apollo_http.search_payload(request.scope["query_string"]) + if isinstance(payload, engine.Refusal): + refusal = payload + else: + request.state.graphql_payload = payload + if refusal is not None: + return _answer(seen, engine.refused(refusal), None) + if method == "POST": + resolved = ENGINE.resolve_payload(_variables_read(payload)) + else: + resolved = ENGINE.resolve_payload(payload, get=True) if isinstance(resolved, engine.Result): - return _answer(request, resolved, payload) + return _answer(seen, resolved, payload) caller = auth.resolve_api_key(request) if caller is None: if _introspection_only(resolved): - return _answer(request, ENGINE.run(resolved), payload) - return Response( - request_body.node_json(_UNAUTHENTICATED), status_code=401, media_type="application/json" - ) + return _answer(seen, ENGINE.run(resolved), payload, request) + return _answer(seen, None, payload) state = request.app.state context = { "conn": auth.conn(request), @@ -142,7 +187,53 @@ async def graphql(request: Request): # entities with no table of their own through `linear_entities` # (store.linear_entity_by_id, called by the resolvers' own `_by_id`). } - return _answer(request, ENGINE.run(resolved, context=context), payload) + return _answer(seen, ENGINE.run(resolved, context=context), payload, request) + + +#: The headers ``cors`` writes on each preflight, as api.linear.app answered every one sent with +#: `Origin` on 2026-10-25 (UTC), and the edge's ``Cache-Control`` and ``Cache-Tag`` beside them. +_PREFLIGHT = ( + ("cache-control", "s-maxage=43200"), + ("vary", "Origin, Access-Control-Request-Headers"), + ("cache-tag", "preflight"), + ("access-control-allow-methods", "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS"), + ( + "access-control-expose-headers", + "Cache-Control,Content-Language,Content-Length,Content-Type,Expires,Last-Modified,Pragma," + "x-request-id,cf-ray,cf-cache-status,Retry-After", + ), + ("access-control-max-age", "86400"), +) + + +def _preflight(request: Request) -> Response: + """Linear's 204 to an `OPTIONS` carrying `Origin`, ahead of the credential and the body: + `Origin` echoed back, :data:`_PREFLIGHT`, and each name `Access-Control-Request-Headers` lists + without the spaces around it. api.linear.app answered so with no credential, `nope` and an API + key under `Bearer`, with `Access-Control-Request-Method` naming `POST`, `PUT` or none, with + `Origin: null`, and with `Content-Type, X-Foo`, ` a , b ` and `a,,b` asked for, which came back + `Content-Type,X-Foo`, `a,b` and `a,,b`. An `OPTIONS` without `Origin` is Apollo Server's.""" + headers = [("access-control-allow-origin", request.headers["origin"].strip(" \t"))] + asked = request.headers.get("access-control-request-headers") + if asked is not None: + headers.append( + ("access-control-allow-headers", ",".join(name.strip(" ") for name in asked.split(","))) + ) + response = Response(status_code=204) + response.raw_headers += [(k.encode(), v.encode("latin-1")) for k, v in (*headers, *_PREFLIGHT)] + return response + + +def _as_express_sends(media_type: str) -> str: + """``media_type`` as Express's ``res.send`` rewrites it through the ``content-type`` package: + parameter names in lower case, in the order of their names. api.linear.app answered + ``Accept: application/json; callbackSpec=1.0`` in + ``application/json; callbackspec=1.0; charset=utf-8`` on 2026-10-25 (UTC).""" + type_, *parameters = (part.strip() for part in media_type.split(";")) + pairs = sorted( + (name.lower(), value) for name, _, value in (p.partition("=") for p in parameters) + ) + return "; ".join([type_, *(f"{name}={value}" for name, value in pairs)]) # Linear's 401, as api.linear.app answered `{ viewer { id } }` with no `Authorization`, with @@ -193,12 +284,33 @@ def only(selections) -> bool: return only(operation.operation.selection_set.selections) -def _answer(request: Request, result: engine.Result, payload: Any) -> Response: - request.state.graphql_raised = result.raised +def _answer( + seen: dict[str, str], + result: engine.Result | None, + payload: Any, + request: Request | None = None, +) -> Response: + """Apollo Server's answer to ``result`` in Linear's envelope (:func:`_envelope`), or to Linear's + 401 where ``result`` is ``None``, which ``request``, where an operation ran, is told of + (``graphql_raised``). A refusal thrown before the request pipeline is in the media type + ``Accept`` takes or ``application/json``; any other answer is Apollo Server's 406 where it takes + none (:data:`~backlot.graphql.apollo_http.NOT_ACCEPTABLE`). api.linear.app answered each of the + two kinds so on 2026-10-25 (UTC), the 401 among the second.""" + media_type = apollo_http.response_type(seen.get("accept")) + thrown = result is not None and bool(result.refusals) and result.refusals[0].thrown + if media_type is None and not thrown: + result = engine.refused(apollo_http.NOT_ACCEPTABLE) + if request is not None and result is not None and not result.refusals: + request.state.graphql_raised = result.raised + if result is None: + body, status, headers = _UNAUTHENTICATED, 401, () + else: + body, status, headers = _envelope(result, payload), result.status, result.headers return Response( - request_body.node_json(_envelope(result, payload)), - status_code=result.status, - media_type="application/json", + request_body.node_json(body), + status_code=status, + headers=dict(headers), + media_type=_as_express_sends(media_type or apollo_http.JSON), ) @@ -221,13 +333,14 @@ def _variables_read(payload: Any) -> Any: _TYPE = {engine.INTERNAL_SERVER_ERROR: "internal error", _INVALID_INPUT: "invalid input"} -def _apollo(message: str, code: str, status: int, locations: Any = ()) -> dict: +def _apollo( + message: str, code: str, status: int, locations: Any = (), http_headers: bool = True +) -> dict: entry: dict = {"message": message} if locations: entry["locations"] = list(locations) - extensions: dict = ( - {} if code == engine.BAD_USER_INPUT else {"http": {"status": status, "headers": {}}} - ) + http: dict = {"status": status, "headers": {}} if http_headers else {"status": status} + extensions: dict = {} if code == engine.BAD_USER_INPUT else {"http": http} extensions["code"] = code extensions["type"] = _TYPE.get(code, "graphql error") extensions["userError"] = code != engine.INTERNAL_SERVER_ERROR @@ -294,7 +407,13 @@ def _envelope(result: engine.Result, payload: Any) -> dict: errors.append(_apollo(refusal.message, _INVALID_INPUT, refusal.status)) else: errors.append( - _apollo(refusal.message, refusal.code, refusal.status, refusal.locations) + _apollo( + refusal.message, + refusal.code, + refusal.status, + refusal.locations, + refusal.http_headers, + ) ) return {"errors": errors} if "errors" not in result.payload: diff --git a/backlot/telemetry/errors.py b/backlot/telemetry/errors.py index f989652e8..109ae5427 100644 --- a/backlot/telemetry/errors.py +++ b/backlot/telemetry/errors.py @@ -21,7 +21,7 @@ | ``unsupported`` | Backlot does not do this yet: the baseline records the operation as ``missing`` or ``refused``, or the GraphQL engine refused a filter | | ``internal`` | of a Backlot bug: its own failure, or a 5xx other than a vendor's answer (below) | | ``rate_limit`` | the vendor's rate limit was reached: a 429, or a 403 with ``x-ratelimit-remaining: 0`` as GitHub sends | -| ``auth`` | the credential was missing, wrong, or not allowed: a 401 or 403, or Slack's auth errors, which it sends with a 200 | +| ``auth`` | the credential was missing, wrong, or not allowed: a 401 or 403, Slack's auth errors, which it sends with a 200, or Fireflies' ``auth_failed``, which it sends with a 500 | | ``client`` | the request was wrong: any other failure | Backlot's own failure is an exception raised instead of an answer, or a field error the GraphQL @@ -175,8 +175,12 @@ def error_code(source: str, status: int, body: bytes) -> str: return "" -# Slack sends its auth errors with a 200. -_SLACK_AUTH = frozenset({"account_inactive", "invalid_auth", "not_authed"}) +# The auth errors a vendor answers at a status other than 401 and 403: Slack's at 200, and Fireflies' +# at 500 (`backlot.routers.fireflies._auth_failed`). +_AUTH = frozenset( + {("slack", "account_inactive"), ("slack", "invalid_auth"), ("slack", "not_authed")} + | {("fireflies", "auth_failed")} +) # Each 5xx a vendor answers a wrong request with, by source and error code, and Backlot answers too: # Fireflies' 500 for a document that reaches a fragment it does not define # (`backlot.routers.fireflies._fragments_unreached`). @@ -194,7 +198,7 @@ def error_class( return "unsupported" if status == 429 or (status == 403 and spent): return "rate_limit" - if status in (401, 403) or (source == "slack" and code in _SLACK_AUTH): + if status in (401, 403) or (source, code) in _AUTH: return "auth" if status >= 500 and status != 501 and (source, code) not in _ANSWERED_5XX: return "internal" diff --git a/docs/fidelity.md b/docs/fidelity.md index 081d24675..25e2201da 100644 --- a/docs/fidelity.md +++ b/docs/fidelity.md @@ -488,7 +488,7 @@ suite. Declared, not served: six live routes are `include_in_schema=False` and so invisible to that check. Four are FastAPI's own (`/docs`, `/docs/oauth2-redirect`, `/openapi.json`, `/redoc`). The other two -are the GraphQL POSTs at `/fireflies/graphql` and `/linear/graphql`, which their own comparison +are the GraphQL endpoints at `/fireflies/graphql` and `/linear/graphql`, which their own comparison covers — introspection, not a path map, so there is no mount to say so. Walking `app.routes` instead was ruled out for `gen_docs.py`, and the same reasoning holds here. diff --git a/docs/supported-sources.md b/docs/supported-sources.md index 6f7c7a956..53bd65406 100644 --- a/docs/supported-sources.md +++ b/docs/supported-sources.md @@ -16,13 +16,13 @@ Generated from `backlot/schemas/*.schema.json` and the app's own `/openapi.json` | `source_type` | Service | URL prefix | Endpoints | Record schema | What one record is | |---|---|---|---|---|---| | `confluence` | Confluence | `/atlassian/wiki/rest/api` | 10 | [`confluence.schema.json`](../backlot/schemas/confluence.schema.json) | A Confluence page or blogpost. | -| `fireflies` | Fireflies | `/fireflies/graphql` | GraphQL (one `POST`) | [`fireflies.schema.json`](../backlot/schemas/fireflies.schema.json) | A Fireflies.ai meeting transcript. | +| `fireflies` | Fireflies | `/fireflies/graphql` | GraphQL (one endpoint, `POST` or `GET`) | [`fireflies.schema.json`](../backlot/schemas/fireflies.schema.json) | A Fireflies.ai meeting transcript. | | `github` | GitHub | `/github` | 34 | [`github.schema.json`](../backlot/schemas/github.schema.json) | A GitHub issue, pull request, file, or the repository itself. | | `gmail` | Gmail | `/gmail/v1` | 8 | [`gmail.schema.json`](../backlot/schemas/gmail.schema.json) | A Gmail message. | | `google_drive` | Google Drive, Docs, Sheets, Slides | `/drive/v3` `/docs/v1` `/sheets/v4` `/slides/v1` | 13 | [`google_drive.schema.json`](../backlot/schemas/google_drive.schema.json) | A Google Drive file. | | `hubspot` | HubSpot | `/hubspot` | 5 | [`hubspot.schema.json`](../backlot/schemas/hubspot.schema.json) | A HubSpot CRM record (contact, company, deal, ticket, note, …). | | `jira` | Jira | `/atlassian/rest/api` | 14 | [`jira.schema.json`](../backlot/schemas/jira.schema.json) | A Jira issue. | -| `linear` | Linear | `/linear/graphql` | GraphQL (one `POST`) | [`linear.schema.json`](../backlot/schemas/linear.schema.json) | A Linear issue. | +| `linear` | Linear | `/linear/graphql` | GraphQL (one endpoint, `POST` or `GET`) | [`linear.schema.json`](../backlot/schemas/linear.schema.json) | A Linear issue. | | `msteams` | Microsoft Teams | `/msgraph/v1.0` | 13 | [`msteams.schema.json`](../backlot/schemas/msteams.schema.json) | A Microsoft Teams channel message. | | `notion` | Notion | `/notion/v1` | 12 | [`notion.schema.json`](../backlot/schemas/notion.schema.json) | A Notion page or database. | | `s3` | Amazon S3 | `/s3` | 5 | [`s3.schema.json`](../backlot/schemas/s3.schema.json) | An S3 object. | @@ -87,7 +87,10 @@ what `search` answers a `GET` or `HEAD`, but for a 401 and a refusal of its `Acc ### Fireflies — `/fireflies/graphql` -**GraphQL only**, one `POST`. Root `Query` fields: +**GraphQL only**, one endpoint: a `POST` with a JSON body, or a `GET` with the query in its query +string and a JSON `Content-Type` or none. Apollo Server serves a `GET` with none only beside an +`apollo-require-preflight` or `x-apollo-operation-name` header, its check that a browser +preflighted the request. Root `Query` fields: | Query field | Notes | |---|---| @@ -553,7 +556,10 @@ operation's 415 carry the account id alone. ### Linear — `/linear/graphql` -**GraphQL only**, one `POST`. Root `Query` fields: +**GraphQL only**, one endpoint: a `POST` with a JSON body, or a `GET` with the query in its query +string. Apollo Server serves a `GET` with no `Content-Type`, or a form or `text/plain` one, only +beside an `apollo-require-preflight` or `x-apollo-operation-name` header, its check that a +browser preflighted the request. Root `Query` fields: | Query field | Notes | |---|---| diff --git a/pyproject.toml b/pyproject.toml index f1ae3e23f..7f9de2e68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -243,6 +243,7 @@ backlot = [ "schemas/*.json", "schemas/*.md", "graphql/*.graphql", + "graphql/*.html", "data/*.json", "data/*.jsonl", "data/*.wadl", diff --git a/scripts/gen_docs.py b/scripts/gen_docs.py index 3c2d307a4..1cb6b136b 100644 --- a/scripts/gen_docs.py +++ b/scripts/gen_docs.py @@ -215,7 +215,7 @@ def render_sources() -> str: for source_type in sorted(SOURCES): name, prefixes = SOURCES[source_type] if source_type in GRAPHQL_ONLY: - endpoints = "GraphQL (one `POST`)" + endpoints = "GraphQL (one endpoint, `POST` or `GET`)" else: endpoints = str( sum(1 for path in spec_paths if any(path.startswith(p) for p in prefixes)) diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index 562a345d7..cac9de33f 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -328,7 +328,9 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( credential = admin if vendor == "linear" else f"Bearer {admin}" body = '{"query":"%s","variables":{"x":%s}}' % (document, number) r = client.post( - f"/{vendor}/graphql", content=body.encode(), headers={"Authorization": credential} + f"/{vendor}/graphql", + content=body.encode(), + headers={"Authorization": credential, "Content-Type": "application/json"}, ) assert r.status_code == 400 message = r.json()["errors"][0]["message"] diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index b20ed68dc..cc25f0179 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -8,6 +8,7 @@ import gzip import json +import re import sqlite3 import time @@ -1321,3 +1322,627 @@ def _roster_emails(settings): data = yaml.safe_load(settings.tokens_path.read_text(encoding="utf-8")) or {} return [u["email"] for u in data.get("users", [])] + + +# --- the HTTP layer: methods, content types, Accept and CORS ------------------------------------- +# The request, the credential (`_FIREFLIES_CREDENTIALS`) and the answer api.fireflies.ai gave it on +# 2026-10-25 (UTC): its status, its media type and its body but for the newline Apollo Server ends +# one with, `_TYPENAME_DATA` where it served the query, and none for a `HEAD`. +_CSRF = _apollo( + b"This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). Please" + b" either specify a 'content-type' header (with a type that is not one of" + b" application/x-www-form-urlencoded, multipart/form-data, text/plain) or provide a non-empty" + b" value for one of the following headers: x-apollo-operation-name, apollo-require-preflight" + b"\\n" +) +_ONLY_GET_POST = _apollo(b"Apollo Server supports only GET/POST requests.") +_NOT_ACCEPTABLE = _apollo( + b"An 'accept' header was provided for this request which does not accept application/json;" + b" charset=utf-8 or application/graphql-response+json; charset=utf-8" +) +_GET_CONTENT_TYPE = _apollo( + b"GET requests may not have a content-type header other than application/json." +) +_UNSUPPORTED = ( + b'{"statusCode":415,"status":false,"code":"http","message":"Unsupported Media Type",' + b'"errorMessage":"Unsupported Media Type"}' +) +_TYPENAME_DATA = b'{"data":{"__typename":"Query"}}' +_JSON_UTF8 = "application/json; charset=utf-8" +_GRAPHQL_RESPONSE = "application/graphql-response+json; charset=utf-8" +_CLOUDFLARE_PAGE = ( + b"\r\n%s\r\n\r\n

%s

\r\n" + b"
cloudflare
\r\n\r\n\r\n" +) +_FIREFLIES_PREFLIGHT = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,HEAD,POST", + "vary": "Access-Control-Request-Headers", +} +_J = (("Content-Type", "application/json"),) +_P = (("apollo-require-preflight", "1"),) +_PREFLIGHT_ASKED = (("Origin", "https://example.com"), ("Access-Control-Request-Method", "POST")) +_Q = "?query=%7B%20__typename%20%7D" +_PAST_THE_LIMIT = b"x" * (2**20 + 10) +_LANDING_PAGE = object() # Apollo Server's page for a browser, `landing_page`'s + + +def _not_found(method: str, target: str) -> bytes: + message = b"Cannot %s /graphql%s" % (method.encode(), target.encode()) + return ( + b'{"statusCode":404,"status":false,"code":"not_found","message":"%s","errorMessage":"%s"}' + % (message, message) + ) + + +def _http_row( + method, target, sent, body, credential, status, media_type, answer, id, answered=None +): + """A row: the request, the credential, and the answer, ``answered`` naming headers on it.""" + return pytest.param( + method, target, sent, body, credential, status, media_type, answer, answered or {}, id=id + ) + + +_FIREFLIES_HTTP = [ + # Cloudflare, ahead of everything. + _http_row( + "TRACE", + "", + (), + None, + "key", + 405, + "text/html", + _CLOUDFLARE_PAGE % ((b"405 Not Allowed",) * 2), + id="TRACE", + ), + _http_row( + "CONNECT", + "", + (), + None, + "key", + 400, + "text/plain; charset=UTF-8", + b"error code: 1045\n", + id="CONNECT", + ), + _http_row( + "PURGE", + "", + (), + None, + "key", + 400, + "text/html; charset=UTF-8", + _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), + id="PURGE", + ), + _http_row( + "TRACE", + "", + (), + None, + "twice", + 400, + "text/html", + _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), + id="TRACE, Authorization twice", + ), + # `@fastify/cors`, whatever the credential and the body. + *( + _http_row( + "OPTIONS", + "", + _PREFLIGHT_ASKED, + None, + c, + 204, + None, + b"", + id=f"preflight, {c}", + answered=_FIREFLIES_PREFLIGHT, + ) + for c in ("key", "none", "Bearer nope") + ), + _http_row( + "OPTIONS", + "", + (("Origin", "null"), ("Access-Control-Request-Method", "POST")), + None, + "key", + 204, + None, + b"", + id="Origin null", + answered=_FIREFLIES_PREFLIGHT, + ), + _http_row( + "OPTIONS", + "", + (("Origin", "https://example.com"), ("Access-Control-Request-Method", "PUT")), + None, + "key", + 204, + None, + b"", + id="asked PUT", + answered=_FIREFLIES_PREFLIGHT, + ), + *( + _http_row( + "OPTIONS", + "", + (*_PREFLIGHT_ASKED, ("Access-Control-Request-Headers", asked)), + None, + "key", + 204, + None, + b"", + id=f"asked {asked!r}", + answered={**_FIREFLIES_PREFLIGHT, "access-control-allow-headers": allowed}, + ) + for asked, allowed in ( + ("Content-Type, X-Foo", "Content-Type, X-Foo"), + (" a , b ", "a , b"), + ("a,,b", "a,,b"), + ) + ), + *( + _http_row( + "OPTIONS", + "", + h, + b, + c, + 400, + "text/plain", + b"Invalid Preflight Request", + id=f"OPTIONS {i}, {c}", + ) + for h, b, c, i in ( + ((), None, "key", ""), + ((), None, "none", ""), + ((), None, "Bearer nope", ""), + ((("Origin", "https://example.com"),), None, "key", "Origin alone"), + ((("Access-Control-Request-Method", "POST"),), None, "key", "no Origin"), + ((*_J, *_P), b"{", "key", "{"), + ) + ), + # Fastify's 404 for a method its route does not take, once it has read a body it reads. + _http_row( + "ACL", + "?_=271823726", + (*_J, *_P), + None, + "key", + 404, + _JSON_UTF8, + b'{"statusCode":404,"status":false,"code":"not_found","message":"Cannot ACL /graphql?_=27182' + b'3726","errorMessage":"Cannot ACL /graphql?_=271823726"}', + id="ACL", + ), + *( + _http_row(m, "?_=1", (*_J, *_P), None, "key", 404, _JSON_UTF8, _not_found(m, "?_=1"), id=m) + for m in ("PROPFIND", "QUERY", "NOTIFY", "BIND", "M-SEARCH", "SOURCE") + ), + _http_row( + "PROPFIND", + "?x=%7B%20y&z&a=b+c&_=1", + _J, + None, + "key", + 404, + _JSON_UTF8, + _not_found("PROPFIND", "?x=%7B%20y&z&a=b+c&_=1"), + id="PROPFIND, a query string", + ), + *( + _http_row(m, "", h, b, c, s, _JSON_UTF8, a, id=f"{m} {i}") + for m, h, b, c, s, a, i in ( + ("PUT", (*_J, *_P), _TYPENAME, "key", 404, _not_found("PUT", ""), "a query"), + ("DELETE", _J, _TYPENAME, "none", 404, _not_found("DELETE", ""), "a query"), + ("PUT", (), _TYPENAME, "key", 404, _not_found("PUT", ""), "no type"), + ( + "PATCH", + (("Content-Type", "application/x-www-form-urlencoded"),), + b"query=%7B%7D", + "key", + 404, + _not_found("PATCH", ""), + "a form", + ), + ( + "DELETE", + (("Content-Type", "text/plain"),), + b"", + "key", + 404, + _not_found("DELETE", ""), + "text/plain", + ), + ("PUT", (*_J, *_P), b"{", "key", 400, _NOT_JSON, "{"), + ("PATCH", (*_J, *_P), None, "key", 400, _EMPTY, "empty"), + ("PUT", (("Content-Type", "a/b/c"),), _TYPENAME, "key", 415, _UNSUPPORTED, "a/b/c"), + ("PUT", (("Content-Type", ""),), _TYPENAME, "key", 415, _UNSUPPORTED, "an empty type"), + ( + "PUT", + (("Content-Type", "text/plain"),), + _PAST_THE_LIMIT, + "key", + 413, + _TOO_LARGE, + "past the limit", + ), + ) + ), + # A POST's content type: a 415 for one Fastify does not read; CSRF for one a browser sends + # unpreflighted; the CSRF check ahead of the credential. + *( + _http_row("POST", "", (("Content-Type", t), *h), b, c, s, _JSON_UTF8, a, id=f"{t!r}{i}") + for t, h, b, c, s, a, i in ( + ("text/plain", (), _TYPENAME, "key", 400, _CSRF, ""), + ("text/plain", (), _TYPENAME, "none", 400, _CSRF, ", none"), + ("text/plain", _P, _TYPENAME, "key", 400, _NO_KEYS, ", preflight"), + ("TEXT/PLAIN;X=Y", (), _TYPENAME, "key", 400, _CSRF, ""), + ("application/x-www-form-urlencoded", (), _TYPENAME, "key", 400, _CSRF, ""), + ("", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), + ("foo", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), + ("text /plain", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), + ("multipart/form-data; boundary=x", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), + ("application/graphql", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), + ( + "application/graphql", + (), + _PAST_THE_LIMIT, + "key", + 415, + _UNSUPPORTED, + ", past the limit", + ), + ("application/json ,", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), + ("text/plain", (), _PAST_THE_LIMIT, "key", 413, _TOO_LARGE, ", past the limit"), + ( + "application/x-www-form-urlencoded", + (), + _PAST_THE_LIMIT, + "key", + 413, + _TOO_LARGE, + ", past the limit", + ), + ("application/json;", (), _TYPENAME, "key", 200, _TYPENAME_DATA, ""), + ("application/json\t;", (), _TYPENAME, "key", 200, _TYPENAME_DATA, ""), + ("Application/JSON", (), _TYPENAME, "key", 200, _TYPENAME_DATA, ""), + ( + "application/x-www-form-urlencoded", + _P, + b"query=%7B%20__typename%20%7D", + "key", + 200, + _TYPENAME_DATA, + ", a form", + ), + ( + "application/x-www-form-urlencoded", + _P, + b"query=%7B%20__typename%20%7D&query=x", + "key", + 400, + _apollo(b"GraphQL queries must be strings."), + ", query twice", + ), + ( + "application/x-www-form-urlencoded", + _P, + b"query=%7B%20__typename%20%7D&variables=%7B%7D", + "key", + 400, + _apollo( + b"`variables` in a POST body should be provided as an object, not a recursively JSON-encoded string." + ), + ", variables", + ), + ("application/x-www-form-urlencoded", _P, b"", "key", 400, _NO_KEYS, ", empty"), + ("text/plain", _P, b"", "key", 400, _NO_KEYS, ", empty"), + ) + ), + _http_row("POST", "", (), _TYPENAME, "key", 415, _JSON_UTF8, _UNSUPPORTED, id="no type"), + _http_row( + "POST", "", _P, _TYPENAME, "key", 415, _JSON_UTF8, _UNSUPPORTED, id="no type, preflight" + ), + _http_row("POST", "", (), b"", "key", 400, _JSON_UTF8, _CSRF, id="no type, empty"), + _http_row( + "POST", "", _P, b"", "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, empty, preflight" + ), + # Apollo Server's page for a browser, ahead of the CSRF check and the credential. + *( + _http_row("GET", t, (*h, ("Accept", a)), None, c, s, ty, e, id=f"GET, Accept {a!r}{i}") + for t, h, a, c, s, ty, e, i in ( + ("", (), "text/html", "none", 200, "text/html", _LANDING_PAGE, ", none"), + (_Q, _P, "text/html,application/json", "key", 200, "text/html", _LANDING_PAGE, ""), + (_Q, _P, "text/*", "key", 200, "text/html", _LANDING_PAGE, ""), + (_Q, _P, "text/html;q=0.5;charset=utf-8", "key", 200, "text/html", _LANDING_PAGE, ""), + ( + _Q, + _P, + "application/json;q=0.5, text/html;q=0.6", + "key", + 200, + "text/html", + _LANDING_PAGE, + "", + ), + (_Q, _P, "text/html;level=1", "key", 406, _JSON_UTF8, _NOT_ACCEPTABLE, ""), + (_Q, _P, "text/html;q", "key", 406, _JSON_UTF8, _NOT_ACCEPTABLE, ""), + ( + _Q, + _P, + "text/html;q=0.9, application/json", + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + "", + ), + ) + ), + # A GET: the CSRF check, the credential, Apollo Server 5.5's 415 for a content type, then its + # query string. + _http_row("GET", _Q, _P, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET"), + _http_row("GET", _Q, (), None, "key", 400, _JSON_UTF8, _CSRF, id="GET, unpreflighted"), + _http_row("GET", _Q, _J, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET, JSON"), + *( + _http_row( + "GET", _Q, (("Content-Type", t), *h), None, c, s, _JSON_UTF8, a, id=f"GET, {t!r}{i}" + ) + for t, h, c, s, a, i in ( + ("text/plain", _P, "key", 415, _GET_CONTENT_TYPE, ""), + ("foo", (), "key", 415, _GET_CONTENT_TYPE, ""), + ("foo", _P, "none", 500, _BAD_KEY, ", none"), + ("application/graphql-response+json", _P, "key", 415, _GET_CONTENT_TYPE, ""), + ("", _P, "key", 200, _TYPENAME_DATA, ""), + ("application/json; charset=latin1", _P, "key", 200, _TYPENAME_DATA, ""), + ) + ), + _http_row( + "GET", + "?query=%7B&query=%7B", + (("Content-Type", "text/plain"), *_P), + None, + "key", + 415, + _JSON_UTF8, + _GET_CONTENT_TYPE, + id="GET, text/plain, query twice", + ), + *( + _http_row("GET", t, _P, None, "key", 400, _JSON_UTF8, _apollo(m), id=i) + for t, m, i in ( + ( + _Q + "&query=", + b"The 'query' search parameter may only be specified once.", + "query twice", + ), + ( + _Q + "&variables=nope", + b"The variables search parameter contains invalid JSON.", + "variables nope", + ), + ( + _Q + "&variables=null", + b"The variables search parameter should contain a JSON-encoded object.", + "variables null", + ), + ( + "?query=%7B&query=%7B&variables=nope", + b"The 'query' search parameter may only be specified once.", + "query twice, variables nope", + ), + ( + "?operationName=A&operationName=B&variables=nope", + b"The 'operationName' search parameter may only be specified once.", + "operationName twice, variables nope", + ), + ( + "?variables=nope&extensions=5", + b"The variables search parameter contains invalid JSON.", + "variables nope, extensions 5", + ), + ) + ), + *( + _http_row( + "GET", + f"?query={op}%20%7B%20__typename%20%7D", + _P, + None, + c, + s, + _JSON_UTF8, + a, + id=f"GET {op}, {c}", + **h, + ) + for op, c, s, a, h in ( + ( + "mutation", + "key", + 405, + _apollo( + b"GET requests only support query operations, not mutation operations", + status=405, + ), + {"answered": {"allow": "POST"}}, + ), + ( + "subscription", + "key", + 405, + _apollo( + b"GET requests only support query operations, not subscription operations", + status=405, + ), + {"answered": {"allow": "POST"}}, + ), + ("mutation", "none", 500, _BAD_KEY, {}), + ) + ), + _http_row( + "HEAD", + _Q, + _P, + None, + "key", + 405, + _JSON_UTF8, + b"", + id="HEAD", + answered={"allow": "GET, POST"}, + ), + _http_row( + "HEAD", + _Q, + (*_P, ("Accept", "text/html")), + None, + "key", + 405, + _JSON_UTF8, + b"", + id="HEAD, Accept text/html", + ), + _http_row("HEAD", _Q, _P, None, "none", 500, _JSON_UTF8, b"", id="HEAD, none"), + _http_row( + "HEAD", + _Q, + (("Content-Type", "text/plain"),), + None, + "key", + 400, + _JSON_UTF8, + b"", + id="HEAD, text/plain", + ), + # Accept: the media type of an answer, Apollo Server's 406 once its request pipeline has + # answered, and none for a refusal it throws before that, the credential's among them. + *( + _http_row("POST", "", (*_J, ("Accept", a)), b, c, s, t, e, id=f"Accept {a!r}{i}") + for a, b, c, s, t, e, i in ( + ("text/html", _TYPENAME, "key", 406, _JSON_UTF8, _NOT_ACCEPTABLE, ""), + ("application/json;q=0", _TYPENAME, "key", 406, _JSON_UTF8, _NOT_ACCEPTABLE, ""), + ("application/json;q", _TYPENAME, "key", 406, _JSON_UTF8, _NOT_ACCEPTABLE, ""), + ( + "application/graphql-response+json", + _TYPENAME, + "key", + 200, + _GRAPHQL_RESPONSE, + _TYPENAME_DATA, + "", + ), + ( + "application/json; callbackSpec=1.0", + _TYPENAME, + "key", + 200, + "application/json; callbackSpec=1.0; charset=utf-8", + _TYPENAME_DATA, + "", + ), + ("text/html", b"{}", "key", 400, _JSON_UTF8, _NO_KEYS, ", {}"), + ( + "application/graphql-response+json", + b"{}", + "key", + 400, + _GRAPHQL_RESPONSE, + _NO_KEYS, + ", {}", + ), + ( + "text/html", + b'{"query":"{"}', + "key", + 406, + _JSON_UTF8, + _NOT_ACCEPTABLE, + ", a parse error", + ), + ("text/html", _TYPENAME, "none", 500, _JSON_UTF8, _BAD_KEY, ", none"), + ( + "application/graphql-response+json", + _TYPENAME, + "none", + 500, + _GRAPHQL_RESPONSE, + _BAD_KEY, + ", none", + ), + ("text/html", b"{", "key", 400, _JSON_UTF8, _NOT_JSON, ", {"), + ) + ), + _http_row( + "POST", + "", + (("Content-Type", "text/plain"), ("Accept", "application/graphql-response+json")), + _TYPENAME, + "key", + 400, + _GRAPHQL_RESPONSE, + _CSRF, + id="text/plain, Accept graphql-response", + ), + _http_row( + "PUT", + "", + (*_J, ("Accept", "application/graphql-response+json")), + _TYPENAME, + "key", + 404, + _JSON_UTF8, + _not_found("PUT", ""), + id="PUT, Accept graphql-response", + ), +] +_FIREFLIES_CREDENTIALS = { + "none": None, + "Bearer nope": "Bearer nope", + "key": "Bearer {t}", + "twice": "Bearer {t}", +} + + +@pytest.mark.parametrize( + "method,target,headers,body,credential,status,media_type,answer,expected_headers", + _FIREFLIES_HTTP, +) +def test_fireflies_answers_each_method_type_and_accept_as_fireflies_does( + client, + tokens_yaml, + method, + target, + headers, + body, + credential, + status, + media_type, + answer, + expected_headers, +): + """Each row of `_FIREFLIES_HTTP` is answered as api.fireflies.ai answered it.""" + sent = list(headers) + if _FIREFLIES_CREDENTIALS[credential] is not None: + value = _FIREFLIES_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) + sent += [("Authorization", value)] * (2 if credential == "twice" else 1) + r = sent_as(client, method, f"/fireflies/graphql{target}", sent, content=body) + assert r.status_code == status + assert r.headers.get("content-type") == media_type + if answer is _LANDING_PAGE: + page = r.text + nonces = set(re.findall(r"nonce-([0-9a-f]+)", page)) + assert len(nonces) == 1 and len(nonces.pop()) == 64 + assert len(page.encode()) == 3522 and "Apollo Server" in page + else: + assert r.content == answer + assert {name: r.headers.get(name) for name in expected_headers} == expected_headers diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 834d64dc9..72157397f 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -27,8 +27,8 @@ from starlette.testclient import TestClient from backlot import auth, pagination, store +from backlot.graphql import apollo_http, engine, mcp_tools, v8_json from backlot.graphql import body as request_body -from backlot.graphql import engine, mcp_tools, v8_json from tests._helpers import selected_fields SDL = """ @@ -1994,3 +1994,167 @@ def test_a_derived_document_validates_against_the_schema(tools): def test_a_tool_carries_its_fields_description(tools): assert "Loose notes, newest first." in tools["notes"].description + + +# --- Apollo Server's HTTP layer, and the Node libraries in front of it -------------------------- +# Each value and what the library the port reads off answers for it under Node 24.18.0, asked on +# 2026-10-25: negotiator 1.0.0 (`apollo_http.response_type`, `apollo_http.prefers_html`), +# whatwg-mimetype 4.0.0 (`apollo_http.mime_essence`), type-is 2.0.1 over content-type 1.0.5 +# (`body.express_reads_json`), Fastify 5.10.0's `ContentType` (`body.fastify_media_type`) and +# fast-querystring 1.1.2 (`body.form_fields`). +_ACCEPTED = [ + ("*/*", apollo_http.JSON, False), + ("application/*", apollo_http.JSON, False), + ("text/html", None, True), + ("", apollo_http.JSON, False), + ("application/json;q=0", None, False), + ("application/json;q", None, False), + ("application/json;q=abc", None, False), + ("application/json;q=1.5", apollo_http.JSON, False), + ("application/json; charset=utf-8", apollo_http.JSON, False), + ("application/json; charset=latin1", None, False), + ("APPLICATION/JSON", apollo_http.JSON, False), + ("application/json;Q=0", None, False), + ("application/json;q=0.5;charset=latin1", apollo_http.JSON, False), + ('application/json;x="a,b"', None, False), + ('"a,b", application/json', apollo_http.JSON, False), + ("text/html;q=0.5;charset=utf-8", None, True), + ("text/html;level=1", None, False), + (",", None, False), + ("foo", None, False), + ("application/json\xa0", apollo_http.JSON, False), + ("application/json\x85", None, False), + ("application/json;q=Infinity", apollo_http.JSON, False), + ("application/json;q=-0", None, False), + ("application/json; callbackSpec=1.0", apollo_http.CALLBACK, False), + ( + "application/graphql-response+json;q=0.5, application/json;q=0.5", + apollo_http.GRAPHQL_RESPONSE, + False, + ), + ("application/json, application/graphql-response+json", apollo_http.JSON, False), + ("application/json;q=0, */*", apollo_http.GRAPHQL_RESPONSE, False), + ("text/html, application/json", apollo_http.JSON, True), + ("text/*", None, True), + ("*/*;q=0.001, text/html;q=0.0005", apollo_http.JSON, False), + ("application/json;q= 0.5", apollo_http.JSON, False), + ("application/json;q=0.5x", apollo_http.JSON, False), + ("application/json ;q=0", None, False), + ("application/json;q =0", None, False), +] + + +@pytest.mark.parametrize("accept,media_type,html", _ACCEPTED) +def test_apollo_server_reads_accept_as_negotiator_does(accept, media_type, html): + """Each row of `_ACCEPTED`: the media type negotiator picks for a single result, and whether it + puts `text/html` first for a GET.""" + assert apollo_http.response_type(accept) == media_type + assert apollo_http.prefers_html("GET", accept) is html + assert apollo_http.prefers_html("POST", accept) is False + + +_ESSENCES = [ + (" text/plain ", "text/plain"), + ("TEXT/PLAIN;x", "text/plain"), + ("text/plain ;", "text/plain"), + ("text/plain\t", "text/plain"), + ("text/plain\xa0", None), + ("text /plain", None), + ("text/ plain", None), + ("a/b/c", None), + ("/b", None), + ("a/", None), + ("", None), + ("text/plain, application/json", None), + ('text/plain;"', "text/plain"), + ("text/pl@in", None), + ("\ttext/plain;charset=utf-8", "text/plain"), + ("multipart/form-data; boundary=x", "multipart/form-data"), + ("application/x-www-form-urlencoded", "application/x-www-form-urlencoded"), + ("*/*", "*/*"), + ("text/plain\x0c", None), +] + + +@pytest.mark.parametrize("content_type,essence", _ESSENCES) +def test_apollo_server_reads_a_content_type_as_whatwg_mimetype_does(content_type, essence): + assert apollo_http.mime_essence(content_type) == essence + + +_EXPRESS_JSON = [ + ("application/json", True), + ("Application/JSON", True), + (" application/json ", True), + ("application/json;", False), + ("application/json; foo", False), + ('application/json;x="a;b"', True), + ('application/json;x="a', False), + ("application/json; charset=utf-8; x=y", True), + ("application/json\xa0", True), + ("application/json\x85", False), + ("application/json\t;charset=utf-8", True), + ("application/json ;", False), + ("application/json+x", False), + ("", False), + ("application/json;charset=utf-8,", False), + ('application/json; x="\\""', True), + ("1application/json", False), +] + + +@pytest.mark.parametrize("content_type,read", _EXPRESS_JSON) +def test_body_parser_reads_json_where_type_is_does(content_type, read): + assert request_body.express_reads_json(content_type) is read + + +_FASTIFY_TYPES = [ + ("application/json", "application/json"), + ("application/json;", "application/json"), + ("application/json ,", None), + ("application/json\t;", "application/json"), + ("application/json x", None), + ("\xa0application/json", "application/json"), + ("application/json\xa0", "application/json"), + ("application/json\x85", None), + ("", None), + ("undefined", None), + ("Undefined", None), + ("a/b/c", None), + ("text /plain", None), + ("text/ plain", None), + ("TEXT/PLAIN;X=Y", "text/plain"), + ("application/json; x=y, text/plain", "application/json"), + ("/json", None), + ("application/", None), + ("app_lication/json", "app_lication/json"), + ("application/json@", None), + ("text/plain\x0b", "text/plain"), +] + + +@pytest.mark.parametrize("content_type,media_type", _FASTIFY_TYPES) +def test_fastify_reads_a_content_type_as_its_content_type_class_does(content_type, media_type): + assert request_body.fastify_media_type(content_type) == media_type + + +_FORMS = [ + ("query=%7B%20__typename%20%7D", {"query": "{ __typename }"}), + ("a&b=&=c&&d", {"a": "", "b": "", "": "c", "d": ""}), + ("a=1&a=2&a=3", {"a": ["1", "2", "3"]}), + ("a+b=c+d", {"a b": "c d"}), + ("a=%zz", {"a": "%zz"}), + ("a=%C3%A9", {"a": "\u00e9"}), + ("a=%C3", {"a": "%C3"}), + ("a=%E2%82", {"a": "%E2%82"}), + ("%zz=1", {"%zz": "1"}), + ("a==b", {"a": "=b"}), + ("a=%41%2", {"a": "%41%2"}), + ("x=%F0%9F%98%80", {"x": "\U0001f600"}), + ("__proto__=1", {"__proto__": "1"}), + ("a=1&b", {"a": "1", "b": ""}), +] + + +@pytest.mark.parametrize("text,fields", _FORMS) +def test_a_form_is_read_as_fast_querystring_reads_it(text, fields): + assert request_body.form_fields(text) == fields diff --git a/tests/test_linear.py b/tests/test_linear.py index 67a67fcc4..b3a761b72 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -4587,7 +4587,11 @@ def test_linear_team_membership_is_the_teams_group(tmp_path, ava_groups): for x in (synth.linear_id("nope"), "a\ud800b"): body = {"query": "query($x: String!) { teamMembership(id: $x) { id } }"} body["variables"] = {"x": x} - miss = client.post("/linear/graphql", content=json.dumps(body), headers=h) + miss = client.post( + "/linear/graphql", + content=json.dumps(body), + headers={**h, "Content-Type": "application/json"}, + ) assert miss.status_code == 200 assert miss.json()["errors"][0]["message"] == "Entity not found: TeamMembership" @@ -5690,3 +5694,588 @@ def got(result): backward = page("last: 3, before: " + lit(backward["pageInfo"]["endCursor"])) assert seen == forward[::-1] assert len(seen) == len(set(seen)) + + +# --- the HTTP layer: methods, content types, Accept and CORS ------------------------------------- +# The request, the credential (`_LINEAR_CREDENTIALS`) and the answer api.linear.app gave it on +# 2026-10-25 (UTC): its status, its media type and its body but for the newline Apollo Server ends +# one with, `_TYPENAME_DATA` where it served the query, and none for a `HEAD`. +_CSRF = ( + b'{"errors":[{"message":"This operation has been blocked as a potential Cross-Site Request' + b" Forgery (CSRF). Please either specify a 'content-type' header (with a type that is not one" + b" of application/x-www-form-urlencoded, multipart/form-data, text/plain) or provide a" + b" non-empty value for one of the following headers: x-apollo-operation-name," + b' apollo-require-preflight\\n","extensions":{"http":{"status":400,"headers":{}},"code":"BAD_R' + b'EQUEST","type":"graphql error","userError":true}}]}' +) +_ONLY_GET_POST = ( + b'{"errors":[{"message":"Apollo Server supports only GET/POST requests.","extensions":{"http":' + b'{"status":405,"headers":{}},"code":"BAD_REQUEST","type":"graphql error","userError":true}}]}' +) +_GET_MUTATION = ( + b'{"errors":[{"message":"GET requests only support query operations, not mutation operations"' + b',"extensions":{"http":{"status":405,"headers":{}},"code":"BAD_REQUEST","type":"graphql erro' + b'r","userError":true}}]}' +) +_NOT_ACCEPTABLE = ( + b'{"errors":[{"message":"An \'accept\' header was provided for this request which does not' + b" accept application/json; charset=utf-8 or application/graphql-response+json; charset=utf-8" + b'","extensions":{"http":{"status":406},"code":"BAD_REQUEST","type":"graphql error","userErr' + b'or":true}}]}' +) +_NO_QUERY = ( + b'{"errors":[{"message":"GraphQL operations must contain a non-empty `query` or a `persistedQ' + b'uery` extension.","extensions":{"http":{"status":400,"headers":{}},"code":"INVALID_INPUT","' + b'type":"invalid input","userError":true}}]}' +) +_TYPENAME_DATA = b'{"data":{"__typename":"Query"}}' +_JSON_UTF8 = "application/json; charset=utf-8" +_GRAPHQL_RESPONSE = "application/graphql-response+json; charset=utf-8" +_CLOUDFLARE_PAGE = ( + b"\r\n%s\r\n\r\n

%s

\r\n" + b"
cloudflare
\r\n\r\n\r\n" +) +_LINEAR_PREFLIGHT = { + "access-control-allow-origin": "https://example.com", + "cache-control": "s-maxage=43200", + "vary": "Origin, Access-Control-Request-Headers", + "cache-tag": "preflight", + "access-control-allow-methods": "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", + "access-control-expose-headers": "Cache-Control,Content-Language,Content-Length,Content-Type," + "Expires,Last-Modified,Pragma,x-request-id,cf-ray,cf-cache-status,Retry-After", + "access-control-max-age": "86400", +} +_J = (("Content-Type", "application/json"),) +_P = (("apollo-require-preflight", "1"),) +_ORIGIN = (("Origin", "https://example.com"),) +_PREFLIGHT_ASKED = (*_ORIGIN, ("Access-Control-Request-Method", "POST")) +_Q = "?query=%7B%20__typename%20%7D" + + +def _search_refusal(message: bytes) -> bytes: + return ( + b'{"errors":[{"message":"%s","extensions":{"http":{"status":400,"headers":{}},"code":"BAD_REQ' + b'UEST","type":"graphql error","userError":true}}]}' % message + ) + + +def _http_row( + method, target, sent, body, credential, status, media_type, answer, id, answered=None +): + """A row: the request, the credential, and the answer, ``answered`` naming headers on it.""" + return pytest.param( + method, target, sent, body, credential, status, media_type, answer, answered or {}, id=id + ) + + +_LINEAR_HTTP = [ + # Cloudflare, then Linear's load balancer, ahead of everything. + _http_row( + "TRACE", + "", + (), + None, + "key", + 405, + "text/html", + _CLOUDFLARE_PAGE % ((b"405 Not Allowed",) * 2), + id="TRACE", + ), + _http_row( + "CONNECT", + "", + (), + None, + "key", + 400, + "text/plain; charset=UTF-8", + b"error code: 1045\n", + id="CONNECT", + ), + _http_row( + "PURGE", + "", + (), + None, + "key", + 400, + "text/html; charset=UTF-8", + _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), + id="PURGE", + ), + _http_row( + "TRACE", + "", + (), + None, + "twice", + 400, + "text/html", + _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), + id="TRACE, Authorization twice", + ), + *( + _http_row(m, "", (*_J, *_P), None, "key", 501, None, b"", id=m) + for m in ("BIND", "LINK", "M-SEARCH", "MKCALENDAR", "REBIND", "SOURCE", "UNBIND", "UNLINK") + ), + *( + _http_row(m, "", (*_J, *_P), None, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id=m) + for m in ("ACL", "PROPFIND", "NOTIFY", "QUERY", "PATCH", "DELETE") + ), + # `cors`, whatever the credential; Apollo Server's CSRF check and 405 without `Origin`. + *( + _http_row( + "OPTIONS", + "", + _PREFLIGHT_ASKED, + None, + c, + 204, + None, + b"", + id=f"preflight, {c}", + answered=_LINEAR_PREFLIGHT, + ) + for c in ("key", "none", "Bearer key") + ), + _http_row( + "OPTIONS", + "", + _ORIGIN, + None, + "nope", + 204, + None, + b"", + id="Origin alone", + answered=_LINEAR_PREFLIGHT, + ), + _http_row( + "OPTIONS", + "", + (("Origin", "null"), ("Access-Control-Request-Method", "POST")), + None, + "key", + 204, + None, + b"", + id="Origin null", + answered={**_LINEAR_PREFLIGHT, "access-control-allow-origin": "null"}, + ), + _http_row( + "OPTIONS", + "", + (*_ORIGIN, ("Access-Control-Request-Method", "PUT")), + None, + "key", + 204, + None, + b"", + id="asked PUT", + answered=_LINEAR_PREFLIGHT, + ), + *( + _http_row( + "OPTIONS", + "", + (*_PREFLIGHT_ASKED, ("Access-Control-Request-Headers", asked)), + None, + "key", + 204, + None, + b"", + id=f"asked {asked!r}", + answered={**_LINEAR_PREFLIGHT, "access-control-allow-headers": allowed}, + ) + for asked, allowed in ( + ("Content-Type, X-Foo", "Content-Type,X-Foo"), + (" a , b ", "a,b"), + ("a,,b", "a,,b"), + ) + ), + _http_row("OPTIONS", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="OPTIONS"), + _http_row( + "OPTIONS", "", (), None, "Bearer key", 400, _JSON_UTF8, _CSRF, id="OPTIONS, Bearer key" + ), + _http_row( + "OPTIONS", + "", + (("Access-Control-Request-Method", "POST"),), + None, + "key", + 400, + _JSON_UTF8, + _CSRF, + id="OPTIONS, no Origin", + ), + _http_row("OPTIONS", "", _J, None, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id="OPTIONS, JSON"), + # The API key under `Bearer` on any method but OPTIONS, ahead of `body-parser`. + *( + _http_row(m, t, h, b, "Bearer key", 400, _JSON_UTF8, a, id=f"{m}, Bearer key") + for m, t, h, b, a in ( + ("GET", _Q, (), None, _API_KEY_AS_BEARER), + ("PUT", "", _J, b"{", _API_KEY_AS_BEARER), + ("HEAD", _Q, _P, None, b""), + ("PATCH", "", _J, None, _API_KEY_AS_BEARER), + ) + ), + # `body-parser` on any method's JSON body, then Apollo Server's CSRF check, then its 405. + _http_row("PUT", "", (*_J, *_P), b"{", "key", 400, _JSON_UTF8, _EXPECTED_PROPERTY, id="PUT {"), + _http_row( + "OPTIONS", "", (*_J, *_P), b"{", "key", 400, _JSON_UTF8, _EXPECTED_PROPERTY, id="OPTIONS {" + ), + _http_row( + "PUT", "", (*_J, *_P), _TYPENAME, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id="PUT, a query" + ), + _http_row( + "DELETE", "", _J, _TYPENAME, "none", 405, _JSON_UTF8, _ONLY_GET_POST, id="DELETE, a query" + ), + _http_row( + "PUT", + "", + (("Content-Type", "text/plain"),), + _TYPENAME, + "key", + 400, + _JSON_UTF8, + _CSRF, + id="PUT, text/plain", + ), + _http_row("DELETE", "", (), None, "nope", 400, _JSON_UTF8, _CSRF, id="DELETE"), + # A POST's content type: read as JSON, refused as a request a browser sends unpreflighted, or + # passed with nothing read. + *( + _http_row( + "POST", + "", + (("Content-Type", t), *h), + _TYPENAME, + "key", + s, + _JSON_UTF8, + a, + id=f"{t!r}{i}", + ) + for t, h, s, a, i in ( + ("text/plain", (), 400, _CSRF, ""), + ("text/plain", _P, 400, _NO_KEYS, ", preflight"), + ("TEXT/PLAIN;X=Y", (), 400, _CSRF, ""), + ("application/x-www-form-urlencoded", (), 400, _CSRF, ""), + ("multipart/form-data; boundary=x", (), 400, _CSRF, ""), + ("", (), 400, _CSRF, ""), + ("foo", (), 400, _NO_KEYS, ""), + ("text/plain, application/json", (), 400, _NO_KEYS, ""), + ("application/graphql", (), 400, _NO_KEYS, ""), + ("application/json;", (), 400, _NO_KEYS, ""), + ("application/json; x=y, text/plain", (), 400, _NO_KEYS, ""), + ("application/json\t;", (), 400, _NO_KEYS, ""), + ("Application/JSON", (), 200, _TYPENAME_DATA, ""), + ("application/json ;charset=utf-8", (), 200, _TYPENAME_DATA, ""), + ('application/json;x="a;b"', (), 200, _TYPENAME_DATA, ""), + ) + ), + _http_row("POST", "", (), _TYPENAME, "key", 400, _JSON_UTF8, _CSRF, id="no type"), + _http_row("POST", "", _P, _TYPENAME, "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, preflight"), + _http_row("POST", "", _J, b"", "key", 400, _JSON_UTF8, _NO_KEYS, id="JSON, empty"), + # A GET: its query string, Apollo Server's 405 for one that is not a query, ahead of the + # credential. + _http_row("GET", _Q, _P, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET"), + _http_row("GET", _Q, (), None, "key", 400, _JSON_UTF8, _CSRF, id="GET, unpreflighted"), + _http_row("GET", _Q, _J, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET, JSON"), + _http_row( + "GET", + _Q, + (("Content-Type", "text/plain"), *_P), + None, + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="GET, text/plain", + ), + _http_row( + "GET", + _Q, + (("Content-Type", "foo"),), + None, + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="GET, foo", + ), + _http_row("GET", _Q, _P, None, "none", 401, _JSON_UTF8, _UNAUTHENTICATED, id="GET, none"), + _http_row( + "GET", + "?query=%7B+__typename+%7D", + _P, + None, + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="GET, +", + ), + _http_row("GET", "", _P, None, "key", 400, _JSON_UTF8, _NO_QUERY, id="GET, no query"), + _http_row( + "GET", "?QUERY" + _Q[6:], _P, None, "key", 400, _JSON_UTF8, _NO_QUERY, id="GET, QUERY" + ), + *( + _http_row("GET", t, _P, None, "key", 400, _JSON_UTF8, _search_refusal(m), id=i) + for t, m, i in ( + ( + _Q + "&query=", + b"The 'query' search parameter may only be specified once.", + "query twice", + ), + ( + _Q + "&operationName=A&operationName=B", + b"The 'operationName' search parameter may only be specified once.", + "operationName twice", + ), + ( + _Q + "&variables=nope", + b"The variables search parameter contains invalid JSON.", + "variables nope", + ), + ( + _Q + "&variables=", + b"The variables search parameter contains invalid JSON.", + "variables empty", + ), + ( + _Q + "&variables=%5B1%5D", + b"The variables search parameter should contain a JSON-encoded object.", + "variables [1]", + ), + ( + _Q + "&variables=null", + b"The variables search parameter should contain a JSON-encoded object.", + "variables null", + ), + ( + _Q + "&extensions=5", + b"The extensions search parameter should contain a JSON-encoded object.", + "extensions 5", + ), + ( + "?query=%7B&query=%7B&variables=nope", + b"The 'query' search parameter may only be specified once.", + "query twice, variables nope", + ), + ( + "?operationName=A&operationName=B&query=%7B&query=%7B", + b"The 'query' search parameter may only be specified once.", + "operationName twice, query twice", + ), + ( + "?operationName=A&operationName=B&variables=nope", + b"The 'operationName' search parameter may only be specified once.", + "operationName twice, variables nope", + ), + ( + "?variables=nope&extensions=5", + b"The variables search parameter contains invalid JSON.", + "variables nope, extensions 5", + ), + ) + ), + *( + _http_row( + "GET", + "?query=mutation%20%7B%20__typename%20%7D", + _P, + None, + c, + 405, + _JSON_UTF8, + _GET_MUTATION, + id=f"GET mutation, {c}", + answered={"allow": "POST"}, + ) + for c in ("key", "none") + ), + _http_row( + "GET", + "?query=query%20A%20%7B%20__typename%20%7D%20mutation%20B%20%7B%20__typename%20%7D" + "&operationName=A", + _P, + None, + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="GET, a query beside a mutation", + ), + _http_row( + "HEAD", + _Q, + _P, + None, + "key", + 405, + _JSON_UTF8, + b"", + id="HEAD", + answered={"allow": "GET, POST"}, + ), + _http_row( + "HEAD", + _Q, + (("Content-Type", "text/plain"),), + None, + "key", + 400, + _JSON_UTF8, + b"", + id="HEAD, text/plain", + ), + # Accept: the media type of an answer, Apollo Server's 406 once its request pipeline has + # answered, and none for a refusal it throws before that. + *( + _http_row("POST", "", (*_J, ("Accept", a)), _TYPENAME, "key", s, t, b, id=f"Accept {a!r}") + for a, s, t, b in ( + ("text/html", 406, _JSON_UTF8, _NOT_ACCEPTABLE), + ("application/json;q=0", 406, _JSON_UTF8, _NOT_ACCEPTABLE), + ("application/json;q", 406, _JSON_UTF8, _NOT_ACCEPTABLE), + ("application/json; charset=latin1", 406, _JSON_UTF8, _NOT_ACCEPTABLE), + ("", 200, _JSON_UTF8, _TYPENAME_DATA), + ("*/*", 200, _JSON_UTF8, _TYPENAME_DATA), + ("application/graphql-response+json", 200, _GRAPHQL_RESPONSE, _TYPENAME_DATA), + ("application/json;q=0, */*", 200, _GRAPHQL_RESPONSE, _TYPENAME_DATA), + ("text/html;q=0.9, */*;q=0.1", 200, _JSON_UTF8, _TYPENAME_DATA), + ( + "application/json; callbackSpec=1.0", + 200, + "application/json; callbackspec=1.0; charset=utf-8", + _TYPENAME_DATA, + ), + ) + ), + *( + _http_row("POST", "", (*_J, ("Accept", a)), b, c, s, t, e, id=f"Accept {a!r}, {i}") + for a, b, c, s, t, e, i in ( + ("text/html", b"{}", "key", 400, _JSON_UTF8, _NO_KEYS, "{}"), + ( + "application/graphql-response+json", + b"{}", + "key", + 400, + _GRAPHQL_RESPONSE, + _NO_KEYS, + "{}", + ), + ("text/html", b"[]", "key", 400, _JSON_UTF8, _BATCHING, "[]"), + ( + "text/html", + b'{"query":"{"}', + "key", + 406, + _JSON_UTF8, + _NOT_ACCEPTABLE, + "a parse error", + ), + ("text/html", _TYPENAME, "none", 406, _JSON_UTF8, _NOT_ACCEPTABLE, "none"), + ( + "application/graphql-response+json", + _TYPENAME, + "none", + 401, + _GRAPHQL_RESPONSE, + _UNAUTHENTICATED, + "none", + ), + ("text/html", b"{", "key", 400, _JSON_UTF8, _EXPECTED_PROPERTY, "{"), + ) + ), + _http_row( + "GET", + _Q, + (*_P, ("Accept", "text/html")), + None, + "key", + 406, + _JSON_UTF8, + _NOT_ACCEPTABLE, + id="GET, Accept text/html", + ), + _http_row( + "GET", + "?query=mutation%20%7B%20__typename%20%7D", + (*_P, ("Accept", "text/html;level=1")), + None, + "key", + 406, + _JSON_UTF8, + _NOT_ACCEPTABLE, + id="GET mutation, Accept text/html;level=1", + ), + _http_row( + "POST", + "", + (("Content-Type", "text/plain"), ("Accept", "application/graphql-response+json")), + _TYPENAME, + "key", + 400, + _GRAPHQL_RESPONSE, + _CSRF, + id="text/plain, Accept graphql-response", + ), + _http_row( + "PUT", + "", + (*_J, ("Accept", "application/graphql-response+json")), + _TYPENAME, + "key", + 405, + _GRAPHQL_RESPONSE, + _ONLY_GET_POST, + id="PUT, Accept graphql-response", + ), + _http_row( + "GET", + _Q, + (*_P, ("Accept", "text/html, application/graphql-response+json")), + None, + "key", + 200, + _GRAPHQL_RESPONSE, + _TYPENAME_DATA, + id="GET, Accept text/html then graphql-response", + ), +] +_LINEAR_CREDENTIALS = { + "none": None, + "nope": "nope", + "Bearer key": "Bearer {t}", + "key": "{t}", + "twice": "{t}", +} + + +@pytest.mark.parametrize( + "method,target,headers,body,credential,status,media_type,answer,expected_headers", _LINEAR_HTTP +) +def test_linear_answers_each_method_type_and_accept_as_linear_does( + client, + tokens_yaml, + method, + target, + headers, + body, + credential, + status, + media_type, + answer, + expected_headers, +): + """Each row of `_LINEAR_HTTP` is answered as api.linear.app answered it.""" + sent = list(headers) + if _LINEAR_CREDENTIALS[credential] is not None: + value = _LINEAR_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) + sent += [("Authorization", value)] * (2 if credential == "twice" else 1) + r = sent_as(client, method, f"/linear/graphql{target}", sent, content=body) + assert r.status_code == status + assert r.headers.get("content-type") == media_type + assert r.content == answer + assert {name: r.headers.get(name) for name in expected_headers} == expected_headers diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 2d9dc0bf2..daacea464 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -29,6 +29,8 @@ ADMIN = {"Authorization": "Bearer admin-service-token"} LINEAR = {"Authorization": "admin-service-token"} JSON = {"Content-Type": "application/json"} +_PREFLIGHT = {"apollo-require-preflight": "1"} +_TYPENAME_SEARCH = "?query=%7B%20__typename%20%7D" UNREACHABLE = "http://127.0.0.1:9/v1/metrics" # The values an export may carry. The collector replaces a value outside its own shape checks with # `invalid`, and every pattern here is narrower than those. @@ -238,6 +240,8 @@ def test_each_client_rule_names_the_headers_it_was_measured_from(): ("fireflies", "unknown", 500, "INTERNAL_SERVER_ERROR", False, "client"), ("linear", "unknown", 500, "INTERNAL_SERVER_ERROR", False, "internal"), ("fireflies", "unknown", 500, "", False, "internal"), + ("fireflies", "unknown", 500, "auth_failed", False, "auth"), + ("linear", "unknown", 500, "auth_failed", False, "internal"), ], ) def test_a_failure_is_classed_by_its_answer(source, support, status, code, spent, expected): @@ -572,6 +576,65 @@ def test_an_error_code_is_the_envelopes_own_identifier(source, status, body, exp {"headers": LINEAR | JSON, "content": b"{"}, ("linear", "unparsed", "unknown", 400, None, "client"), ), + # The HTTP layer in front of each GraphQL server answers every method, a GET's query among + # them, and Fireflies' credential's 500 is a refusal of the credential. + ( + "GET", + "/fireflies/graphql" + _TYPENAME_SEARCH, + {"headers": _PREFLIGHT}, + ("fireflies", "query.__typename", "implemented", 200, None, None), + ), + ( + "GET", + "/fireflies/graphql" + _TYPENAME_SEARCH, + {"headers": _PREFLIGHT | {"Authorization": "Bearer nope"}}, + ("fireflies", "unparsed", "unknown", 500, "auth_failed", "auth"), + ), + ( + "PROPFIND", + "/fireflies/graphql", + {"headers": JSON}, + ("fireflies", "unparsed", "unknown", 404, "not_found", "client"), + ), + ( + "OPTIONS", + "/fireflies/graphql", + {}, + ("fireflies", "unparsed", "unknown", 400, None, "client"), + ), + ( + "GET", + "/linear/graphql" + _TYPENAME_SEARCH, + {"headers": LINEAR | _PREFLIGHT}, + ("linear", "query.__typename", "implemented", 200, None, None), + ), + ( + "PUT", + "/linear/graphql", + {"headers": LINEAR | JSON, "json": {"query": "{ __typename }"}}, + ("linear", "unparsed", "unknown", 405, "BAD_REQUEST", "client"), + ), + ( + "POST", + "/linear/graphql", + { + "headers": LINEAR | JSON | {"Accept": "text/html"}, + "json": {"query": "{ __typename }"}, + }, + ("linear", "query.__typename", "implemented", 406, "BAD_REQUEST", "client"), + ), + ( + "BIND", + "/linear/graphql", + {"headers": LINEAR}, + ("linear", "unparsed", "unknown", 501, None, "client"), + ), + ( + "TRACE", + "/linear/graphql", + {"headers": LINEAR}, + ("linear", "unparsed", "unknown", 405, None, "client"), + ), ( "GET", "/calendar/v3/calendars/primary/events", From 094059430ec9eb19e8db813d7c7eaa771ed4f48d Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:29:43 +0900 Subject: [PATCH 10/52] graphql: a persisted query's docstring holds what each vendor answered a hash alone with once its query was served --- backlot/graphql/engine.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index 8d8ea02ed..b628df078 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -257,11 +257,12 @@ def body_refusal(payload: Any) -> Result | None: def persisted_query_refusal(payload: dict) -> Result | None: """Apollo Server's answer to the ``persistedQuery`` extension of ``payload``, or ``None`` for - a request it runs. Both vendors answered on 2026-10-25 (UTC) as a server that keeps no query: - a hash sent with no ``query``, or with ``null``, ``false`` or ``0``, was - ``PersistedQueryNotFound`` at 200, even after the query that hashes to it had been sent with it - and served. A version other than the number 1 is refused, then a hash that is not the SHA-256 - of the query sent, in lower-case hex, before the query is parsed.""" + a request it runs, as a server that keeps no query answers: a hash sent with no ``query``, or + with ``null``, ``false`` or ``0``, is ``PersistedQueryNotFound`` at 200. Both vendors answered + so on 2026-10-25 (UTC). Once a query had been sent with its hash and served, Linear answered + its hash alone so to each of 16 requests, and Fireflies to 15 of 16, serving the query to the + other. A version other than the number 1 is refused, then a hash that is not the SHA-256 of the + query sent, in lower-case hex, before the query is parsed.""" extensions = payload.get("extensions") persisted = extensions.get("persistedQuery") if isinstance(extensions, dict) else None if not _js_truthy(persisted): From 85de3d73a9b4bbfdd0a980996a696b9635603b36 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:13:11 +0900 Subject: [PATCH 11/52] graphql: a header sent twice, a body of no length and a preflight's header list are read as each vendor reads them Linear reads Content-Type sent twice joined and Fireflies the first; Linear's front gives a body to any method but GET and HEAD; each preflight echoes the copies of Access-Control-Request-Headers as its CORS middleware does. The negotiator port loses branches that cannot change an answer, and the tables gain the rows that tell each decision apart: every method the vendors answered, the ACL through a GET and a form, Cloudflare's methods on other paths, and each library's own answers. --- backlot/graphql/apollo_http.py | 45 ++++--- backlot/graphql/body.py | 20 +-- backlot/routers/fireflies.py | 27 ++-- backlot/routers/linear.py | 46 ++++--- tests/_helpers.py | 7 +- tests/test_acl.py | 52 ++++++-- tests/test_cross_vendor.py | 40 ++++++ tests/test_fireflies.py | 235 ++++++++++++++++++++++++++++++++- tests/test_graphql.py | 16 +++ tests/test_linear.py | 216 +++++++++++++++++++++++++++++- 10 files changed, 618 insertions(+), 86 deletions(-) diff --git a/backlot/graphql/apollo_http.py b/backlot/graphql/apollo_http.py index 6568d05da..324216c04 100644 --- a/backlot/graphql/apollo_http.py +++ b/backlot/graphql/apollo_http.py @@ -60,8 +60,9 @@ # negotiator's `simpleMediaTypeRegExp`. _S = re.escape(_JS_SPACE) _ACCEPT_ENTRY = re.compile(rf"[{_S}]*([^{_S}/;]+)/([^;{_S}]+)[{_S}]*(?:;({_JS_ANY}*))?\Z") -# The literal JavaScript's `parseFloat` reads, past the white space it skips. -_FLOAT = re.compile(r"[+-]?(?:Infinity|(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)") +# The literal JavaScript's `parseFloat` reads, past the white space it skips, each of which Python's +# `float` reads alike. +_FLOAT = re.compile(r"[+-]?(?:Infinity|(?:[0-9]+[.]?[0-9]*|[.][0-9]+)(?:[eE][+-]?[0-9]+)?)") def _js_or(*values: float) -> float: @@ -73,16 +74,11 @@ def _js_or(*values: float) -> float: return values[-1] -def _parse_float(text: str | None) -> float: +def _parse_float(text: str) -> float: """JavaScript's ``parseFloat``: the longest decimal literal at the start of ``text`` past its white space, or ``NaN``.""" - match = _FLOAT.match((text or "").lstrip(_JS_SPACE)) - if text is None or match is None: - return math.nan - literal = match.group() - if literal.lstrip("+-") == "Infinity": - return -math.inf if literal.startswith("-") else math.inf - return float(literal) + match = _FLOAT.match(text.lstrip(_JS_SPACE)) + return math.nan if match is None else float(match.group()) def _split_quoted(text: str, separator: str) -> list[str]: @@ -104,12 +100,12 @@ def _media_range(text: str, index: int | None) -> dict | None: match = _ACCEPT_ENTRY.match(text) if match is None: return None - params: dict[str, str | None] = {} + params: dict[str, str] = {} q = 1.0 if match.group(3): for parameter in _split_quoted(match.group(3), ";"): - key, equals, value = parameter.strip(_JS_SPACE).partition("=") - value = value if equals else None + # A parameter with no `=` has an undefined value there, which reads as "" does. + key, _, value = parameter.strip(_JS_SPACE).partition("=") if value and value[0] == '"' and value[-1] == '"': value = value[1:-1] if key.lower() == "q": @@ -133,7 +129,7 @@ def _specificity(provided: dict, spec: dict, index: int) -> dict | None: return None if spec["params"]: if all( - value == "*" or (value or "").lower() == (provided["params"].get(key) or "").lower() + value == "*" or value.lower() == provided["params"].get(key, "").lower() for key, value in spec["params"].items() ): s |= 1 @@ -220,8 +216,8 @@ def mime_essence(value: str) -> str | None: """``whatwg-mimetype``'s ``MIMEType.parse(value).essence``: the type and subtype in lower case, or ``None`` where it parses none. The parameters cannot make it ``None``.""" value = value.strip(_HTTP_SPACE) - type_, slash, rest = value.partition("/") - if not slash or not _TOKEN.fullmatch(type_): + type_, _, rest = value.partition("/") + if not _TOKEN.fullmatch(type_): return None subtype = rest.partition(";")[0].rstrip(_HTTP_SPACE) if not _TOKEN.fullmatch(subtype): @@ -243,14 +239,19 @@ def mime_essence(value: str) -> str | None: def headers_seen(raw: list[tuple[bytes, bytes]]) -> dict[str, str]: """The request headers as Apollo Server reads them behind Node, from the ASGI ``raw`` list: - each name in lower case, the values of a name sent more than once joined with ``", "`` but - ``content-type``'s, of which Node keeps the first, and a header with no value left out. Both - vendors read an empty ``Content-Type`` as none on 2026-10-25 (UTC): Linear refused a POST - carrying one as a POST with none, and Fireflies served a GET carrying one with the preflight - header, where it refuses a GET carrying a content type that is not JSON.""" + each name in lower case and each value without the spaces and tabs at its ends, the values of + a name sent more than once joined with ``", "`` but ``content-type``'s, of which Node keeps the + first, and a header with no value left out. Both vendors answered so on 2026-10-25 (UTC): + `Accept` sent as `application/json` and as `text/html`, in either order, was served; + `apollo-require-preflight` sent empty and as `1`, in either order, let a GET through, and an + `x-apollo-operation-name` of a space did not; a `Content-Type` sent twice was the first to + Fireflies; an empty `Content-Type` was none to Linear, which refused a POST carrying one as a + POST with none, and to Fireflies, which served a GET carrying one with the preflight header + where it refuses a GET carrying one that is not JSON. Linear reads `Content-Type` on its own + (``backlot.routers.linear``).""" seen: dict[str, str] = {} for name, value in raw: - key, text = name.decode("latin-1").lower(), value.decode("latin-1") + key, text = name.decode("latin-1").lower(), value.decode("latin-1").strip(" \t") if key in seen: if key != "content-type": seen[key] = f"{seen[key]}, {text}" diff --git a/backlot/graphql/body.py b/backlot/graphql/body.py index fe4af8918..f73aab661 100644 --- a/backlot/graphql/body.py +++ b/backlot/graphql/body.py @@ -167,34 +167,24 @@ def _charset(content_type: str | None) -> str: return charset.lower() or "utf-8" -# content-type 1.0.5's `TYPE_REGEXP`, and media-typer 1.1.0's, which type-is 2.0.1 also asks. -_CONTENT_TYPE = re.compile(rf"{_TOKEN}/{_TOKEN}") -_MEDIA_TYPER = re.compile( - r" *[A-Za-z0-9][A-Za-z0-9!#$&^_-]{0,126}/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126} *" -) - - def express_reads_json(content_type: str | None) -> bool: """Whether Linear's ``body-parser`` reads a body under ``content_type`` as JSON, which ``type-is`` 2.0.1 decides as ``content-type`` 1.0.5 parses the header: the type before the first ``;``, trimmed, is ``application/json`` in any case, and each parameter after it is a token, ``=`` and a token or a quoted string, to the end. A body under any other is not read, - and reaches Apollo Server as none. api.linear.app answered as these two packages do 71 content + and reaches Apollo Server as none. api.linear.app answered as these two packages do 66 content types on 2026-10-25 (UTC), where ``content-type`` 2.1.0, which ``type-is`` 2.1.0 asks, reads - ``application/json;``, ``application/json; foo`` and five more as JSON that Linear did not.""" + ``application/json;``, ``application/json; foo`` and six more as JSON that Linear did not.""" if not content_type: return False index = content_type.find(";") - type_ = (content_type if index == -1 else content_type[:index]).strip(JS_SPACE) - if not _CONTENT_TYPE.fullmatch(type_): + type_ = content_type if index == -1 else content_type[:index] + if type_.strip(JS_SPACE).lower() != "application/json": return False if index != -1: while (parameter := PARAMETER.match(content_type, index)) is not None: index = parameter.end() - if index != len(content_type): - return False - type_ = type_.lower() - return _MEDIA_TYPER.fullmatch(type_) is not None and type_ == "application/json" + return index in (-1, len(content_type)) # Fastify's `typeNameReg` and `subtypeNameReg`, the latter with no white space to end it once diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index 7c838ca02..47b8ab726 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -156,7 +156,7 @@ async def graphql(request: Request): if refusal is not None: return _answer(seen, engine.refused(refusal)) token = auth.fireflies_bearer_token(request) - caller = None if token is None else auth.acl(request).resolve(token) + caller = auth.acl(request).resolve(token) if caller is None: return _auth_failed(seen, _NO_PREFIX if token is None else _BAD_KEY) if method == "HEAD": @@ -182,8 +182,7 @@ async def graphql(request: Request): result = ENGINE.execute_payload( payload, context=context, on_document=_fragments_unreached, get=method == "GET" ) - if not result.refusals: - request.state.graphql_raised = result.raised + request.state.graphql_raised = result.raised return _answer(seen, result) @@ -215,20 +214,24 @@ def _answer(seen: dict[str, str], result: engine.Result) -> Response: def _preflight(request: Request) -> Response: """``@fastify/cors``'s answer to an `OPTIONS`, ahead of the body and the credential: its 204, - with :data:`_PREFLIGHT` and `Access-Control-Request-Headers` echoed back as sent, to one - carrying both `Origin` and `Access-Control-Request-Method`, and its 400 to any other. + with :data:`_PREFLIGHT` and `Access-Control-Request-Headers` echoed back as Node reads it, to + one carrying both `Origin` and `Access-Control-Request-Method`, and its 400 to any other. api.fireflies.ai answered so with no credential and `Bearer nope`, with `Access-Control-Request-Method` naming `POST` or `PUT`, with `Origin: null`, and with a JSON - body or `{`.""" + body or `{`; it echoed ` a , b ` as `a , b`, `a` and `b` in two copies as `a, b`, and a value + with a space, a tab, U+0085 or U+00A0 inside it, U+00A0 at either end or U+0085 after it, as + sent.""" if "origin" not in request.headers or "access-control-request-method" not in request.headers: return Response( b"Invalid Preflight Request", status_code=400, headers={"content-type": "text/plain"} ) response = Response(status_code=204) - asked = request.headers.get("access-control-request-headers") - # Node's parser drops the spaces and tabs around a header's value, where uvicorn keeps those - # after it. - echoed = () if asked is None else (("access-control-allow-headers", asked.strip(" \t")),) + # Node joins the copies of a header with ", ", each without the spaces and tabs at its ends, + # which uvicorn keeps after a value. + asked = ", ".join( + v.strip(" \t") for v in request.headers.getlist("access-control-request-headers") + ) + echoed = (("access-control-allow-headers", asked),) if asked else () response.raw_headers += [(k.encode(), v.encode("latin-1")) for k, v in (*_PREFLIGHT, *echoed)] return response @@ -239,8 +242,8 @@ def _not_found(request: Request) -> Response: answered so each of the 28 methods of :data:`apollo_http.METHODS` but GET, HEAD, POST, OPTIONS and Cloudflare's three on 2026-10-25 (UTC), `PUT`, `DELETE` and `PATCH` once Fastify had read their body (:func:`_fastify_read`), and quoted `/graphql?x=%7B%20y&z&a=b+c` as sent.""" - raw = request.scope.get("raw_path", request.url.path.encode()).decode("latin-1") - path = raw.removeprefix(router.prefix) if raw.startswith(f"{router.prefix}/") else "/graphql" + # The path as sent, but for the segment that names Fireflies here. + path = "/" + request.scope["raw_path"].decode("latin-1").split("/", 2)[2] query = request.scope["query_string"].decode("latin-1") message = f"Cannot {request.method} {path}{'?' if query else ''}{query}" return _body_refusal(message, 404, "not_found") diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index e65dabce7..4cc1f5e18 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -134,9 +134,8 @@ async def graphql(request: Request): if method != "OPTIONS" and auth.linear_api_key_under_bearer(request): return _api_key_as_bearer() payload: Any = None - content_type = request.headers.get("content-type") - has_body = "transfer-encoding" in request.headers or "content-length" in request.headers - if has_body and request_body.express_reads_json(content_type): + content_type = request_body.joined_header(request.headers.getlist("content-type")) + if _has_body(request) and request_body.express_reads_json(content_type): # In a worker thread: `_read` can take seconds over a body near the 10 MiB limit, which # would hold up every other request on the event loop. payload = await run_in_threadpool( @@ -151,6 +150,9 @@ async def graphql(request: Request): # The telemetry middleware names the call by this document (backlot.telemetry.naming). request.state.graphql_payload = payload seen = apollo_http.headers_seen(request.headers.raw) + seen.pop("content-type", None) + if content_type: + seen["content-type"] = content_type refusal = apollo_http.csrf_refusal(seen) if refusal is None and method not in ("GET", "POST"): refusal = apollo_http.method_refusal() @@ -190,6 +192,15 @@ async def graphql(request: Request): return _answer(seen, ENGINE.run(resolved, context=context), payload, request) +def _has_body(request: Request) -> bool: + """Whether ``body-parser`` finds a body to read, as ``type-is``'s ``hasBody`` decides behind + Linear's front: under any method but `GET` and `HEAD`, which that front gives a length of 0 + where it has none. api.linear.app refused a `Content-Type` naming `charset=latin1` with no body + and no length with its 415 under `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` and `PROPFIND` on + 2026-10-25 (UTC), and read no body under `GET` and `HEAD`, a length of 0 among them.""" + return request.method not in ("GET", "HEAD") + + #: The headers ``cors`` writes on each preflight, as api.linear.app answered every one sent with #: `Origin` on 2026-10-25 (UTC), and the edge's ``Cache-Control`` and ``Cache-Tag`` beside them. _PREFLIGHT = ( @@ -208,17 +219,18 @@ async def graphql(request: Request): def _preflight(request: Request) -> Response: """Linear's 204 to an `OPTIONS` carrying `Origin`, ahead of the credential and the body: - `Origin` echoed back, :data:`_PREFLIGHT`, and each name `Access-Control-Request-Headers` lists - without the spaces around it. api.linear.app answered so with no credential, `nope` and an API - key under `Bearer`, with `Access-Control-Request-Method` naming `POST`, `PUT` or none, with - `Origin: null`, and with `Content-Type, X-Foo`, ` a , b ` and `a,,b` asked for, which came back - `Content-Type,X-Foo`, `a,b` and `a,,b`. An `OPTIONS` without `Origin` is Apollo Server's.""" + `Origin` echoed back, :data:`_PREFLIGHT`, and each name `Access-Control-Request-Headers` lists, + in each copy of it, without the spaces and tabs around it. api.linear.app answered so with no + credential, `nope` and an API key under `Bearer`, with `Access-Control-Request-Method` naming + `POST`, `PUT` or none, with `Origin: null`, and with `Content-Type, X-Foo`, ` a , b `, `a,,b`, + `a, ,b`, a tab after `a` and `a` and `b` in two copies asked for, which came back + `Content-Type,X-Foo`, `a,b`, `a,,b`, `a,,b`, `a` and `a,b`. An `OPTIONS` without `Origin` is + Apollo Server's.""" headers = [("access-control-allow-origin", request.headers["origin"].strip(" \t"))] - asked = request.headers.get("access-control-request-headers") - if asked is not None: - headers.append( - ("access-control-allow-headers", ",".join(name.strip(" ") for name in asked.split(","))) - ) + asked = request.headers.getlist("access-control-request-headers") + if asked: + names = ", ".join(asked).split(",") + headers.append(("access-control-allow-headers", ",".join(n.strip(" \t") for n in names))) response = Response(status_code=204) response.raw_headers += [(k.encode(), v.encode("latin-1")) for k, v in (*headers, *_PREFLIGHT)] return response @@ -291,17 +303,17 @@ def _answer( request: Request | None = None, ) -> Response: """Apollo Server's answer to ``result`` in Linear's envelope (:func:`_envelope`), or to Linear's - 401 where ``result`` is ``None``, which ``request``, where an operation ran, is told of - (``graphql_raised``). A refusal thrown before the request pipeline is in the media type + 401 where ``result`` is ``None``. ``request`` is passed where ``result`` is a run, and is told + whether it raised (``graphql_raised``). A refusal thrown before the request pipeline is in the media type ``Accept`` takes or ``application/json``; any other answer is Apollo Server's 406 where it takes none (:data:`~backlot.graphql.apollo_http.NOT_ACCEPTABLE`). api.linear.app answered each of the two kinds so on 2026-10-25 (UTC), the 401 among the second.""" + if request is not None: + request.state.graphql_raised = result.raised media_type = apollo_http.response_type(seen.get("accept")) thrown = result is not None and bool(result.refusals) and result.refusals[0].thrown if media_type is None and not thrown: result = engine.refused(apollo_http.NOT_ACCEPTABLE) - if request is not None and result is not None and not result.refusals: - request.state.graphql_raised = result.raised if result is None: body, status, headers = _UNAUTHENTICATED, 401, () else: diff --git a/tests/_helpers.py b/tests/_helpers.py index b12a0b374..04cf17ccc 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -298,11 +298,12 @@ def __new__(cls, answer: bytes, status: int = 400): return self -def sent_as(client, method: str, path: str, headers, content=None): +def sent_as(client, method: str, path: str, headers, content=None, bare: bool = False): """``method`` on ``path`` with each header's latin-1 bytes as sent, ``headers`` a mapping or a sequence of pairs, which can name a header twice. The TestClient re-encodes a header's bytes above 0x7F as UTF-8 (`\\x85` reaches the app as `\\xc2\\x85`), where httpx's own - `ASGITransport` hands the app what it was given, over the app the fixture started.""" + `ASGITransport` hands the app what it was given, over the app the fixture started. ``bare`` + leaves out the headers httpx sends unasked, `Accept: */*` among them.""" import asyncio import httpx @@ -313,6 +314,8 @@ def sent_as(client, method: str, path: str, headers, content=None): async def send(): transport = httpx.ASGITransport(app=client.app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + if bare: + c.headers.clear() return await c.request(method, path, headers=raw, content=content) return asyncio.run(send()) diff --git a/tests/test_acl.py b/tests/test_acl.py index 13d809e8c..2f52d8446 100644 --- a/tests/test_acl.py +++ b/tests/test_acl.py @@ -1,10 +1,30 @@ """ACL resolution + visibility, asserted against the SAMPLE corpus's generated ACL.""" +import json + +import pytest import yaml from backlot import store from tests._helpers import client_for, gql +# The two ways but a POST of JSON a document reaches each GraphQL server's resolvers: a GET with it +# in the query string, and on Fireflies a POST of a form. Apollo Server serves both only with its +# preflight header. +_ADMIN = "admin-service-token" + + +def _sent(client, path, query, authorization, via, **variables): + """``query`` sent to ``path`` as ``via`` names: ``POST`` as JSON (:func:`gql`), ``GET`` in the + query string and ``form`` as a form, the last two beside the preflight header.""" + if via == "POST": + return gql(client, path, query, authorization, **variables) + fields = {"query": query, **({"variables": json.dumps(variables)} if variables else {})} + headers = {"Authorization": authorization, "apollo-require-preflight": "1"} + if via == "GET": + return client.get(path, params=fields, headers=headers) + return client.post(path, data=fields, headers=headers) + def _visible(db, acl, token, source): """The SERVED keys this token can see in one source — the same tuples `keys` hands back, so an @@ -165,24 +185,25 @@ def test_linear_team_counts_are_acl_scoped(db, acl, tokens): # offline: an enumerable oracle, not merely a confirmable one. -def _gql(client, query, token): - return gql(client, "/linear/graphql", query, token).json() +def _gql(client, query, token, via="POST"): + return _sent(client, "/linear/graphql", query, token, via).json() -def test_linear_by_id_roots_do_not_leak_entities_off_hidden_issues(sample_settings, tokens): +@pytest.mark.parametrize("via", ["POST", "GET"]) +def test_linear_by_id_roots_do_not_leak_entities_off_hidden_issues(sample_settings, tokens, via): """`blackops` is granted to hana only, so its "Triage" state, in no other team, must fail for - ava exactly as an absent id would — and must still work for hana, proving the id is real and - the difference is the ACL.""" + ava exactly as an absent id would — and must still work for hana and the admin, proving the id + is real and the difference is the ACL, whichever way the document is sent.""" from backlot import synth with client_for(sample_settings) as client: state_id = synth.linear_state_id("Triage", "blackops") q = '{ workflowState(id: "%s") { name } }' % state_id - hidden = _gql(client, q, tokens["ava@acme.com"]) - granted = _gql(client, q, tokens["hana@acme.com"]) + hidden = _gql(client, q, tokens["ava@acme.com"], via) assert "data" not in hidden or hidden["data"] is None assert "Entity not found" in hidden["errors"][0]["message"] - assert granted["data"]["workflowState"]["name"] == "Triage" + for granted in (tokens["hana@acme.com"], _ADMIN): + assert _gql(client, q, granted, via)["data"]["workflowState"]["name"] == "Triage" # ...and indistinguishable from an id that genuinely does not exist. absent = _gql( @@ -190,6 +211,7 @@ def test_linear_by_id_roots_do_not_leak_entities_off_hidden_issues(sample_settin '{ workflowState(id: "%s") { name } }' % synth.linear_state_id("No Such State", "engineering"), tokens["ava@acme.com"], + via, ) assert ( absent["errors"][0]["message"].split("id=")[0] @@ -361,8 +383,8 @@ def test_linear_hidden_assignee_is_not_nameable_by_id(sample_settings, tokens): # both the unfiltered list and every filter have to agree about hiding it. -def _ff_gql(client, query, token, **variables): - return gql(client, "/fireflies/graphql", query, f"Bearer {token}", **variables).json() +def _ff_gql(client, query, token, via="POST", **variables): + return _sent(client, "/fireflies/graphql", query, f"Bearer {token}", via, **variables).json() def test_fireflies_store_reads_are_acl_scoped(db, acl, tokens, keys): @@ -377,13 +399,15 @@ def test_fireflies_store_reads_are_acl_scoped(db, acl, tokens, keys): ) -def test_fireflies_transcripts_list_hides_denied_meetings(sample_settings, tokens): +@pytest.mark.parametrize("via", ["POST", "GET", "form"]) +def test_fireflies_transcripts_list_hides_denied_meetings(sample_settings, tokens, via): with client_for(sample_settings) as client: q = "{ transcripts(limit: 50) { title } }" - ava = _ff_gql(client, q, tokens["ava@acme.com"])["data"]["transcripts"] - hana = _ff_gql(client, q, tokens["hana@acme.com"])["data"]["transcripts"] + ava = _ff_gql(client, q, tokens["ava@acme.com"], via)["data"]["transcripts"] assert "Board pre-read walkthrough" not in [t["title"] for t in ava] - assert "Board pre-read walkthrough" in [t["title"] for t in hana] + for granted in (tokens["hana@acme.com"], _ADMIN): + seen = _ff_gql(client, q, granted, via)["data"]["transcripts"] + assert "Board pre-read walkthrough" in [t["title"] for t in seen] def test_fireflies_transcript_by_id_denies_rather_than_reveals(sample_settings, tokens): diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index cac9de33f..970553862 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -392,6 +392,46 @@ def test_cloudflare_refuses_a_repeated_authorization( assert r.content == page +# Cloudflare's answer to each method it refuses in front of Linear and Fireflies, on each path +# api.linear.app and api.fireflies.ai answered it on, and behind its 400 for `Authorization` sent +# twice; `backlot.main.refuse_cloudflares_methods_on_linear_and_fireflies` records them. The last +# row is the control: a path that only starts with the vendor's name is not the vendor's. +_PAGE = ( + b"\r\n%s\r\n\r\n

%s

\r\n" + b"
cloudflare
\r\n\r\n\r\n" +) +_NOT_ALLOWED = (405, "text/html", _PAGE % ((b"405 Not Allowed",) * 2)) +_ERROR_1045 = (400, "text/plain; charset=UTF-8", b"error code: 1045\n") +_PURGED = (400, "text/html; charset=UTF-8", _PAGE % ((b"400 Bad Request",) * 2)) +_TWICE = (400, "text/html", _PAGE % ((b"400 Bad Request",) * 2)) +_CLOUDFLARE_METHODS = [ + *( + pytest.param(m, path, 1, answer, id=f"{m} {path or '/'}") + for m, answer in (("TRACE", _NOT_ALLOWED), ("CONNECT", _ERROR_1045), ("PURGE", _PURGED)) + for path in ("/graphql/", "/nosuch", "") + ), + *( + pytest.param(m, "/graphql", 2, _TWICE, id=f"{m}, twice") + for m in ("TRACE", "CONNECT", "PURGE") + ), + pytest.param("TRACE", "x/graphql", 1, None, id="a longer name"), +] + + +@pytest.mark.parametrize("vendor", ["linear", "fireflies"]) +@pytest.mark.parametrize("method, path, copies, answer", _CLOUDFLARE_METHODS) +def test_cloudflare_refuses_trace_connect_and_purge( + client, tokens_yaml, vendor, method, path, copies, answer +): + admin = tokens_yaml["admin_token"] + key = admin if vendor == "linear" else f"Bearer {admin}" + r = client.request(method, f"/{vendor}{path}", headers=[("Authorization", key)] * copies) + if answer is None: + assert b"cloudflare" not in r.content and r.status_code == 404 + return + assert (r.status_code, r.headers["content-type"], r.content) == answer + + @pytest.mark.parametrize( "vendor, reader, body", [ diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index cc25f0179..ef6e6d323 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -1363,6 +1363,15 @@ def _roster_emails(settings): _P = (("apollo-require-preflight", "1"),) _PREFLIGHT_ASKED = (("Origin", "https://example.com"), ("Access-Control-Request-Method", "POST")) _Q = "?query=%7B%20__typename%20%7D" + + +class _Chunked(bytes): + """A body sent in chunks, with no `Content-Length`.""" + + async def chunks(self): + yield bytes(self) + + _PAST_THE_LIMIT = b"x" * (2**20 + 10) _LANDING_PAGE = object() # Apollo Server's page for a browser, `landing_page`'s @@ -1525,7 +1534,32 @@ def _http_row( ), *( _http_row(m, "?_=1", (*_J, *_P), None, "key", 404, _JSON_UTF8, _not_found(m, "?_=1"), id=m) - for m in ("PROPFIND", "QUERY", "NOTIFY", "BIND", "M-SEARCH", "SOURCE") + for m in ( + "BIND", + "CHECKOUT", + "COPY", + "LINK", + "LOCK", + "M-SEARCH", + "MERGE", + "MKACTIVITY", + "MKCALENDAR", + "MKCOL", + "MOVE", + "NOTIFY", + "PROPFIND", + "PROPPATCH", + "QUERY", + "REBIND", + "REPORT", + "SEARCH", + "SOURCE", + "SUBSCRIBE", + "UNBIND", + "UNLINK", + "UNLOCK", + "UNSUBSCRIBE", + ) # fmt: skip ), _http_row( "PROPFIND", @@ -1904,6 +1938,202 @@ def _http_row( _not_found("PUT", ""), id="PUT, Accept graphql-response", ), + # Fastify reads the body of a DELETE before its 404, and leaves a type it has no parser for + # unread there. + _http_row("DELETE", "", (*_J, *_P), b"{", "key", 400, _JSON_UTF8, _NOT_JSON, id="DELETE {"), + _http_row( + "PUT", + "", + (("Content-Type", "application/graphql"), *_P), + _TYPENAME, + "key", + 404, + _JSON_UTF8, + _not_found("PUT", ""), + id="PUT application/graphql", + ), + _http_row( + "OPTIONS", + "", + ( + *_PREFLIGHT_ASKED, + ("Access-Control-Request-Headers", "a"), + ("Access-Control-Request-Headers", "b"), + ), + None, + "key", + 204, + None, + b"", + id="asked twice", + answered={**_FIREFLIES_PREFLIGHT, "access-control-allow-headers": "a, b"}, + ), + *( + _http_row( + "OPTIONS", + "", + (*_PREFLIGHT_ASKED, ("Access-Control-Request-Headers", asked)), + None, + "key", + 204, + None, + b"", + id=f"asked {asked!r}", + answered={**_FIREFLIES_PREFLIGHT, "access-control-allow-headers": allowed}, + ) + for asked, allowed in ( + ("a\xa0b", "a\xa0b"), + ("a b", "a b"), + ("a\x85b", "a\x85b"), + ("a\tb", "a\tb"), + ("a\xa0", "a\xa0"), + ("\xa0a", "\xa0a"), + ("a\x85", "a\x85"), + ("a\t", "a"), + ("a, ,b", "a, ,b"), + ("A-B, c_d", "A-B, c_d"), + ) + ), + # A header sent twice: `Accept` and the preflight header joined, `Content-Type` the first. + *( + _http_row("POST", "", h, _TYPENAME, "key", s, _JSON_UTF8, a, id=i) + for h, s, a, i in ( + ( + (*_J, ("Accept", "application/json"), ("Accept", "text/html")), + 200, + _TYPENAME_DATA, + "Accept json, html", + ), + ( + (*_J, ("Accept", "text/html"), ("Accept", "application/json")), + 200, + _TYPENAME_DATA, + "Accept html, json", + ), + ( + (*_J, ("Content-Type", "text/plain")), + 200, + _TYPENAME_DATA, + "Content-Type json, text/plain", + ), + ((("Content-Type", "text/plain"), *_J), 400, _CSRF, "Content-Type text/plain, json"), + ((*_J, ("Content-Type", "")), 200, _TYPENAME_DATA, "Content-Type json, empty"), + ((("Content-Type", ""), *_J), 415, _UNSUPPORTED, "Content-Type empty, json"), + ((*_J, *_J), 200, _TYPENAME_DATA, "Content-Type json twice"), + ( + (("Content-Type", "text/plain"), ("x-apollo-operation-name", " ")), + 400, + _CSRF, + "operation name a space", + ), + ) + ), + *( + _http_row("GET", _Q, h, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id=i) + for h, i in ( + ((("apollo-require-preflight", ""), *_P), "preflight empty, 1"), + ((*_P, ("apollo-require-preflight", "")), "preflight 1, empty"), + ) + ), + # A JSON type with no body and no length is read as empty by each method Fastify reads one for. + *( + _http_row( + m, + t, + (("Content-Type", "application/json; charset=latin1"),), + None, + "key", + s, + ty, + a, + id=f"{m}, latin1, no body", + ) + for m, t, s, ty, a in ( + ("POST", "", 400, _JSON_UTF8, _EMPTY), + ("PUT", "", 400, _JSON_UTF8, _EMPTY), + ("PATCH", "", 400, _JSON_UTF8, _EMPTY), + ("DELETE", "", 400, _JSON_UTF8, _EMPTY), + ("OPTIONS", "", 400, "text/plain", b"Invalid Preflight Request"), + ("PROPFIND", "", 404, _JSON_UTF8, _not_found("PROPFIND", "")), + ( + "GET", + "", + 400, + _JSON_UTF8, + _apollo( + b"GraphQL operations must contain a non-empty `query` or a `persistedQuery` extension." + ), + ), + ("HEAD", "", 405, _JSON_UTF8, b""), + ) + ), + _http_row("POST", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="no type, no body"), + _http_row( + "POST", "", _P, None, "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, no body, preflight" + ), + _http_row( + "POST", + "", + _P, + _Chunked(_TYPENAME), + "key", + 415, + _JSON_UTF8, + _UNSUPPORTED, + id="no type, chunked, preflight", + ), + _http_row( + "POST", + "", + _J, + _Chunked(_TYPENAME), + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="JSON, chunked", + ), + _http_row( + "GET", + "?query=%7B&query=%7B", + (), + None, + "key", + 400, + _JSON_UTF8, + _CSRF, + id="GET, unpreflighted, query twice", + ), + _http_row( + "GET", + _Q + "&variables=%7B%7D&operationName=", + _P, + None, + "key", + 400, + _JSON_UTF8, + _apollo(b'Unknown operation named \\"\\".', b"OPERATION_RESOLUTION_FAILURE"), + id="GET, variables {}, operationName empty", + ), + *( + _http_row( + m, + t, + (*h, ("Origin", "https://example.com")), + b, + "key", + s, + _JSON_UTF8, + a, + id=f"{m}, Origin", + ) + for m, t, h, b, s, a in ( + ("POST", "", _J, _TYPENAME, 200, _TYPENAME_DATA), + ("GET", _Q, _P, None, 200, _TYPENAME_DATA), + ("HEAD", _Q, _P, None, 405, b""), + ("PUT", "", _J, _TYPENAME, 404, _not_found("PUT", "")), + ) + ), ] _FIREFLIES_CREDENTIALS = { "none": None, @@ -1935,7 +2165,8 @@ def test_fireflies_answers_each_method_type_and_accept_as_fireflies_does( if _FIREFLIES_CREDENTIALS[credential] is not None: value = _FIREFLIES_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) sent += [("Authorization", value)] * (2 if credential == "twice" else 1) - r = sent_as(client, method, f"/fireflies/graphql{target}", sent, content=body) + content = body.chunks() if isinstance(body, _Chunked) else body + r = sent_as(client, method, f"/fireflies/graphql{target}", sent, content=content, bare=True) assert r.status_code == status assert r.headers.get("content-type") == media_type if answer is _LANDING_PAGE: diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 72157397f..104518e90 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -2041,6 +2041,22 @@ def test_a_tool_carries_its_fields_description(tools): ("application/json;q=0.5x", apollo_http.JSON, False), ("application/json ;q=0", None, False), ("application/json;q =0", None, False), + ( + "application/json;charset=utf-8;q=0.1, application/json;q=0.9, application/graphql-response+json;q=0.5", + apollo_http.GRAPHQL_RESPONSE, + False, + ), + ("application/json;q=abc, application/json;q=0.5", apollo_http.JSON, False), + ('text/html;x=",application/json,"', None, False), + ('application/json;x="a;q=0"', None, False), + ('application/json;charset="utf-8"', apollo_http.JSON, False), + ('application/json;charset=autf-8"', None, False), + ('application/json;charset="utf-8a', None, False), + ("application/json;charset=*", apollo_http.JSON, False), + ("application/json;foo", apollo_http.JSON, False), + ("multipart/mixed; deferSpec=20220824, text/html;q=0.9", None, False), + ("multipart/mixed; incrementalSpec=v0.2, text/html;q=0.9", None, False), + ("multipart/mixed, text/html;q=0.9", None, False), ] diff --git a/tests/test_linear.py b/tests/test_linear.py index b3a761b72..bcb9f4a5c 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -5752,6 +5752,13 @@ def got(result): _Q = "?query=%7B%20__typename%20%7D" +class _Chunked(bytes): + """A body sent in chunks, with no `Content-Length`.""" + + async def chunks(self): + yield bytes(self) + + def _search_refusal(message: bytes) -> bytes: return ( b'{"errors":[{"message":"%s","extensions":{"http":{"status":400,"headers":{}},"code":"BAD_REQ' @@ -5820,7 +5827,28 @@ def _http_row( ), *( _http_row(m, "", (*_J, *_P), None, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id=m) - for m in ("ACL", "PROPFIND", "NOTIFY", "QUERY", "PATCH", "DELETE") + for m in ( + "ACL", + "CHECKOUT", + "COPY", + "DELETE", + "LOCK", + "MERGE", + "MKACTIVITY", + "MKCOL", + "MOVE", + "NOTIFY", + "OPTIONS", + "PATCH", + "PROPFIND", + "PROPPATCH", + "QUERY", + "REPORT", + "SEARCH", + "SUBSCRIBE", + "UNLOCK", + "UNSUBSCRIBE", + ) # fmt: skip ), # `cors`, whatever the credential; Apollo Server's CSRF check and 405 without `Origin`. *( @@ -6243,6 +6271,189 @@ def _http_row( _TYPENAME_DATA, id="GET, Accept text/html then graphql-response", ), + # `cors` lets any other method carrying `Origin` through; an API key under `Bearer` on DELETE. + *( + _http_row(m, t, (*h, *_ORIGIN), b, "key", s, _JSON_UTF8, a, id=f"{m}, Origin") + for m, t, h, b, s, a in ( + ("POST", "", _J, _TYPENAME, 200, _TYPENAME_DATA), + ("GET", _Q, _P, None, 200, _TYPENAME_DATA), + ("HEAD", _Q, _P, None, 405, b""), + ("PUT", "", _J, _TYPENAME, 405, _ONLY_GET_POST), + ("PATCH", "", _J, _TYPENAME, 405, _ONLY_GET_POST), + ("DELETE", "", _J, _TYPENAME, 405, _ONLY_GET_POST), + ) + ), + _http_row( + "DELETE", + "", + (), + None, + "Bearer key", + 400, + _JSON_UTF8, + _API_KEY_AS_BEARER, + id="DELETE, Bearer key", + ), + _http_row( + "OPTIONS", + "", + ( + *_PREFLIGHT_ASKED, + ("Access-Control-Request-Headers", "a"), + ("Access-Control-Request-Headers", "b"), + ), + None, + "key", + 204, + None, + b"", + id="asked twice", + answered={**_LINEAR_PREFLIGHT, "access-control-allow-headers": "a,b"}, + ), + *( + _http_row( + "OPTIONS", + "", + (*_PREFLIGHT_ASKED, ("Access-Control-Request-Headers", asked)), + None, + "key", + 204, + None, + b"", + id=f"asked {asked!r}", + answered={**_LINEAR_PREFLIGHT, "access-control-allow-headers": allowed}, + ) + for asked, allowed in ( + ("a, ,b", "a,,b"), + ("A-B, c_d", "A-B,c_d"), + ("a\t", "a"), + ("a b", "a b"), + ) + ), + _http_row( + "OPTIONS", + "", + (("Origin", " https://example.com\xa0"), ("Access-Control-Request-Method", "POST")), + None, + "key", + 204, + None, + b"", + id="Origin with spaces", + answered={**_LINEAR_PREFLIGHT, "access-control-allow-origin": "https://example.com\xa0"}, + ), + # A header sent twice: `Accept` and the preflight header joined, `Content-Type` joined as + # Linear's front joins it (`backlot.graphql.body.joined_header`). + *( + _http_row("POST", "", h, _TYPENAME, "key", s, _JSON_UTF8, a, id=i) + for h, s, a, i in ( + ( + (*_J, ("Accept", "application/json"), ("Accept", "text/html")), + 200, + _TYPENAME_DATA, + "Accept json, html", + ), + ( + (*_J, ("Accept", "text/html"), ("Accept", "application/json")), + 200, + _TYPENAME_DATA, + "Accept html, json", + ), + ((*_J, ("Content-Type", "text/plain")), 400, _NO_KEYS, "Content-Type json, text/plain"), + ((("Content-Type", "text/plain"), *_J), 400, _NO_KEYS, "Content-Type text/plain, json"), + ((*_J, ("Content-Type", "")), 400, _NO_KEYS, "Content-Type json, empty"), + ((("Content-Type", ""), *_J), 200, _TYPENAME_DATA, "Content-Type empty, json"), + ((*_J, *_J), 400, _NO_KEYS, "Content-Type json twice"), + ( + (("Content-Type", "text/plain"), ("x-apollo-operation-name", " ")), + 400, + _CSRF, + "operation name a space", + ), + ) + ), + *( + _http_row("GET", _Q, h, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id=i) + for h, i in ( + ((("apollo-require-preflight", ""), *_P), "preflight empty, 1"), + ((*_P, ("apollo-require-preflight", "")), "preflight 1, empty"), + ) + ), + # A body under any method but GET and HEAD, as Linear's front gives one with no length. + *( + _http_row( + m, + t, + (("Content-Type", "application/json; charset=latin1"), *h), + None, + "key", + s, + _JSON_UTF8, + a, + id=f"{m}, latin1, no body", + ) + for m, t, h, s, a in ( + ("POST", "", (), 415, _LATIN1), + ("PUT", "", (), 415, _LATIN1), + ("PATCH", "", (), 415, _LATIN1), + ("DELETE", "", (), 415, _LATIN1), + ("OPTIONS", "", (), 415, _LATIN1), + ("PROPFIND", "", (), 415, _LATIN1), + ("GET", "", (), 400, _NO_QUERY), + ("HEAD", "", (), 405, b""), + ) + ), + _http_row("POST", "", _J, None, "key", 400, _JSON_UTF8, _NO_KEYS, id="JSON, no body"), + _http_row("POST", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="no type, no body"), + _http_row( + "POST", "", _P, None, "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, no body, preflight" + ), + _http_row( + "POST", + "", + _P, + _Chunked(_TYPENAME), + "key", + 400, + _JSON_UTF8, + _NO_KEYS, + id="no type, chunked, preflight", + ), + _http_row( + "POST", + "", + _J, + _Chunked(_TYPENAME), + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="JSON, chunked", + ), + _http_row( + "GET", + "?query=%7B&query=%7B", + (), + None, + "key", + 400, + _JSON_UTF8, + _CSRF, + id="GET, unpreflighted, query twice", + ), + _http_row( + "GET", + _Q + "&variables=%7B%7D&operationName=", + _P, + None, + "key", + 400, + _JSON_UTF8, + b'{"errors":[{"message":"operation does not exist","extensions":{"type":"invalid input","code":"INPUT_ERROR",' + b'"statusCode":400,"userError":true,"userPresentableMessage":"The operation does not exist on the query.",' + b'"http":{"status":400}}}]}', + id="GET, variables {}, operationName empty", + ), ] _LINEAR_CREDENTIALS = { "none": None, @@ -6274,7 +6485,8 @@ def test_linear_answers_each_method_type_and_accept_as_linear_does( if _LINEAR_CREDENTIALS[credential] is not None: value = _LINEAR_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) sent += [("Authorization", value)] * (2 if credential == "twice" else 1) - r = sent_as(client, method, f"/linear/graphql{target}", sent, content=body) + content = body.chunks() if isinstance(body, _Chunked) else body + r = sent_as(client, method, f"/linear/graphql{target}", sent, content=content, bare=True) assert r.status_code == status assert r.headers.get("content-type") == media_type assert r.content == answer From 569f2f7feea7b2c2d4f59fafa47f6902c3789504 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:24:47 +0900 Subject: [PATCH 12/52] graphql: each docstring and test comment points at the layer that holds a rule, and the counts are the ones measured --- backlot/graphql/apollo_http.py | 7 +- backlot/graphql/body.py | 11 ++- backlot/main.py | 3 +- backlot/routers/fireflies.py | 64 ++++++------- backlot/routers/linear.py | 44 +++++---- backlot/telemetry/errors.py | 6 +- tests/_helpers.py | 15 +++ tests/test_acl.py | 4 +- tests/test_cross_vendor.py | 7 +- tests/test_fireflies.py | 136 ++++++++++++--------------- tests/test_graphql.py | 9 +- tests/test_linear.py | 165 +++++++++++++++------------------ tests/test_telemetry.py | 4 +- 13 files changed, 225 insertions(+), 250 deletions(-) diff --git a/backlot/graphql/apollo_http.py b/backlot/graphql/apollo_http.py index 324216c04..40d4093de 100644 --- a/backlot/graphql/apollo_http.py +++ b/backlot/graphql/apollo_http.py @@ -9,7 +9,8 @@ :func:`landing_page` its page for a browser. Each refusal is thrown before the request pipeline, so it takes whichever media type ``Accept`` allows and ``application/json`` where it allows none: a :class:`~backlot.graphql.engine.Refusal` that is ``thrown``. A refusal the pipeline makes, from a -persisted query's to a variable's, is refused for the ``Accept`` in turn, with :data:`NOT_ACCEPTABLE`. +persisted query's to a variable's, is refused for the ``Accept`` in turn, with +:data:`NOT_ACCEPTABLE`. The code is read off Apollo Server 5.5.1's ``runHttpQuery.ts``, ``preventCsrf.ts`` and ``ApolloServer.ts``, and the libraries they call: ``whatwg-mimetype`` 4.0.0 (:func:`mime_essence`) @@ -213,8 +214,8 @@ def prefers_html(method: str, accept: str | None) -> bool: def mime_essence(value: str) -> str | None: - """``whatwg-mimetype``'s ``MIMEType.parse(value).essence``: the type and subtype in lower case, - or ``None`` where it parses none. The parameters cannot make it ``None``.""" + """``whatwg-mimetype`` 4.0.0's ``MIMEType.parse(value).essence``: the type and subtype in lower + case, or ``None`` where it parses none. The parameters cannot make it ``None``.""" value = value.strip(_HTTP_SPACE) type_, _, rest = value.partition("/") if not _TOKEN.fullmatch(type_): diff --git a/backlot/graphql/body.py b/backlot/graphql/body.py index f73aab661..c159b5673 100644 --- a/backlot/graphql/body.py +++ b/backlot/graphql/body.py @@ -199,10 +199,11 @@ def fastify_media_type(content_type: str) -> str | None: is the whole value, and splits at its first ``/`` into two tokens, white space allowed before the first and after the second. An empty value and ``undefined`` are invalid. - Fastify 5.7.4 to 5.10.0 answered alike each of 2,138 content types sent to them on - 2026-10-25 (UTC), and api.fireflies.ai answered as they do each sent to it, where 5.5.0 refuses - ``application/json\\t;``, 5.7.0 serves ``application/json ,`` and 5.11.0 reads the body of a - ``QUERY``, which Fireflies answered as it answers a ``PUT`` with none.""" + Fastify 5.7.4 to 5.10.0 answered alike each of 661 content types sent to them on 2026-10-25 + (UTC), and as api.fireflies.ai did each of its requests replayed to them but Cloudflare's four + and a persisted query's, where 5.5.0 refuses ``application/json\\t;``, 5.7.0 serves + ``application/json ,`` and 5.11.3 reads the body of a ``QUERY``, which Fireflies answered with + its 404 without reading ``{``.""" if content_type in ("", "undefined"): return None index = content_type.find(";") @@ -249,7 +250,7 @@ def _fast_decode(text: str) -> str | None: def form_fields(text: str) -> dict[str, str | list[str]]: """A form body as ``@fastify/formbody`` 8 reads it with ``fast-querystring`` 1.1.2: the pairs between each ``&``, a name alone given ``""``, each ``+`` a space and each name and value - percent-decoded, left as sent where it cannot be (:func:`_fast_decode`), and a name sent more + percent-decoded, kept as sent where it cannot be (:func:`_fast_decode`), and a name sent more than once given the list of its values. api.fireflies.ai served ``query=%7B%20__typename%20%7D`` with the preflight header on 2026-10-25 (UTC).""" fields: dict[str, str | list[str]] = {} diff --git a/backlot/main.py b/backlot/main.py index 606e58e8d..168271f9e 100644 --- a/backlot/main.py +++ b/backlot/main.py @@ -1226,7 +1226,8 @@ async def warn_slack_post_charset(request: Request, call_next): #: Cloudflare's answer to each method it refuses in front of Linear and Fireflies, as #: api.linear.app and api.fireflies.ai answered each on `/graphql`, `/graphql/`, `/nosuch` and `/` -#: on 2026-10-25 (UTC): a page with CRLF line ends, or a line of text, and the headers it puts on it. +#: on 2026-10-25 (UTC): a page with CRLF line ends, or a line of text, and the headers it puts on +#: it. _CLOUDFLARE_METHOD = { "TRACE": ( 405, diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index 47b8ab726..3819e4ab6 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -43,7 +43,7 @@ # schema fails at startup instead of on the first request. ENGINE = build_engine() -# The media type of every answer Fastify writes from an object. +# The media type api.fireflies.ai answered each envelope of `_body_refusal` and `_not_found` in. _JSON = "application/json; charset=utf-8" _EMPTY = "Body cannot be empty when content-type is set to 'application/json'" _UNSUPPORTED = "Unsupported Media Type" @@ -91,8 +91,8 @@ def _auth_failed(seen: dict[str, str], message: str) -> Response: entry, as api.fireflies.ai answered on 2026-10-18 with no ``Authorization``, with ``nope``, ``Basic …``, ``token ``, the bare key, ``Bearer nope`` and ``Bearer extra``. The document is not read first: ``{"query":"{"}``, ``[]`` and ``5`` drew the same 500. Apollo - Server makes it from the error its context function throws, so it is in the media type - ``Accept`` takes, as a refusal thrown before the request pipeline is (:func:`_answer`).""" + Server makes it from the error its context function throws, so it takes a thrown refusal's + media type (:mod:`backlot.graphql.apollo_http`).""" return Response( request_body.node_json( { @@ -127,12 +127,10 @@ async def graphql(request: Request): Introspection is the schema description for this endpoint. The layers answer in this order, each measured against api.fireflies.ai on 2026-10-25 (UTC) - with the one after it wrong too: ``@fastify/cors``'s preflight (:func:`_preflight`); Fastify's - reading of the body (:func:`_fastify_read`) and its 404 for a method its route does not take - (:func:`_not_found`); then Apollo Server: its page for a browser, its CSRF check - (:func:`~backlot.graphql.apollo_http.csrf_refusal`), the credential (:func:`_auth_failed`), - its 405 for a `HEAD`, then the request as a GET's query string or a POST's body carries it, - and the run. Its answer is in the media type ``Accept`` takes, or its 406 (:func:`_answer`).""" + with the one after it wrong too: ``@fastify/cors`` (:func:`_preflight`), Fastify + (:func:`_fastify_read`, :func:`_not_found`), then Apollo Server + (:mod:`backlot.graphql.apollo_http`): its page for a browser and its CSRF check ahead of the + credential (:func:`_auth_failed`), and the rest after it (:func:`_answer`).""" method = request.method if method == "OPTIONS": return _preflight(request) @@ -187,11 +185,8 @@ async def graphql(request: Request): def _answer(seen: dict[str, str], result: engine.Result) -> Response: - """Apollo Server's answer to ``result`` in Fireflies' envelope (:func:`_envelope`). A refusal - thrown before the request pipeline is in the media type ``Accept`` takes or - ``application/json``; any other answer is Apollo Server's 406 where it takes none - (:data:`~backlot.graphql.apollo_http.NOT_ACCEPTABLE`), as api.fireflies.ai answered each of - the two kinds on 2026-10-25 (UTC).""" + """Apollo Server's answer to ``result`` in Fireflies' envelope (:func:`_envelope`), in the + media type :func:`~backlot.graphql.apollo_http.response_type` picks, or Apollo Server's 406.""" media_type = apollo_http.response_type(seen.get("accept")) if media_type is None and not (result.refusals and result.refusals[0].thrown): result = engine.refused(apollo_http.NOT_ACCEPTABLE) @@ -226,22 +221,19 @@ def _preflight(request: Request) -> Response: b"Invalid Preflight Request", status_code=400, headers={"content-type": "text/plain"} ) response = Response(status_code=204) - # Node joins the copies of a header with ", ", each without the spaces and tabs at its ends, - # which uvicorn keeps after a value. - asked = ", ".join( - v.strip(" \t") for v in request.headers.getlist("access-control-request-headers") - ) - echoed = (("access-control-allow-headers", asked),) if asked else () + asked = apollo_http.headers_seen(request.headers.raw).get("access-control-request-headers") + echoed = () if asked is None else (("access-control-allow-headers", asked),) response.raw_headers += [(k.encode(), v.encode("latin-1")) for k, v in (*_PREFLIGHT, *echoed)] return response def _not_found(request: Request) -> Response: """Fireflies' 404 for a method its route does not take, the method and the path quoted in it as - a client puts them after ``https://api.fireflies.ai``, the query string as sent. api.fireflies.ai - answered so each of the 28 methods of :data:`apollo_http.METHODS` but GET, HEAD, POST, OPTIONS - and Cloudflare's three on 2026-10-25 (UTC), `PUT`, `DELETE` and `PATCH` once Fastify had read - their body (:func:`_fastify_read`), and quoted `/graphql?x=%7B%20y&z&a=b+c` as sent.""" + a client puts them after ``https://api.fireflies.ai``, the query string as sent. + api.fireflies.ai answered so each of the 28 methods of :data:`apollo_http.METHODS` but GET, + HEAD, POST, OPTIONS and Cloudflare's three on 2026-10-25 (UTC), `PUT`, `DELETE` and `PATCH` once + Fastify had read their body (:func:`_fastify_read`), and quoted `/graphql?x=%7B%20y&z&a=b+c` as + sent.""" # The path as sent, but for the segment that names Fireflies here. path = "/" + request.scope["raw_path"].decode("latin-1").split("/", 2)[2] query = request.scope["query_string"].decode("latin-1") @@ -253,19 +245,19 @@ def _not_found(request: Request) -> Response: async def _fastify_read(request: Request, routed: bool) -> Any: - """The body Fastify 5.10.0 hands a request's handler, or its refusal of it, read off - ``lib/handle-request.js`` and ``lib/content-type-parser.js``. With no ``Content-Type``, a - request with no ``Content-Length`` or one of 0, and no ``Transfer-Encoding``, has none, and - any other is the 415 (:data:`_UNSUPPORTED`); a type it cannot parse - (:func:`~backlot.graphql.body.fastify_media_type`) is the 415. It reads JSON, plain text and a - form (:func:`~backlot.graphql.body.form_fields`): each up to :data:`_LIMIT` bytes, the first as - :func:`_read` does. On a request its route does not take, ``routed`` false, any other type - and no type are left unread, where a routed request gets the 415. + """The body Fastify 5.10.0 hands a request's handler, or :func:`_body_refusal`'s refusal of it, + read off ``lib/handle-request.js`` and ``lib/content-type-parser.js``. With no + ``Content-Type``, a request with no ``Content-Length`` or one of 0, and no + ``Transfer-Encoding``, has none, and any other is the 415; a type it cannot parse + (:func:`~backlot.graphql.body.fastify_media_type`) is the 415. It reads JSON as :func:`_read` + does, plain text and a form (:func:`~backlot.graphql.body.form_fields`). On a request its route + does not take, ``routed`` false, any other type and no type are left unread, where a routed + request gets the 415. api.fireflies.ai answered so each body sent on 2026-10-25 (UTC) to `POST`, `PUT`, `DELETE` and `PATCH`: no type with a body was the 415 by `POST` and the 404 by `PUT`, `a/b/c` and an - empty type the 415 by both, `application/graphql` the 415 by `POST` and the 404 by `PUT`, also - past the limit, and a form or plain text past the limit the 413 by both.""" + empty type the 415 by both, `application/graphql` the 415 by `POST`, past the limit too, and + the 404 by `PUT`, and a form or plain text past the limit the 413 by both.""" content_type = request.headers.get("content-type") if content_type is None: length = request.headers.get("content-length") @@ -366,8 +358,8 @@ def reaches_a_fault(selections, path: tuple[str, ...]) -> bool: def _read(raw: bytes) -> Any: - """The payload Fireflies reads from the body ``raw``, or :func:`_body_refusal`'s refusal of - it.""" + """The payload Fireflies reads from the JSON body ``raw``, or :func:`_body_refusal`'s refusal + of it.""" if len(raw) > _LIMIT: return _body_refusal(_TOO_LARGE, 413) if not raw: diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 4cc1f5e18..1e8049b85 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -21,11 +21,12 @@ string ``variables``, an ``extensions`` or ``operationName`` of ``5``, a persisted query's hash alone or one that does not match, a document that does not parse or validate and one of two operations with no ``operationName`` drew what the key drew on 2026-10-25 (UTC), and a document -missing a variable the 401. An API key under ``Bearer`` is refused ahead of the body: with -``Bearer ``, ``{``, an empty body and a body holding ``NaN`` drew -:func:`_api_key_as_bearer`'s 400 on 2026-10-18, and ``charset=latin1``, ``Content-Encoding: bogus`` -and a body past the size limit did on 2026-10-19, when a key in an API key's shape that Linear never -issued drew it under ``Bearer`` with ``{``, ``NaN``, ``5``, ``charset=latin1`` and an empty body. +missing a variable the 401. An API key under ``Bearer`` is refused ahead of the body on any method +but ``OPTIONS``: with ``Bearer ``, ``{``, an empty body and a body holding +``NaN`` drew :func:`_api_key_as_bearer`'s 400 on 2026-10-18, and ``charset=latin1``, +``Content-Encoding: bogus`` and a body past the size limit did on 2026-10-19, when a key in an API +key's shape that Linear never issued drew it under ``Bearer`` with ``{``, ``NaN``, ``5``, +``charset=latin1`` and an empty body. """ from __future__ import annotations @@ -104,7 +105,7 @@ def _express(message: str, status: int = 400, length: int | None = None) -> Resp #: The methods Linear's load balancer answers with an empty 501 before Linear sees them, as #: api.linear.app answered each on 2026-10-25 (UTC). Of the rest of :data:`apollo_http.METHODS`, -#: Cloudflare answers `TRACE`, `CONNECT` and `PURGE` (``backlot.main``) and Apollo Server the others. +#: Cloudflare answers those ``backlot.main._CLOUDFLARE_METHOD`` holds and Apollo Server the others. _LOAD_BALANCER_501 = frozenset( {"BIND", "LINK", "M-SEARCH", "MKCALENDAR", "REBIND", "SOURCE", "UNBIND", "UNLINK"} ) @@ -119,13 +120,11 @@ async def graphql(request: Request): description for this endpoint. The layers answer in this order, each measured against api.linear.app on 2026-10-25 (UTC) with - the one after it wrong too: ``cors``'s preflight (:func:`_preflight`), the API key under - ``Bearer`` on any method but `OPTIONS`, ``body-parser`` on a body under a JSON type - (:func:`~backlot.graphql.body.express_reads_json`) whatever the method, then Apollo Server: its - CSRF check (:func:`~backlot.graphql.apollo_http.csrf_refusal`), its 405 for a method but GET and - POST, then the request as a GET's query string or a POST's body carries it, up to the choice of - operation; then the credential, and the run. Its answer is in the media type ``Accept`` takes, - or its 406 (:func:`_answer`).""" + the one after it wrong too: the load balancer (:data:`_LOAD_BALANCER_501`), ``cors`` + (:func:`_preflight`), :func:`_api_key_as_bearer`, ``body-parser`` (:func:`_has_body`, + :func:`~backlot.graphql.body.express_reads_json`), Apollo Server + (:mod:`backlot.graphql.apollo_http`) up to the choice of operation, then the credential and the + run (:func:`_answer`).""" method = request.method if method in _LOAD_BALANCER_501: return Response(status_code=501) @@ -227,10 +226,10 @@ def _preflight(request: Request) -> Response: `Content-Type,X-Foo`, `a,b`, `a,,b`, `a,,b`, `a` and `a,b`. An `OPTIONS` without `Origin` is Apollo Server's.""" headers = [("access-control-allow-origin", request.headers["origin"].strip(" \t"))] - asked = request.headers.getlist("access-control-request-headers") - if asked: - names = ", ".join(asked).split(",") - headers.append(("access-control-allow-headers", ",".join(n.strip(" \t") for n in names))) + asked = apollo_http.headers_seen(request.headers.raw).get("access-control-request-headers") + if asked is not None: + names = (name.strip(" \t") for name in asked.split(",")) + headers.append(("access-control-allow-headers", ",".join(names))) response = Response(status_code=204) response.raw_headers += [(k.encode(), v.encode("latin-1")) for k, v in (*headers, *_PREFLIGHT)] return response @@ -302,12 +301,11 @@ def _answer( payload: Any, request: Request | None = None, ) -> Response: - """Apollo Server's answer to ``result`` in Linear's envelope (:func:`_envelope`), or to Linear's - 401 where ``result`` is ``None``. ``request`` is passed where ``result`` is a run, and is told - whether it raised (``graphql_raised``). A refusal thrown before the request pipeline is in the media type - ``Accept`` takes or ``application/json``; any other answer is Apollo Server's 406 where it takes - none (:data:`~backlot.graphql.apollo_http.NOT_ACCEPTABLE`). api.linear.app answered each of the - two kinds so on 2026-10-25 (UTC), the 401 among the second.""" + """Apollo Server's answer to ``result`` in Linear's envelope (:func:`_envelope`), or Linear's + 401 where ``result`` is ``None``, in the media type + :func:`~backlot.graphql.apollo_http.response_type` picks as Express writes it + (:func:`_as_express_sends`), or Apollo Server's 406. ``request``, passed where ``result`` is a + run, carries whether it raised (``graphql_raised``).""" if request is not None: request.state.graphql_raised = result.raised media_type = apollo_http.response_type(seen.get("accept")) diff --git a/backlot/telemetry/errors.py b/backlot/telemetry/errors.py index 109ae5427..d08f9e0e8 100644 --- a/backlot/telemetry/errors.py +++ b/backlot/telemetry/errors.py @@ -21,7 +21,7 @@ | ``unsupported`` | Backlot does not do this yet: the baseline records the operation as ``missing`` or ``refused``, or the GraphQL engine refused a filter | | ``internal`` | of a Backlot bug: its own failure, or a 5xx other than a vendor's answer (below) | | ``rate_limit`` | the vendor's rate limit was reached: a 429, or a 403 with ``x-ratelimit-remaining: 0`` as GitHub sends | -| ``auth`` | the credential was missing, wrong, or not allowed: a 401 or 403, Slack's auth errors, which it sends with a 200, or Fireflies' ``auth_failed``, which it sends with a 500 | +| ``auth`` | the credential was missing, wrong, or not allowed: a 401 or 403, or one in ``_AUTH`` | | ``client`` | the request was wrong: any other failure | Backlot's own failure is an exception raised instead of an answer, or a field error the GraphQL @@ -175,8 +175,8 @@ def error_code(source: str, status: int, body: bytes) -> str: return "" -# The auth errors a vendor answers at a status other than 401 and 403: Slack's at 200, and Fireflies' -# at 500 (`backlot.routers.fireflies._auth_failed`). +# The auth errors a vendor answers at a status other than 401 and 403: Slack's at 200, and +# Fireflies' at 500 (`backlot.routers.fireflies._auth_failed`). _AUTH = frozenset( {("slack", "account_inactive"), ("slack", "invalid_auth"), ("slack", "not_authed")} | {("fireflies", "auth_failed")} diff --git a/tests/_helpers.py b/tests/_helpers.py index 04cf17ccc..9c5baf5bd 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -321,6 +321,21 @@ async def send(): return asyncio.run(send()) +class Chunked(bytes): + """A body :func:`sent_as` sends in chunks, with no `Content-Length`.""" + + async def chunks(self): + yield bytes(self) + + +def http_row(method, target, sent, body, credential, status, media_type, answer, id, answered=None): + """A row of an HTTP layer's table: the request, the credential, and the answer, ``answered`` + naming headers on it.""" + return pytest.param( + method, target, sent, body, credential, status, media_type, answer, answered or {}, id=id + ) + + def selected_fields(document: str, *path: str) -> set[str]: """The field names ``document`` selects at ``path`` — for asserting on a generated selection set (``backlot.graphql.mcp_tools``) by shape rather than by substring.""" diff --git a/tests/test_acl.py b/tests/test_acl.py index 2f52d8446..b8eafde53 100644 --- a/tests/test_acl.py +++ b/tests/test_acl.py @@ -9,8 +9,8 @@ from tests._helpers import client_for, gql # The two ways but a POST of JSON a document reaches each GraphQL server's resolvers: a GET with it -# in the query string, and on Fireflies a POST of a form. Apollo Server serves both only with its -# preflight header. +# in the query string, and on Fireflies a POST of a form, each sent with Apollo Server's preflight +# header (`backlot.graphql.apollo_http.csrf_refusal`). _ADMIN = "admin-service-token" diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index 970553862..339e8a3df 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -392,10 +392,9 @@ def test_cloudflare_refuses_a_repeated_authorization( assert r.content == page -# Cloudflare's answer to each method it refuses in front of Linear and Fireflies, on each path -# api.linear.app and api.fireflies.ai answered it on, and behind its 400 for `Authorization` sent -# twice; `backlot.main.refuse_cloudflares_methods_on_linear_and_fireflies` records them. The last -# row is the control: a path that only starts with the vendor's name is not the vendor's. +# Each of `backlot.main._CLOUDFLARE_METHOD`'s answers on the paths other than `/graphql` it records, +# and the 400 ahead of it for `Authorization` sent twice. The last row is the control: a path that +# only starts with the vendor's name is not the vendor's. _PAGE = ( b"\r\n%s\r\n\r\n

%s

\r\n" b"
cloudflare
\r\n\r\n\r\n" diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index ef6e6d323..a102e95b6 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -19,10 +19,12 @@ from backlot.graphql import mcp_tools from tests._helpers import ( SWEPT_BYTES, + Chunked, WithTheKey, client_for, corpus_client, gql, + http_row, padded_query, sent_as, served_id, @@ -1324,7 +1326,7 @@ def _roster_emails(settings): return [u["email"] for u in data.get("users", [])] -# --- the HTTP layer: methods, content types, Accept and CORS ------------------------------------- +# --- Fastify, @fastify/cors and Apollo Server in front of Fireflies' GraphQL ---------------------- # The request, the credential (`_FIREFLIES_CREDENTIALS`) and the answer api.fireflies.ai gave it on # 2026-10-25 (UTC): its status, its media type and its body but for the newline Apollo Server ends # one with, `_TYPENAME_DATA` where it served the query, and none for a `HEAD`. @@ -1358,6 +1360,8 @@ def _roster_emails(settings): "access-control-allow-origin": "*", "access-control-allow-methods": "GET,HEAD,POST", "vary": "Access-Control-Request-Headers", + # Sent no `Access-Control-Request-Headers`, a preflight is answered none. + "access-control-allow-headers": None, } _J = (("Content-Type", "application/json"),) _P = (("apollo-require-preflight", "1"),) @@ -1365,13 +1369,6 @@ def _roster_emails(settings): _Q = "?query=%7B%20__typename%20%7D" -class _Chunked(bytes): - """A body sent in chunks, with no `Content-Length`.""" - - async def chunks(self): - yield bytes(self) - - _PAST_THE_LIMIT = b"x" * (2**20 + 10) _LANDING_PAGE = object() # Apollo Server's page for a browser, `landing_page`'s @@ -1384,18 +1381,9 @@ def _not_found(method: str, target: str) -> bytes: ) -def _http_row( - method, target, sent, body, credential, status, media_type, answer, id, answered=None -): - """A row: the request, the credential, and the answer, ``answered`` naming headers on it.""" - return pytest.param( - method, target, sent, body, credential, status, media_type, answer, answered or {}, id=id - ) - - _FIREFLIES_HTTP = [ # Cloudflare, ahead of everything. - _http_row( + http_row( "TRACE", "", (), @@ -1406,7 +1394,7 @@ def _http_row( _CLOUDFLARE_PAGE % ((b"405 Not Allowed",) * 2), id="TRACE", ), - _http_row( + http_row( "CONNECT", "", (), @@ -1417,7 +1405,7 @@ def _http_row( b"error code: 1045\n", id="CONNECT", ), - _http_row( + http_row( "PURGE", "", (), @@ -1428,7 +1416,7 @@ def _http_row( _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), id="PURGE", ), - _http_row( + http_row( "TRACE", "", (), @@ -1441,7 +1429,7 @@ def _http_row( ), # `@fastify/cors`, whatever the credential and the body. *( - _http_row( + http_row( "OPTIONS", "", _PREFLIGHT_ASKED, @@ -1455,7 +1443,7 @@ def _http_row( ) for c in ("key", "none", "Bearer nope") ), - _http_row( + http_row( "OPTIONS", "", (("Origin", "null"), ("Access-Control-Request-Method", "POST")), @@ -1467,7 +1455,7 @@ def _http_row( id="Origin null", answered=_FIREFLIES_PREFLIGHT, ), - _http_row( + http_row( "OPTIONS", "", (("Origin", "https://example.com"), ("Access-Control-Request-Method", "PUT")), @@ -1480,7 +1468,7 @@ def _http_row( answered=_FIREFLIES_PREFLIGHT, ), *( - _http_row( + http_row( "OPTIONS", "", (*_PREFLIGHT_ASKED, ("Access-Control-Request-Headers", asked)), @@ -1499,7 +1487,7 @@ def _http_row( ) ), *( - _http_row( + http_row( "OPTIONS", "", h, @@ -1519,8 +1507,8 @@ def _http_row( ((*_J, *_P), b"{", "key", "{"), ) ), - # Fastify's 404 for a method its route does not take, once it has read a body it reads. - _http_row( + # `_not_found`, after `_fastify_read`. + http_row( "ACL", "?_=271823726", (*_J, *_P), @@ -1533,7 +1521,7 @@ def _http_row( id="ACL", ), *( - _http_row(m, "?_=1", (*_J, *_P), None, "key", 404, _JSON_UTF8, _not_found(m, "?_=1"), id=m) + http_row(m, "?_=1", (*_J, *_P), None, "key", 404, _JSON_UTF8, _not_found(m, "?_=1"), id=m) for m in ( "BIND", "CHECKOUT", @@ -1561,7 +1549,7 @@ def _http_row( "UNSUBSCRIBE", ) # fmt: skip ), - _http_row( + http_row( "PROPFIND", "?x=%7B%20y&z&a=b+c&_=1", _J, @@ -1573,7 +1561,7 @@ def _http_row( id="PROPFIND, a query string", ), *( - _http_row(m, "", h, b, c, s, _JSON_UTF8, a, id=f"{m} {i}") + http_row(m, "", h, b, c, s, _JSON_UTF8, a, id=f"{m} {i}") for m, h, b, c, s, a, i in ( ("PUT", (*_J, *_P), _TYPENAME, "key", 404, _not_found("PUT", ""), "a query"), ("DELETE", _J, _TYPENAME, "none", 404, _not_found("DELETE", ""), "a query"), @@ -1614,7 +1602,7 @@ def _http_row( # A POST's content type: a 415 for one Fastify does not read; CSRF for one a browser sends # unpreflighted; the CSRF check ahead of the credential. *( - _http_row("POST", "", (("Content-Type", t), *h), b, c, s, _JSON_UTF8, a, id=f"{t!r}{i}") + http_row("POST", "", (("Content-Type", t), *h), b, c, s, _JSON_UTF8, a, id=f"{t!r}{i}") for t, h, b, c, s, a, i in ( ("text/plain", (), _TYPENAME, "key", 400, _CSRF, ""), ("text/plain", (), _TYPENAME, "none", 400, _CSRF, ", none"), @@ -1682,17 +1670,15 @@ def _http_row( ("text/plain", _P, b"", "key", 400, _NO_KEYS, ", empty"), ) ), - _http_row("POST", "", (), _TYPENAME, "key", 415, _JSON_UTF8, _UNSUPPORTED, id="no type"), - _http_row( + http_row("POST", "", (), _TYPENAME, "key", 415, _JSON_UTF8, _UNSUPPORTED, id="no type"), + http_row( "POST", "", _P, _TYPENAME, "key", 415, _JSON_UTF8, _UNSUPPORTED, id="no type, preflight" ), - _http_row("POST", "", (), b"", "key", 400, _JSON_UTF8, _CSRF, id="no type, empty"), - _http_row( - "POST", "", _P, b"", "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, empty, preflight" - ), + http_row("POST", "", (), b"", "key", 400, _JSON_UTF8, _CSRF, id="no type, empty"), + http_row("POST", "", _P, b"", "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, empty, preflight"), # Apollo Server's page for a browser, ahead of the CSRF check and the credential. *( - _http_row("GET", t, (*h, ("Accept", a)), None, c, s, ty, e, id=f"GET, Accept {a!r}{i}") + http_row("GET", t, (*h, ("Accept", a)), None, c, s, ty, e, id=f"GET, Accept {a!r}{i}") for t, h, a, c, s, ty, e, i in ( ("", (), "text/html", "none", 200, "text/html", _LANDING_PAGE, ", none"), (_Q, _P, "text/html,application/json", "key", 200, "text/html", _LANDING_PAGE, ""), @@ -1722,13 +1708,12 @@ def _http_row( ), ) ), - # A GET: the CSRF check, the credential, Apollo Server 5.5's 415 for a content type, then its - # query string. - _http_row("GET", _Q, _P, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET"), - _http_row("GET", _Q, (), None, "key", 400, _JSON_UTF8, _CSRF, id="GET, unpreflighted"), - _http_row("GET", _Q, _J, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET, JSON"), + # A GET, its content type and its query string read after the credential. + http_row("GET", _Q, _P, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET"), + http_row("GET", _Q, (), None, "key", 400, _JSON_UTF8, _CSRF, id="GET, unpreflighted"), + http_row("GET", _Q, _J, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET, JSON"), *( - _http_row( + http_row( "GET", _Q, (("Content-Type", t), *h), None, c, s, _JSON_UTF8, a, id=f"GET, {t!r}{i}" ) for t, h, c, s, a, i in ( @@ -1740,7 +1725,7 @@ def _http_row( ("application/json; charset=latin1", _P, "key", 200, _TYPENAME_DATA, ""), ) ), - _http_row( + http_row( "GET", "?query=%7B&query=%7B", (("Content-Type", "text/plain"), *_P), @@ -1752,7 +1737,7 @@ def _http_row( id="GET, text/plain, query twice", ), *( - _http_row("GET", t, _P, None, "key", 400, _JSON_UTF8, _apollo(m), id=i) + http_row("GET", t, _P, None, "key", 400, _JSON_UTF8, _apollo(m), id=i) for t, m, i in ( ( _Q + "&query=", @@ -1787,7 +1772,7 @@ def _http_row( ) ), *( - _http_row( + http_row( "GET", f"?query={op}%20%7B%20__typename%20%7D", _P, @@ -1823,7 +1808,7 @@ def _http_row( ("mutation", "none", 500, _BAD_KEY, {}), ) ), - _http_row( + http_row( "HEAD", _Q, _P, @@ -1835,7 +1820,7 @@ def _http_row( id="HEAD", answered={"allow": "GET, POST"}, ), - _http_row( + http_row( "HEAD", _Q, (*_P, ("Accept", "text/html")), @@ -1846,8 +1831,8 @@ def _http_row( b"", id="HEAD, Accept text/html", ), - _http_row("HEAD", _Q, _P, None, "none", 500, _JSON_UTF8, b"", id="HEAD, none"), - _http_row( + http_row("HEAD", _Q, _P, None, "none", 500, _JSON_UTF8, b"", id="HEAD, none"), + http_row( "HEAD", _Q, (("Content-Type", "text/plain"),), @@ -1858,10 +1843,9 @@ def _http_row( b"", id="HEAD, text/plain", ), - # Accept: the media type of an answer, Apollo Server's 406 once its request pipeline has - # answered, and none for a refusal it throws before that, the credential's among them. + # `apollo_http.response_type` and its 406, the credential's 500 among the thrown refusals. *( - _http_row("POST", "", (*_J, ("Accept", a)), b, c, s, t, e, id=f"Accept {a!r}{i}") + http_row("POST", "", (*_J, ("Accept", a)), b, c, s, t, e, id=f"Accept {a!r}{i}") for a, b, c, s, t, e, i in ( ("text/html", _TYPENAME, "key", 406, _JSON_UTF8, _NOT_ACCEPTABLE, ""), ("application/json;q=0", _TYPENAME, "key", 406, _JSON_UTF8, _NOT_ACCEPTABLE, ""), @@ -1916,7 +1900,7 @@ def _http_row( ("text/html", b"{", "key", 400, _JSON_UTF8, _NOT_JSON, ", {"), ) ), - _http_row( + http_row( "POST", "", (("Content-Type", "text/plain"), ("Accept", "application/graphql-response+json")), @@ -1927,7 +1911,7 @@ def _http_row( _CSRF, id="text/plain, Accept graphql-response", ), - _http_row( + http_row( "PUT", "", (*_J, ("Accept", "application/graphql-response+json")), @@ -1940,8 +1924,8 @@ def _http_row( ), # Fastify reads the body of a DELETE before its 404, and leaves a type it has no parser for # unread there. - _http_row("DELETE", "", (*_J, *_P), b"{", "key", 400, _JSON_UTF8, _NOT_JSON, id="DELETE {"), - _http_row( + http_row("DELETE", "", (*_J, *_P), b"{", "key", 400, _JSON_UTF8, _NOT_JSON, id="DELETE {"), + http_row( "PUT", "", (("Content-Type", "application/graphql"), *_P), @@ -1952,7 +1936,7 @@ def _http_row( _not_found("PUT", ""), id="PUT application/graphql", ), - _http_row( + http_row( "OPTIONS", "", ( @@ -1969,7 +1953,7 @@ def _http_row( answered={**_FIREFLIES_PREFLIGHT, "access-control-allow-headers": "a, b"}, ), *( - _http_row( + http_row( "OPTIONS", "", (*_PREFLIGHT_ASKED, ("Access-Control-Request-Headers", asked)), @@ -1994,9 +1978,9 @@ def _http_row( ("A-B, c_d", "A-B, c_d"), ) ), - # A header sent twice: `Accept` and the preflight header joined, `Content-Type` the first. + # Copies of a header, read as `apollo_http.headers_seen` reads them. *( - _http_row("POST", "", h, _TYPENAME, "key", s, _JSON_UTF8, a, id=i) + http_row("POST", "", h, _TYPENAME, "key", s, _JSON_UTF8, a, id=i) for h, s, a, i in ( ( (*_J, ("Accept", "application/json"), ("Accept", "text/html")), @@ -2029,15 +2013,15 @@ def _http_row( ) ), *( - _http_row("GET", _Q, h, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id=i) + http_row("GET", _Q, h, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id=i) for h, i in ( ((("apollo-require-preflight", ""), *_P), "preflight empty, 1"), ((*_P, ("apollo-require-preflight", "")), "preflight 1, empty"), ) ), - # A JSON type with no body and no length is read as empty by each method Fastify reads one for. + # No body and no length under a JSON type, by method. *( - _http_row( + http_row( m, t, (("Content-Type", "application/json; charset=latin1"),), @@ -2067,33 +2051,33 @@ def _http_row( ("HEAD", "", 405, _JSON_UTF8, b""), ) ), - _http_row("POST", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="no type, no body"), - _http_row( + http_row("POST", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="no type, no body"), + http_row( "POST", "", _P, None, "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, no body, preflight" ), - _http_row( + http_row( "POST", "", _P, - _Chunked(_TYPENAME), + Chunked(_TYPENAME), "key", 415, _JSON_UTF8, _UNSUPPORTED, id="no type, chunked, preflight", ), - _http_row( + http_row( "POST", "", _J, - _Chunked(_TYPENAME), + Chunked(_TYPENAME), "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="JSON, chunked", ), - _http_row( + http_row( "GET", "?query=%7B&query=%7B", (), @@ -2104,7 +2088,7 @@ def _http_row( _CSRF, id="GET, unpreflighted, query twice", ), - _http_row( + http_row( "GET", _Q + "&variables=%7B%7D&operationName=", _P, @@ -2116,7 +2100,7 @@ def _http_row( id="GET, variables {}, operationName empty", ), *( - _http_row( + http_row( m, t, (*h, ("Origin", "https://example.com")), @@ -2165,7 +2149,7 @@ def test_fireflies_answers_each_method_type_and_accept_as_fireflies_does( if _FIREFLIES_CREDENTIALS[credential] is not None: value = _FIREFLIES_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) sent += [("Authorization", value)] * (2 if credential == "twice" else 1) - content = body.chunks() if isinstance(body, _Chunked) else body + content = body.chunks() if isinstance(body, Chunked) else body r = sent_as(client, method, f"/fireflies/graphql{target}", sent, content=content, bare=True) assert r.status_code == status assert r.headers.get("content-type") == media_type diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 104518e90..bd3bc90b5 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -1996,12 +1996,9 @@ def test_a_tool_carries_its_fields_description(tools): assert "Loose notes, newest first." in tools["notes"].description -# --- Apollo Server's HTTP layer, and the Node libraries in front of it -------------------------- -# Each value and what the library the port reads off answers for it under Node 24.18.0, asked on -# 2026-10-25: negotiator 1.0.0 (`apollo_http.response_type`, `apollo_http.prefers_html`), -# whatwg-mimetype 4.0.0 (`apollo_http.mime_essence`), type-is 2.0.1 over content-type 1.0.5 -# (`body.express_reads_json`), Fastify 5.10.0's `ContentType` (`body.fastify_media_type`) and -# fast-querystring 1.1.2 (`body.form_fields`). +# --- the Node libraries `apollo_http` and `body` are read off ------------------------------------- +# Each value and what the library behind the port each test names answered for it under Node +# 24.18.0, on 2026-10-25; the ports' docstrings name each library's release. _ACCEPTED = [ ("*/*", apollo_http.JSON, False), ("application/*", apollo_http.JSON, False), diff --git a/tests/test_linear.py b/tests/test_linear.py index bcb9f4a5c..3fb789dae 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -25,12 +25,14 @@ from backlot.graphql.body import node_json from tests._helpers import ( SWEPT_BYTES, + Chunked, WithTheKey, build_corpus, client_for, complete, corpus_client, db_count, + http_row, padded_query, selected_field_count, selected_fields, @@ -5696,7 +5698,7 @@ def got(result): assert len(seen) == len(set(seen)) -# --- the HTTP layer: methods, content types, Accept and CORS ------------------------------------- +# --- the load balancer, Express, cors and Apollo Server in front of Linear's GraphQL -------------- # The request, the credential (`_LINEAR_CREDENTIALS`) and the answer api.linear.app gave it on # 2026-10-25 (UTC): its status, its media type and its body but for the newline Apollo Server ends # one with, `_TYPENAME_DATA` where it served the query, and none for a `HEAD`. @@ -5744,6 +5746,8 @@ def got(result): "access-control-expose-headers": "Cache-Control,Content-Language,Content-Length,Content-Type," "Expires,Last-Modified,Pragma,x-request-id,cf-ray,cf-cache-status,Retry-After", "access-control-max-age": "86400", + # Sent no `Access-Control-Request-Headers`, a preflight is answered none. + "access-control-allow-headers": None, } _J = (("Content-Type", "application/json"),) _P = (("apollo-require-preflight", "1"),) @@ -5752,13 +5756,6 @@ def got(result): _Q = "?query=%7B%20__typename%20%7D" -class _Chunked(bytes): - """A body sent in chunks, with no `Content-Length`.""" - - async def chunks(self): - yield bytes(self) - - def _search_refusal(message: bytes) -> bytes: return ( b'{"errors":[{"message":"%s","extensions":{"http":{"status":400,"headers":{}},"code":"BAD_REQ' @@ -5766,18 +5763,9 @@ def _search_refusal(message: bytes) -> bytes: ) -def _http_row( - method, target, sent, body, credential, status, media_type, answer, id, answered=None -): - """A row: the request, the credential, and the answer, ``answered`` naming headers on it.""" - return pytest.param( - method, target, sent, body, credential, status, media_type, answer, answered or {}, id=id - ) - - _LINEAR_HTTP = [ # Cloudflare, then Linear's load balancer, ahead of everything. - _http_row( + http_row( "TRACE", "", (), @@ -5788,7 +5776,7 @@ def _http_row( _CLOUDFLARE_PAGE % ((b"405 Not Allowed",) * 2), id="TRACE", ), - _http_row( + http_row( "CONNECT", "", (), @@ -5799,7 +5787,7 @@ def _http_row( b"error code: 1045\n", id="CONNECT", ), - _http_row( + http_row( "PURGE", "", (), @@ -5810,7 +5798,7 @@ def _http_row( _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), id="PURGE", ), - _http_row( + http_row( "TRACE", "", (), @@ -5822,11 +5810,11 @@ def _http_row( id="TRACE, Authorization twice", ), *( - _http_row(m, "", (*_J, *_P), None, "key", 501, None, b"", id=m) + http_row(m, "", (*_J, *_P), None, "key", 501, None, b"", id=m) for m in ("BIND", "LINK", "M-SEARCH", "MKCALENDAR", "REBIND", "SOURCE", "UNBIND", "UNLINK") ), *( - _http_row(m, "", (*_J, *_P), None, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id=m) + http_row(m, "", (*_J, *_P), None, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id=m) for m in ( "ACL", "CHECKOUT", @@ -5852,7 +5840,7 @@ def _http_row( ), # `cors`, whatever the credential; Apollo Server's CSRF check and 405 without `Origin`. *( - _http_row( + http_row( "OPTIONS", "", _PREFLIGHT_ASKED, @@ -5866,7 +5854,7 @@ def _http_row( ) for c in ("key", "none", "Bearer key") ), - _http_row( + http_row( "OPTIONS", "", _ORIGIN, @@ -5878,7 +5866,7 @@ def _http_row( id="Origin alone", answered=_LINEAR_PREFLIGHT, ), - _http_row( + http_row( "OPTIONS", "", (("Origin", "null"), ("Access-Control-Request-Method", "POST")), @@ -5890,7 +5878,7 @@ def _http_row( id="Origin null", answered={**_LINEAR_PREFLIGHT, "access-control-allow-origin": "null"}, ), - _http_row( + http_row( "OPTIONS", "", (*_ORIGIN, ("Access-Control-Request-Method", "PUT")), @@ -5903,7 +5891,7 @@ def _http_row( answered=_LINEAR_PREFLIGHT, ), *( - _http_row( + http_row( "OPTIONS", "", (*_PREFLIGHT_ASKED, ("Access-Control-Request-Headers", asked)), @@ -5921,11 +5909,11 @@ def _http_row( ("a,,b", "a,,b"), ) ), - _http_row("OPTIONS", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="OPTIONS"), - _http_row( + http_row("OPTIONS", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="OPTIONS"), + http_row( "OPTIONS", "", (), None, "Bearer key", 400, _JSON_UTF8, _CSRF, id="OPTIONS, Bearer key" ), - _http_row( + http_row( "OPTIONS", "", (("Access-Control-Request-Method", "POST"),), @@ -5936,10 +5924,10 @@ def _http_row( _CSRF, id="OPTIONS, no Origin", ), - _http_row("OPTIONS", "", _J, None, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id="OPTIONS, JSON"), - # The API key under `Bearer` on any method but OPTIONS, ahead of `body-parser`. + http_row("OPTIONS", "", _J, None, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id="OPTIONS, JSON"), + # `_api_key_as_bearer` ahead of `body-parser`. *( - _http_row(m, t, h, b, "Bearer key", 400, _JSON_UTF8, a, id=f"{m}, Bearer key") + http_row(m, t, h, b, "Bearer key", 400, _JSON_UTF8, a, id=f"{m}, Bearer key") for m, t, h, b, a in ( ("GET", _Q, (), None, _API_KEY_AS_BEARER), ("PUT", "", _J, b"{", _API_KEY_AS_BEARER), @@ -5947,18 +5935,18 @@ def _http_row( ("PATCH", "", _J, None, _API_KEY_AS_BEARER), ) ), - # `body-parser` on any method's JSON body, then Apollo Server's CSRF check, then its 405. - _http_row("PUT", "", (*_J, *_P), b"{", "key", 400, _JSON_UTF8, _EXPECTED_PROPERTY, id="PUT {"), - _http_row( + # `body-parser` on a JSON body (`_has_body`), then Apollo Server's CSRF check, then its 405. + http_row("PUT", "", (*_J, *_P), b"{", "key", 400, _JSON_UTF8, _EXPECTED_PROPERTY, id="PUT {"), + http_row( "OPTIONS", "", (*_J, *_P), b"{", "key", 400, _JSON_UTF8, _EXPECTED_PROPERTY, id="OPTIONS {" ), - _http_row( + http_row( "PUT", "", (*_J, *_P), _TYPENAME, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id="PUT, a query" ), - _http_row( + http_row( "DELETE", "", _J, _TYPENAME, "none", 405, _JSON_UTF8, _ONLY_GET_POST, id="DELETE, a query" ), - _http_row( + http_row( "PUT", "", (("Content-Type", "text/plain"),), @@ -5969,11 +5957,11 @@ def _http_row( _CSRF, id="PUT, text/plain", ), - _http_row("DELETE", "", (), None, "nope", 400, _JSON_UTF8, _CSRF, id="DELETE"), + http_row("DELETE", "", (), None, "nope", 400, _JSON_UTF8, _CSRF, id="DELETE"), # A POST's content type: read as JSON, refused as a request a browser sends unpreflighted, or # passed with nothing read. *( - _http_row( + http_row( "POST", "", (("Content-Type", t), *h), @@ -6002,15 +5990,15 @@ def _http_row( ('application/json;x="a;b"', (), 200, _TYPENAME_DATA, ""), ) ), - _http_row("POST", "", (), _TYPENAME, "key", 400, _JSON_UTF8, _CSRF, id="no type"), - _http_row("POST", "", _P, _TYPENAME, "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, preflight"), - _http_row("POST", "", _J, b"", "key", 400, _JSON_UTF8, _NO_KEYS, id="JSON, empty"), + http_row("POST", "", (), _TYPENAME, "key", 400, _JSON_UTF8, _CSRF, id="no type"), + http_row("POST", "", _P, _TYPENAME, "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, preflight"), + http_row("POST", "", _J, b"", "key", 400, _JSON_UTF8, _NO_KEYS, id="JSON, empty"), # A GET: its query string, Apollo Server's 405 for one that is not a query, ahead of the # credential. - _http_row("GET", _Q, _P, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET"), - _http_row("GET", _Q, (), None, "key", 400, _JSON_UTF8, _CSRF, id="GET, unpreflighted"), - _http_row("GET", _Q, _J, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET, JSON"), - _http_row( + http_row("GET", _Q, _P, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET"), + http_row("GET", _Q, (), None, "key", 400, _JSON_UTF8, _CSRF, id="GET, unpreflighted"), + http_row("GET", _Q, _J, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET, JSON"), + http_row( "GET", _Q, (("Content-Type", "text/plain"), *_P), @@ -6021,7 +6009,7 @@ def _http_row( _TYPENAME_DATA, id="GET, text/plain", ), - _http_row( + http_row( "GET", _Q, (("Content-Type", "foo"),), @@ -6032,8 +6020,8 @@ def _http_row( _TYPENAME_DATA, id="GET, foo", ), - _http_row("GET", _Q, _P, None, "none", 401, _JSON_UTF8, _UNAUTHENTICATED, id="GET, none"), - _http_row( + http_row("GET", _Q, _P, None, "none", 401, _JSON_UTF8, _UNAUTHENTICATED, id="GET, none"), + http_row( "GET", "?query=%7B+__typename+%7D", _P, @@ -6044,12 +6032,12 @@ def _http_row( _TYPENAME_DATA, id="GET, +", ), - _http_row("GET", "", _P, None, "key", 400, _JSON_UTF8, _NO_QUERY, id="GET, no query"), - _http_row( + http_row("GET", "", _P, None, "key", 400, _JSON_UTF8, _NO_QUERY, id="GET, no query"), + http_row( "GET", "?QUERY" + _Q[6:], _P, None, "key", 400, _JSON_UTF8, _NO_QUERY, id="GET, QUERY" ), *( - _http_row("GET", t, _P, None, "key", 400, _JSON_UTF8, _search_refusal(m), id=i) + http_row("GET", t, _P, None, "key", 400, _JSON_UTF8, _search_refusal(m), id=i) for t, m, i in ( ( _Q + "&query=", @@ -6109,7 +6097,7 @@ def _http_row( ) ), *( - _http_row( + http_row( "GET", "?query=mutation%20%7B%20__typename%20%7D", _P, @@ -6123,7 +6111,7 @@ def _http_row( ) for c in ("key", "none") ), - _http_row( + http_row( "GET", "?query=query%20A%20%7B%20__typename%20%7D%20mutation%20B%20%7B%20__typename%20%7D" "&operationName=A", @@ -6135,7 +6123,7 @@ def _http_row( _TYPENAME_DATA, id="GET, a query beside a mutation", ), - _http_row( + http_row( "HEAD", _Q, _P, @@ -6147,7 +6135,7 @@ def _http_row( id="HEAD", answered={"allow": "GET, POST"}, ), - _http_row( + http_row( "HEAD", _Q, (("Content-Type", "text/plain"),), @@ -6158,10 +6146,9 @@ def _http_row( b"", id="HEAD, text/plain", ), - # Accept: the media type of an answer, Apollo Server's 406 once its request pipeline has - # answered, and none for a refusal it throws before that. + # `apollo_http.response_type` and its 406, the 401 among the answers it replaces. *( - _http_row("POST", "", (*_J, ("Accept", a)), _TYPENAME, "key", s, t, b, id=f"Accept {a!r}") + http_row("POST", "", (*_J, ("Accept", a)), _TYPENAME, "key", s, t, b, id=f"Accept {a!r}") for a, s, t, b in ( ("text/html", 406, _JSON_UTF8, _NOT_ACCEPTABLE), ("application/json;q=0", 406, _JSON_UTF8, _NOT_ACCEPTABLE), @@ -6181,7 +6168,7 @@ def _http_row( ) ), *( - _http_row("POST", "", (*_J, ("Accept", a)), b, c, s, t, e, id=f"Accept {a!r}, {i}") + http_row("POST", "", (*_J, ("Accept", a)), b, c, s, t, e, id=f"Accept {a!r}, {i}") for a, b, c, s, t, e, i in ( ("text/html", b"{}", "key", 400, _JSON_UTF8, _NO_KEYS, "{}"), ( @@ -6216,7 +6203,7 @@ def _http_row( ("text/html", b"{", "key", 400, _JSON_UTF8, _EXPECTED_PROPERTY, "{"), ) ), - _http_row( + http_row( "GET", _Q, (*_P, ("Accept", "text/html")), @@ -6227,7 +6214,7 @@ def _http_row( _NOT_ACCEPTABLE, id="GET, Accept text/html", ), - _http_row( + http_row( "GET", "?query=mutation%20%7B%20__typename%20%7D", (*_P, ("Accept", "text/html;level=1")), @@ -6238,7 +6225,7 @@ def _http_row( _NOT_ACCEPTABLE, id="GET mutation, Accept text/html;level=1", ), - _http_row( + http_row( "POST", "", (("Content-Type", "text/plain"), ("Accept", "application/graphql-response+json")), @@ -6249,7 +6236,7 @@ def _http_row( _CSRF, id="text/plain, Accept graphql-response", ), - _http_row( + http_row( "PUT", "", (*_J, ("Accept", "application/graphql-response+json")), @@ -6260,7 +6247,7 @@ def _http_row( _ONLY_GET_POST, id="PUT, Accept graphql-response", ), - _http_row( + http_row( "GET", _Q, (*_P, ("Accept", "text/html, application/graphql-response+json")), @@ -6273,7 +6260,7 @@ def _http_row( ), # `cors` lets any other method carrying `Origin` through; an API key under `Bearer` on DELETE. *( - _http_row(m, t, (*h, *_ORIGIN), b, "key", s, _JSON_UTF8, a, id=f"{m}, Origin") + http_row(m, t, (*h, *_ORIGIN), b, "key", s, _JSON_UTF8, a, id=f"{m}, Origin") for m, t, h, b, s, a in ( ("POST", "", _J, _TYPENAME, 200, _TYPENAME_DATA), ("GET", _Q, _P, None, 200, _TYPENAME_DATA), @@ -6283,7 +6270,7 @@ def _http_row( ("DELETE", "", _J, _TYPENAME, 405, _ONLY_GET_POST), ) ), - _http_row( + http_row( "DELETE", "", (), @@ -6294,7 +6281,7 @@ def _http_row( _API_KEY_AS_BEARER, id="DELETE, Bearer key", ), - _http_row( + http_row( "OPTIONS", "", ( @@ -6311,7 +6298,7 @@ def _http_row( answered={**_LINEAR_PREFLIGHT, "access-control-allow-headers": "a,b"}, ), *( - _http_row( + http_row( "OPTIONS", "", (*_PREFLIGHT_ASKED, ("Access-Control-Request-Headers", asked)), @@ -6330,7 +6317,7 @@ def _http_row( ("a b", "a b"), ) ), - _http_row( + http_row( "OPTIONS", "", (("Origin", " https://example.com\xa0"), ("Access-Control-Request-Method", "POST")), @@ -6342,10 +6329,10 @@ def _http_row( id="Origin with spaces", answered={**_LINEAR_PREFLIGHT, "access-control-allow-origin": "https://example.com\xa0"}, ), - # A header sent twice: `Accept` and the preflight header joined, `Content-Type` joined as - # Linear's front joins it (`backlot.graphql.body.joined_header`). + # Copies of a header: `Content-Type` as `backlot.graphql.body.joined_header` joins them, the + # rest as `apollo_http.headers_seen` reads them. *( - _http_row("POST", "", h, _TYPENAME, "key", s, _JSON_UTF8, a, id=i) + http_row("POST", "", h, _TYPENAME, "key", s, _JSON_UTF8, a, id=i) for h, s, a, i in ( ( (*_J, ("Accept", "application/json"), ("Accept", "text/html")), @@ -6373,15 +6360,15 @@ def _http_row( ) ), *( - _http_row("GET", _Q, h, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id=i) + http_row("GET", _Q, h, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id=i) for h, i in ( ((("apollo-require-preflight", ""), *_P), "preflight empty, 1"), ((*_P, ("apollo-require-preflight", "")), "preflight 1, empty"), ) ), - # A body under any method but GET and HEAD, as Linear's front gives one with no length. + # No body and no length under a JSON type, by method (`_has_body`). *( - _http_row( + http_row( m, t, (("Content-Type", "application/json; charset=latin1"), *h), @@ -6403,34 +6390,34 @@ def _http_row( ("HEAD", "", (), 405, b""), ) ), - _http_row("POST", "", _J, None, "key", 400, _JSON_UTF8, _NO_KEYS, id="JSON, no body"), - _http_row("POST", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="no type, no body"), - _http_row( + http_row("POST", "", _J, None, "key", 400, _JSON_UTF8, _NO_KEYS, id="JSON, no body"), + http_row("POST", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="no type, no body"), + http_row( "POST", "", _P, None, "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, no body, preflight" ), - _http_row( + http_row( "POST", "", _P, - _Chunked(_TYPENAME), + Chunked(_TYPENAME), "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, chunked, preflight", ), - _http_row( + http_row( "POST", "", _J, - _Chunked(_TYPENAME), + Chunked(_TYPENAME), "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="JSON, chunked", ), - _http_row( + http_row( "GET", "?query=%7B&query=%7B", (), @@ -6441,7 +6428,7 @@ def _http_row( _CSRF, id="GET, unpreflighted, query twice", ), - _http_row( + http_row( "GET", _Q + "&variables=%7B%7D&operationName=", _P, @@ -6485,7 +6472,7 @@ def test_linear_answers_each_method_type_and_accept_as_linear_does( if _LINEAR_CREDENTIALS[credential] is not None: value = _LINEAR_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) sent += [("Authorization", value)] * (2 if credential == "twice" else 1) - content = body.chunks() if isinstance(body, _Chunked) else body + content = body.chunks() if isinstance(body, Chunked) else body r = sent_as(client, method, f"/linear/graphql{target}", sent, content=content, bare=True) assert r.status_code == status assert r.headers.get("content-type") == media_type diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index daacea464..e27ae7075 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -576,8 +576,8 @@ def test_an_error_code_is_the_envelopes_own_identifier(source, status, body, exp {"headers": LINEAR | JSON, "content": b"{"}, ("linear", "unparsed", "unknown", 400, None, "client"), ), - # The HTTP layer in front of each GraphQL server answers every method, a GET's query among - # them, and Fireflies' credential's 500 is a refusal of the credential. + # The HTTP layer in front of each GraphQL server answers methods but POST, a GET's query + # among them, and Fireflies' credential's 500 is a refusal of the credential. ( "GET", "/fireflies/graphql" + _TYPENAME_SEARCH, From 7fd2354a31fa8bd58e3a3aefeb92f28503649aa9 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:30:29 +0900 Subject: [PATCH 13/52] graphql: one home for each rule the docstrings state, and present-tense docstrings --- backlot/graphql/apollo_http.py | 2 +- backlot/routers/fireflies.py | 3 +-- backlot/routers/linear.py | 2 +- tests/test_fireflies.py | 10 +++++----- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/backlot/graphql/apollo_http.py b/backlot/graphql/apollo_http.py index 40d4093de..2fa595461 100644 --- a/backlot/graphql/apollo_http.py +++ b/backlot/graphql/apollo_http.py @@ -242,7 +242,7 @@ def headers_seen(raw: list[tuple[bytes, bytes]]) -> dict[str, str]: """The request headers as Apollo Server reads them behind Node, from the ASGI ``raw`` list: each name in lower case and each value without the spaces and tabs at its ends, the values of a name sent more than once joined with ``", "`` but ``content-type``'s, of which Node keeps the - first, and a header with no value left out. Both vendors answered so on 2026-10-25 (UTC): + first, and a header with no value left out. Each vendor answered so on 2026-10-25 (UTC): `Accept` sent as `application/json` and as `text/html`, in either order, was served; `apollo-require-preflight` sent empty and as `1`, in either order, let a GET through, and an `x-apollo-operation-name` of a space did not; a `Content-Type` sent twice was the first to diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index 3819e4ab6..48caf70b6 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -61,8 +61,7 @@ def _body_refusal(message: str, status: int = 400, code: str = "http") -> Respon :data:`_NOT_JSON` the 400 for every other one :mod:`backlot.graphql.body` cannot read or finds :func:`~backlot.graphql.body.prototype_poisoned`, as api.fireflies.ai answered on 2026-10-18; :data:`_TOO_LARGE` is the 413 for one over :data:`_LIMIT` and :data:`_UNSUPPORTED` the 415 - for one of a type it does not read (:func:`_fastify_read`). :func:`_not_found` is the same - envelope.""" + for one of a type it does not read. :func:`_not_found` is the same envelope.""" envelope = { "statusCode": status, "status": False, diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 1e8049b85..34e2e6fc9 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -305,7 +305,7 @@ def _answer( 401 where ``result`` is ``None``, in the media type :func:`~backlot.graphql.apollo_http.response_type` picks as Express writes it (:func:`_as_express_sends`), or Apollo Server's 406. ``request``, passed where ``result`` is a - run, carries whether it raised (``graphql_raised``).""" + run, is given ``result.raised`` as ``graphql_raised``.""" if request is not None: request.state.graphql_raised = result.raised media_type = apollo_http.response_type(seen.get("accept")) diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index a102e95b6..c694b92dd 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -1327,9 +1327,9 @@ def _roster_emails(settings): # --- Fastify, @fastify/cors and Apollo Server in front of Fireflies' GraphQL ---------------------- -# The request, the credential (`_FIREFLIES_CREDENTIALS`) and the answer api.fireflies.ai gave it on -# 2026-10-25 (UTC): its status, its media type and its body but for the newline Apollo Server ends -# one with, `_TYPENAME_DATA` where it served the query, and none for a `HEAD`. +# Each row: a request, its credential (`_FIREFLIES_CREDENTIALS`), and api.fireflies.ai's answer to +# it on 2026-10-25 (UTC), status, media type and body less the newline Apollo Server ends one with: +# `_TYPENAME_DATA` where the query was served, nothing for a `HEAD`. _CSRF = _apollo( b"This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). Please" b" either specify a 'content-type' header (with a type that is not one of" @@ -1360,7 +1360,7 @@ def _roster_emails(settings): "access-control-allow-origin": "*", "access-control-allow-methods": "GET,HEAD,POST", "vary": "Access-Control-Request-Headers", - # Sent no `Access-Control-Request-Headers`, a preflight is answered none. + # A preflight asking for no header names gets none back. "access-control-allow-headers": None, } _J = (("Content-Type", "application/json"),) @@ -2019,7 +2019,7 @@ def _not_found(method: str, target: str) -> bytes: ((*_P, ("apollo-require-preflight", "")), "preflight 1, empty"), ) ), - # No body and no length under a JSON type, by method. + # Each method under a JSON type, with no body and no length. *( http_row( m, From fe255a7b4a62c8c64dc650f5a61cde7f776f6557 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:31:09 +0900 Subject: [PATCH 14/52] tests: a bare request with no body carries no length, as the measured ones did --- tests/_helpers.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/_helpers.py b/tests/_helpers.py index 9c5baf5bd..2fe639810 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -303,7 +303,8 @@ def sent_as(client, method: str, path: str, headers, content=None, bare: bool = sequence of pairs, which can name a header twice. The TestClient re-encodes a header's bytes above 0x7F as UTF-8 (`\\x85` reaches the app as `\\xc2\\x85`), where httpx's own `ASGITransport` hands the app what it was given, over the app the fixture started. ``bare`` - leaves out the headers httpx sends unasked, `Accept: */*` among them.""" + leaves out the headers httpx sends unasked, `Accept: */*` among them, and with no ``content`` + the `Content-Length: 0` it puts on a POST.""" import asyncio import httpx @@ -316,7 +317,10 @@ async def send(): async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: if bare: c.headers.clear() - return await c.request(method, path, headers=raw, content=content) + request = c.build_request(method, path, headers=raw, content=content) + if bare and content is None: + request.headers.pop("content-length", None) + return await c.send(request) return asyncio.run(send()) From 8f08947d217eecab638a6d2f7612c221fe6b6d4b Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:06:01 +0900 Subject: [PATCH 15/52] graphql: the ports lose branches no input reaches, and the unit tables gain the rows that tell the rest apart --- backlot/graphql/body.py | 38 ++++++++++---------------------------- tests/test_graphql.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/backlot/graphql/body.py b/backlot/graphql/body.py index c159b5673..420473d3e 100644 --- a/backlot/graphql/body.py +++ b/backlot/graphql/body.py @@ -167,7 +167,7 @@ def _charset(content_type: str | None) -> str: return charset.lower() or "utf-8" -def express_reads_json(content_type: str | None) -> bool: +def express_reads_json(content_type: str) -> bool: """Whether Linear's ``body-parser`` reads a body under ``content_type`` as JSON, which ``type-is`` 2.0.1 decides as ``content-type`` 1.0.5 parses the header: the type before the first ``;``, trimmed, is ``application/json`` in any case, and each parameter after it is a @@ -175,16 +175,13 @@ def express_reads_json(content_type: str | None) -> bool: and reaches Apollo Server as none. api.linear.app answered as these two packages do 66 content types on 2026-10-25 (UTC), where ``content-type`` 2.1.0, which ``type-is`` 2.1.0 asks, reads ``application/json;``, ``application/json; foo`` and six more as JSON that Linear did not.""" - if not content_type: - return False - index = content_type.find(";") - type_ = content_type if index == -1 else content_type[:index] + type_, semicolon, _ = content_type.partition(";") if type_.strip(JS_SPACE).lower() != "application/json": return False - if index != -1: - while (parameter := PARAMETER.match(content_type, index)) is not None: - index = parameter.end() - return index in (-1, len(content_type)) + index = len(type_) + while semicolon and (parameter := PARAMETER.match(content_type, index)) is not None: + index = parameter.end() + return index == len(content_type) # Fastify's `typeNameReg` and `subtypeNameReg`, the latter with no white space to end it once @@ -197,29 +194,16 @@ def fastify_media_type(content_type: str) -> str | None: header, or ``None`` for one it calls invalid: read off ``lib/content-type.js`` in Fastify 5.10.0. The parameters are not read for it: a type is before the first ``;`` or, with none, is the whole value, and splits at its first ``/`` into two tokens, white space allowed before - the first and after the second. An empty value and ``undefined`` are invalid. + the first and after the second. Fastify 5.7.4 to 5.10.0 answered alike each of 661 content types sent to them on 2026-10-25 (UTC), and as api.fireflies.ai did each of its requests replayed to them but Cloudflare's four and a persisted query's, where 5.5.0 refuses ``application/json\\t;``, 5.7.0 serves ``application/json ,`` and 5.11.3 reads the body of a ``QUERY``, which Fireflies answered with its 404 without reading ``{``.""" - if content_type in ("", "undefined"): - return None - index = content_type.find(";") - if index == -1: - index = content_type.find("/") - if index == -1: - return None - type_ = content_type[:index].lstrip(JS_SPACE).lower() - subtype = content_type[index + 1 :].rstrip(JS_SPACE).lower() - else: - media = content_type[:index].lower() - index = media.find("/") - if index == -1: - return None - type_, subtype = media[:index].lstrip(JS_SPACE), media[index + 1 :].rstrip(JS_SPACE) - if not (_FASTIFY_TOKEN.fullmatch(type_) and _FASTIFY_TOKEN.fullmatch(subtype)): + type_, slash, subtype = content_type.partition(";")[0].lower().partition("/") + type_, subtype = type_.lstrip(JS_SPACE), subtype.rstrip(JS_SPACE) + if not (slash and _FASTIFY_TOKEN.fullmatch(type_) and _FASTIFY_TOKEN.fullmatch(subtype)): return None return f"{type_}/{subtype}" @@ -230,8 +214,6 @@ def fastify_media_type(content_type: str) -> str | None: def _fast_decode(text: str) -> str | None: """``fast-decode-uri-component``: ``text`` with each run of ``%XX`` read as UTF-8, or ``None`` where a ``%`` is not followed by two hexadecimal digits or a run is not UTF-8.""" - if "%" not in text: - return text out, last = [], 0 for run in _PERCENT_RUN.finditer(text): if "%" in text[last : run.start()]: diff --git a/tests/test_graphql.py b/tests/test_graphql.py index bd3bc90b5..0320d2878 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -2054,6 +2054,13 @@ def test_a_tool_carries_its_fields_description(tools): ("multipart/mixed; deferSpec=20220824, text/html;q=0.9", None, False), ("multipart/mixed; incrementalSpec=v0.2, text/html;q=0.9", None, False), ("multipart/mixed, text/html;q=0.9", None, False), + ("application/json;q=.5, application/graphql-response+json;q=0.4", apollo_http.JSON, False), + ( + "application/json;q=1e-1, application/graphql-response+json;q=0.4", + apollo_http.GRAPHQL_RESPONSE, + False, + ), + ("application/json;q=+0.1, application/graphql-response+json;q=0.05", apollo_http.JSON, False), ] @@ -2086,6 +2093,7 @@ def test_apollo_server_reads_accept_as_negotiator_does(accept, media_type, html) ("application/x-www-form-urlencoded", "application/x-www-form-urlencoded"), ("*/*", "*/*"), ("text/plain\x0c", None), + ("te!xt/plain", "te!xt/plain"), ] @@ -2142,6 +2150,9 @@ def test_body_parser_reads_json_where_type_is_does(content_type, read): ("app_lication/json", "app_lication/json"), ("application/json@", None), ("text/plain\x0b", "text/plain"), + ("t\xe9xt/plain", None), + ("application; charset=utf-8", None), + ("text/pl\xe1in;x=y", None), ] @@ -2165,6 +2176,9 @@ def test_fastify_reads_a_content_type_as_its_content_type_class_does(content_typ ("x=%F0%9F%98%80", {"x": "\U0001f600"}), ("__proto__=1", {"__proto__": "1"}), ("a=1&b", {"a": "1", "b": ""}), + ("x=a%zz%41", {"x": "a%zz%41"}), + ("%41=1", {"A": "1"}), + ("x=%c3%a9", {"x": "\xe9"}), ] From add05dae0bc9efd65b3629f5099844b18e4fdbc2 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:17:36 +0900 Subject: [PATCH 16/52] graphql: the two Node ports drop the last checks no input reaches --- backlot/graphql/body.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backlot/graphql/body.py b/backlot/graphql/body.py index 420473d3e..e7cf4f021 100644 --- a/backlot/graphql/body.py +++ b/backlot/graphql/body.py @@ -175,11 +175,11 @@ def express_reads_json(content_type: str) -> bool: and reaches Apollo Server as none. api.linear.app answered as these two packages do 66 content types on 2026-10-25 (UTC), where ``content-type`` 2.1.0, which ``type-is`` 2.1.0 asks, reads ``application/json;``, ``application/json; foo`` and six more as JSON that Linear did not.""" - type_, semicolon, _ = content_type.partition(";") + type_ = content_type.partition(";")[0] if type_.strip(JS_SPACE).lower() != "application/json": return False index = len(type_) - while semicolon and (parameter := PARAMETER.match(content_type, index)) is not None: + while (parameter := PARAMETER.match(content_type, index)) is not None: index = parameter.end() return index == len(content_type) @@ -201,9 +201,9 @@ def fastify_media_type(content_type: str) -> str | None: and a persisted query's, where 5.5.0 refuses ``application/json\\t;``, 5.7.0 serves ``application/json ,`` and 5.11.3 reads the body of a ``QUERY``, which Fireflies answered with its 404 without reading ``{``.""" - type_, slash, subtype = content_type.partition(";")[0].lower().partition("/") + type_, _, subtype = content_type.partition(";")[0].lower().partition("/") type_, subtype = type_.lstrip(JS_SPACE), subtype.rstrip(JS_SPACE) - if not (slash and _FASTIFY_TOKEN.fullmatch(type_) and _FASTIFY_TOKEN.fullmatch(subtype)): + if not (_FASTIFY_TOKEN.fullmatch(type_) and _FASTIFY_TOKEN.fullmatch(subtype)): return None return f"{type_}/{subtype}" From eb1ed53ea10de21ad3e8974ac74bb5f00b08916a Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:20:34 +0900 Subject: [PATCH 17/52] tests: the HTTP-layer tables point at the code's dated record rather than dating it again --- tests/test_fireflies.py | 5 +++-- tests/test_graphql.py | 2 +- tests/test_linear.py | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index c694b92dd..a42cd181c 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -1328,8 +1328,9 @@ def _roster_emails(settings): # --- Fastify, @fastify/cors and Apollo Server in front of Fireflies' GraphQL ---------------------- # Each row: a request, its credential (`_FIREFLIES_CREDENTIALS`), and api.fireflies.ai's answer to -# it on 2026-10-25 (UTC), status, media type and body less the newline Apollo Server ends one with: -# `_TYPENAME_DATA` where the query was served, nothing for a `HEAD`. +# it, as `graphql` records the layers it was measured through: status, media type and body less +# the newline Apollo Server ends one with, `_TYPENAME_DATA` where the query was served, nothing for +# a `HEAD`. _CSRF = _apollo( b"This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). Please" b" either specify a 'content-type' header (with a type that is not one of" diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 0320d2878..c6e7d96c5 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -1998,7 +1998,7 @@ def test_a_tool_carries_its_fields_description(tools): # --- the Node libraries `apollo_http` and `body` are read off ------------------------------------- # Each value and what the library behind the port each test names answered for it under Node -# 24.18.0, on 2026-10-25; the ports' docstrings name each library's release. +# 24.18.0; the ports' docstrings name each library's release. _ACCEPTED = [ ("*/*", apollo_http.JSON, False), ("application/*", apollo_http.JSON, False), diff --git a/tests/test_linear.py b/tests/test_linear.py index 3fb789dae..f5d107207 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -5699,9 +5699,10 @@ def got(result): # --- the load balancer, Express, cors and Apollo Server in front of Linear's GraphQL -------------- -# The request, the credential (`_LINEAR_CREDENTIALS`) and the answer api.linear.app gave it on -# 2026-10-25 (UTC): its status, its media type and its body but for the newline Apollo Server ends -# one with, `_TYPENAME_DATA` where it served the query, and none for a `HEAD`. +# The request, the credential (`_LINEAR_CREDENTIALS`) and the answer api.linear.app gave it, as +# `graphql` records the layers it was measured through: its status, its media type and its body but +# for the newline Apollo Server ends one with, `_TYPENAME_DATA` where it served the query, and none +# for a `HEAD`. _CSRF = ( b'{"errors":[{"message":"This operation has been blocked as a potential Cross-Site Request' b" Forgery (CSRF). Please either specify a 'content-type' header (with a type that is not one" From fcf025701de14508a7937c10e899af9f1df5c7d0 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:08:49 +0900 Subject: [PATCH 18/52] linear, fireflies: each path is answered by the layer that routes it at the vendor Linear serves its endpoint at each path Express hands Apollo Server once Linear's front has read it (any case, a slash and more after it, a run of slashes as one, an escaped unreserved character decoded, a backslash as a slash); `/` answers Express's greeting and any other path finalhandler's 404 page, behind the load balancer, cors and the API-key-under-Bearer 400, whose envelope the path as sent picks. Fireflies serves `/graphql` alone; a path nginx passes on gets Fastify's 404 after its body is read, and any other nginx's 404 page. Cloudflare's 400 page answers a path holding `%00` or a broken escape on both, ahead of its method answers. Measured against api.linear.app and api.fireflies.ai on 2026-10-25 (UTC). --- backlot/main.py | 64 ++++++++++++-- backlot/routers/__init__.py | 9 ++ backlot/routers/fireflies.py | 78 +++++++++++++---- backlot/routers/linear.py | 160 ++++++++++++++++++++++++++--------- backlot/telemetry/naming.py | 6 ++ docs/supported-sources.md | 11 ++- tests/test_acl.py | 17 ++-- tests/test_cross_vendor.py | 20 +++-- tests/test_fireflies.py | 134 ++++++++++++++++++++++++++++- tests/test_linear.py | 156 +++++++++++++++++++++++++++++++++- tests/test_telemetry.py | 36 ++++++++ 11 files changed, 611 insertions(+), 80 deletions(-) diff --git a/backlot/main.py b/backlot/main.py index 168271f9e..259394983 100644 --- a/backlot/main.py +++ b/backlot/main.py @@ -43,6 +43,7 @@ notion, oauth, s3, + sent_path, slack, ) from backlot.telemetry import naming, recorder @@ -753,6 +754,32 @@ async def serve_a_slashed_hubspot_path_as_the_path_without_it(request: Request, return await call_next(request) +_GRAPHQL_ENDPOINTS = ("/linear/graphql", "/fireflies/graphql") + + +def _off_a_graphql_endpoint(scope: Scope) -> bool: + path = scope["path"] + return path.startswith(("/linear/", "/fireflies/")) and path not in _GRAPHQL_ENDPOINTS + + +@_middleware(_off_a_graphql_endpoint) +async def route_a_linear_or_fireflies_path_as_its_front_does(request: Request, call_next): + """Route a `/linear` or `/fireflies` path but the endpoint's own to the route that answers it as + the vendor's front does: a `/linear` path Express hands Apollo Server + (``linear.routes_to_apollo``) to ``linear.graphql``, and any other to the vendor's root route + (``linear.express``, ``fireflies.unrouted``), each of which reads the path as sent off the raw + path, which is left alone. Starlette's own routing is not asked: its pattern for the endpoint + takes a path with a newline after it (`/graphql%0A`), and its `path` convertor none with a + newline inside it, where each vendor's front reads both as another path. The path the + telemetry names a call by is then the endpoint's where the endpoint answers.""" + vendor = request.scope["path"].split("/")[1] + served = vendor == "linear" and linear.routes_to_apollo( + linear.express_path(sent_path(request.scope)) + ) + request.scope["path"] = f"/{vendor}/graphql" if served else f"/{vendor}/" + return await call_next(request) + + def _google_route_match(scope: Scope) -> Match: """How the best Google route matches this scope: in full, by its path alone (``PARTIAL``, the router's 405), or not at all. Asked of ``backlot.routers.google.router``, which holds the Google @@ -1282,8 +1309,9 @@ async def refuse_cloudflares_methods_on_linear_and_fireflies(request: Request, c #: The page Cloudflare, in front of Linear and Fireflies, answers a request carrying `Authorization` -#: more than once with: `text/html` with no charset, CRLF line ends, 155 bytes. -_CLOUDFLARE_REPEATED_AUTHORIZATION = ( +#: more than once with, and one whose path breaks an escape: `text/html` with no charset, CRLF line +#: ends, 155 bytes. +_CLOUDFLARE_BAD_REQUEST = ( "\r\n400 Bad Request\r\n\r\n" "

400 Bad Request

\r\n
cloudflare
\r\n" "\r\n\r\n" @@ -1298,16 +1326,40 @@ async def refuse_cloudflares_methods_on_linear_and_fireflies(request: Request, c ) async def refuse_a_repeated_authorization_on_linear_and_fireflies(request: Request, call_next): """Answer a `/linear` or `/fireflies` request that carries `Authorization` more than once with - :data:`_CLOUDFLARE_REPEATED_AUTHORIZATION`'s 400, ahead of the route, the body and the + :data:`_CLOUDFLARE_BAD_REQUEST`'s 400, ahead of the route, the body and the credential. api.linear.app and api.fireflies.ai answered it on 2026-10-19 (UTC) to two copies of a key, a key and `nope` in either order, a key and an empty value, two empty values, three copies and a second header named in lower case; on `POST /graphql` with a query, with no bytes, with `{` and with a body past each vendor's size limit, on `GET /graphql`, on `HEAD /graphql`, which declared the 155 bytes and sent none, and on `POST /nosuch` and `POST /`.""" return Response( - _CLOUDFLARE_REPEATED_AUTHORIZATION.encode(), - status_code=400, - headers={"Content-Type": "text/html"}, + _CLOUDFLARE_BAD_REQUEST.encode(), status_code=400, headers={"Content-Type": "text/html"} + ) + + +#: A `%` two hex digits do not follow. +_BROKEN_ESCAPE = re.compile(rb"%(?![0-9A-Fa-f]{2})") + + +def _a_broken_escape(scope: Scope) -> bool: + raw = scope.get("raw_path") or scope["path"].encode() + return b"%00" in raw or _BROKEN_ESCAPE.search(raw) is not None + + +@_middleware( + lambda scope: ( + _at_or_under(_url_path(scope), ("/linear", "/fireflies")) and _a_broken_escape(scope) + ) +) +async def refuse_a_broken_escape_on_linear_and_fireflies(request: Request, call_next): + """Answer a `/linear` or `/fireflies` request whose path holds `%00`, or a `%` two hex digits do + not follow, with :data:`_CLOUDFLARE_BAD_REQUEST`'s 400, ahead of + :func:`refuse_cloudflares_methods_on_linear_and_fireflies`. api.linear.app and api.fireflies.ai + answered so `/a%zz` and `/a%00b` by each method of :data:`_CLOUDFLARE_METHOD`, and `/a%2`, + `/graphql%zz` and a `%` before `b` by `POST`, on 2026-10-25 (UTC), and passed `?a=%zz`, `?a=%`, + `?a=%2` and `?a=%00` after `/graphql` on to the vendor.""" + return Response( + _CLOUDFLARE_BAD_REQUEST.encode(), status_code=400, headers={"Content-Type": "text/html"} ) diff --git a/backlot/routers/__init__.py b/backlot/routers/__init__.py index d651af919..0f9bb3bd8 100644 --- a/backlot/routers/__init__.py +++ b/backlot/routers/__init__.py @@ -9,6 +9,15 @@ from __future__ import annotations from fastapi import Request +from starlette.types import Scope + + +def sent_path(scope: Scope) -> str: + """The path a client sends the vendor's own host: this request's as it arrived, escapes and + all, with the first segment, which names the vendor here, taken off. A first segment with no + slash after it leaves `/`.""" + raw = (scope.get("raw_path") or scope["path"].encode()).decode("latin-1") + return "/" + raw.partition("/")[2].partition("/")[2] async def json_body(request: Request) -> dict: diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index 48caf70b6..e4dfd9634 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -1,9 +1,10 @@ """Fireflies.ai's GraphQL API, served at ``/fireflies/graphql``. Fireflies is **GraphQL only** — there is no REST surface to emulate — so this router is one -endpoint. The schema and the resolvers live in ``backlot/graphql/`` (``fireflies.graphql`` + -``fireflies_resolvers.py``); everything here is HTTP: a request and credentials in, a GraphQL -envelope out, or its HTTP layer's for a body it cannot read (:func:`_body_refusal`). +endpoint, and what answers at any other path (:func:`unrouted`). The schema and the resolvers live +in ``backlot/graphql/`` (``fireflies.graphql`` + ``fireflies_resolvers.py``); everything here is +HTTP: a request and credentials in, a GraphQL envelope out, or its HTTP layer's for a body it cannot +read (:func:`_body_refusal`). Auth is the bearer path (``Authorization: Bearer ``), which is what the vendor's own quickstart shows for all four of its raw-HTTP examples, read by @@ -19,6 +20,8 @@ from __future__ import annotations +import re +import urllib.parse from typing import Any from fastapi import APIRouter, Request @@ -36,6 +39,7 @@ from backlot.graphql import apollo_http, engine from backlot.graphql import body as request_body from backlot.graphql.fireflies_resolvers import build_engine +from backlot.routers import sent_path router = APIRouter(prefix="/fireflies", tags=["fireflies"]) @@ -112,15 +116,16 @@ def _auth_failed(seen: dict[str, str], message: str) -> Response: _FRIENDLY = {"invalid_arguments": True, "INTERNAL_SERVER_ERROR": False} -# The methods Fastify reads a body for and has no route for at this path: it reads the body, then -# answers its not-found handler's 404 (:func:`_not_found`). -_READ_THEN_NOT_FOUND = ("DELETE", "PATCH", "PUT") +# The methods Fastify reads a body for but `OPTIONS`, which `@fastify/cors` answers first: at a path +# or under a method no route takes, it reads the body, then answers its not-found handler's 404 +# (:func:`_not_found`). +_BODIED = ("DELETE", "PATCH", "POST", "PUT") @router.api_route("/graphql", methods=list(apollo_http.METHODS), include_in_schema=False) async def graphql(request: Request): - """The single Fireflies endpoint, in front of which Fireflies runs Fastify and - ``@fastify/cors``. Not in the OpenAPI document on purpose: this is a GraphQL service, and + """Fireflies' endpoint, the one path Fastify routes, in front of which Fireflies runs nginx + (:func:`nginx_passes`), Fastify and ``@fastify/cors``. Not in the OpenAPI document on purpose: this is a GraphQL service, and describing one POST route that accepts an arbitrary query would tell an OpenAPI→MCP bridge nothing useful (hence no ``SOURCE_PREFIXES`` entry either — see ``backlot/openapi.py``). Introspection is the schema description for this endpoint. @@ -134,7 +139,7 @@ async def graphql(request: Request): if method == "OPTIONS": return _preflight(request) if method not in ("GET", "HEAD", "POST"): - if method in _READ_THEN_NOT_FOUND: + if method in _BODIED: read = await _fastify_read(request, routed=False) if isinstance(read, Response): return read @@ -227,19 +232,58 @@ def _preflight(request: Request) -> Response: def _not_found(request: Request) -> Response: - """Fireflies' 404 for a method its route does not take, the method and the path quoted in it as - a client puts them after ``https://api.fireflies.ai``, the query string as sent. + """Fireflies' 404 for a method or a path no route takes, the method, the path as a client sent + it (:func:`~backlot.routers.sent_path`) and the query string as sent quoted in it. api.fireflies.ai answered so each of the 28 methods of :data:`apollo_http.METHODS` but GET, - HEAD, POST, OPTIONS and Cloudflare's three on 2026-10-25 (UTC), `PUT`, `DELETE` and `PATCH` once - Fastify had read their body (:func:`_fastify_read`), and quoted `/graphql?x=%7B%20y&z&a=b+c` as - sent.""" - # The path as sent, but for the segment that names Fireflies here. - path = "/" + request.scope["raw_path"].decode("latin-1").split("/", 2)[2] + HEAD, POST, OPTIONS and Cloudflare's three at `/graphql` on 2026-10-25 (UTC), `PUT`, `DELETE` + and `PATCH` once Fastify had read their body (:func:`_fastify_read`), and quoted + `/graphql?x=%7B%20y&z&a=b+c` as sent; and each method at each path nginx passes on + (:func:`nginx_passes`) but `/graphql`, quoting `//graphql`, `/graph%71l`'s `PUT` and + `/graphql%0A` as sent.""" query = request.scope["query_string"].decode("latin-1") - message = f"Cannot {request.method} {path}{'?' if query else ''}{query}" + message = f"Cannot {request.method} {sent_path(request.scope)}{'?' if query else ''}{query}" return _body_refusal(message, 404, "not_found") +_NGINX_404 = ( + b"\r\n404 Not Found\r\n\r\n" + b"

404 Not Found

\r\n
nginx
\r\n\r\n\r\n" +) +_NGINX_LOCATION = re.compile(rb"/graphql(/|$)", re.IGNORECASE) + + +def nginx_passes(path: str) -> bool: + """Whether nginx in front of Fastify passes on ``path``, as a client sent it + (:func:`~backlot.routers.sent_path`): once each escape is decoded and each run of slashes is + one, it starts with `/graphql`, in any case, followed by a slash, by nothing or by a newline + that ends it. api.fireflies.ai answered with Fastify's 404 at `/GRAPHQL`, `/graphql/x`, + `//graphql`, `/%2Fgraphql`, `/graphql%2F` and `/graphql%0A`, and with nginx's + (:data:`_NGINX_404`) at `/graphqlx`, `/graphql.json`, `/graphql;x`, `/graphql%20`, + `/graphql%7E`, `/x/graphql` and `/`, on 2026-10-25 (UTC).""" + decoded = re.sub(rb"/{2,}", b"/", urllib.parse.unquote_to_bytes(path)) + return _NGINX_LOCATION.match(decoded) is not None + + +@router.api_route("/", methods=list(apollo_http.METHODS), include_in_schema=False) +async def unrouted(request: Request): + """Fireflies' answer at a path Fastify has no route for, each of which ``backlot.main`` routes + here: :data:`_NGINX_404` where nginx keeps + the request (:func:`nginx_passes`), and Fastify's where it passes it on, ``@fastify/cors``'s to + an `OPTIONS` (:func:`_preflight`) and its not-found handler's 404 (:func:`_not_found`) to any + other method, once it has read a body (:data:`_BODIED`). api.fireflies.ai answered `{`, an + empty body and `a/b/c` at `/graphql/` with Fastify's 400 and 415 and plain text and + `application/graphql` with its 404, on 2026-10-25 (UTC).""" + if not nginx_passes(sent_path(request.scope)): + return Response(_NGINX_404, status_code=404, headers={"content-type": "text/html"}) + if request.method == "OPTIONS": + return _preflight(request) + if request.method in _BODIED: + read = await _fastify_read(request, routed=False) + if isinstance(read, Response): + return read + return _not_found(request) + + _FASTIFY_PARSED = ("application/json", "text/plain", "application/x-www-form-urlencoded") diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 34e2e6fc9..172e9f5f9 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -1,9 +1,10 @@ """Linear's GraphQL API, served at ``/linear/graphql``. Linear is **GraphQL only** — there is no REST surface to emulate — so this router is one -endpoint. The schema and the resolvers live in ``backlot/graphql/`` (``linear.graphql`` + -``linear_resolvers.py``); everything here is HTTP: a request and credentials in, a GraphQL -envelope out, or Express's for the bodies :func:`_express` answers. +endpoint, and what answers at any other path (:func:`express`). The schema and the resolvers live in +``backlot/graphql/`` (``linear.graphql`` + ``linear_resolvers.py``); everything here is HTTP: a +request and credentials in, a GraphQL envelope out, or Express's for the bodies :func:`_express` +answers. Auth is the one place Linear differs from every other source in this repo. A personal API key travels as the **bare** header value (``Authorization: lin_api_…``, no scheme), read by @@ -31,6 +32,8 @@ from __future__ import annotations +import re +import string from typing import Any from fastapi import APIRouter, Request @@ -43,6 +46,7 @@ from backlot.graphql import apollo_http, engine, v8_json from backlot.graphql import body as request_body from backlot.graphql.linear_resolvers import build_engine +from backlot.routers import sent_path router = APIRouter(prefix="/linear", tags=["linear"]) @@ -57,30 +61,31 @@ ENGINE = build_engine() -def _api_key_as_bearer() -> Response: +_API_KEY_AS_BEARER = ( + "It looks like you're trying to use an API key as a Bearer token. Remove the Bearer prefix " + "from the Authorization header." +) + + +def _api_key_as_bearer(path: str) -> Response: """Linear's 400 for an API key under ``Bearer``, which - :func:`backlot.auth.linear_api_key_under_bearer` finds, as api.linear.app sent it on - 2026-10-18.""" - return Response( - request_body.node_json( - { - "errors": [ - { - "message": "It looks like you're trying to use an API key as a Bearer " - "token. Remove the Bearer prefix from the Authorization header.", - "extensions": { - "type": "invalid input", - "code": "INPUT_ERROR", - "statusCode": 400, - "userError": True, - }, - } - ] - } - ), - status_code=400, - media_type=_JSON, - ) + :func:`backlot.auth.linear_api_key_under_bearer` finds, at ``path`` as a client sent it + (:func:`backlot.routers.sent_path`). Where ``path`` starts `/graphql` it is a GraphQL error, as + api.linear.app sent it on 2026-10-18; at any other it is the members of :func:`_express`'s + envelope, as api.linear.app sent it at `/GRAPHQL`, `//graphql`, `/graph%71l`, `/nosuch` and `/` + on 2026-10-25 (UTC), the first three of which it serves as `/graphql`, where `/graphqlx`, + `/graphql;x` and `/graphql%2F` drew the GraphQL error.""" + extensions = { + "type": "invalid input", + "code": "INPUT_ERROR", + "statusCode": 400, + "userError": True, + } + if path.startswith("/graphql"): + body: dict = {"errors": [{"message": _API_KEY_AS_BEARER, "extensions": extensions}]} + else: + body = {"error": _API_KEY_AS_BEARER, "details": {}, "extensions": extensions, "code": 400} + return Response(request_body.node_json(body), status_code=400, media_type=_JSON) def _express(message: str, status: int = 400, length: int | None = None) -> Response: @@ -111,27 +116,38 @@ def _express(message: str, status: int = 400, length: int | None = None) -> Resp ) +def _front(request: Request) -> Response | None: + """The answer of a layer ahead of Express's routing, whatever the path: the load balancer's + (:data:`_LOAD_BALANCER_501`), ``cors``'s (:func:`_preflight`) and :func:`_api_key_as_bearer`, + in that order; or None.""" + method = request.method + if method in _LOAD_BALANCER_501: + return Response(status_code=501) + if method == "OPTIONS" and "origin" in request.headers: + return _preflight(request) + if method != "OPTIONS" and auth.linear_api_key_under_bearer(request): + return _api_key_as_bearer(sent_path(request.scope)) + return None + + @router.api_route("/graphql", methods=list(apollo_http.METHODS), include_in_schema=False) async def graphql(request: Request): - """The single Linear endpoint, in front of which Linear runs Express and ``cors``. Not in the - OpenAPI document on purpose: this is a GraphQL service, and describing one POST route that - accepts an arbitrary query would tell an OpenAPI→MCP bridge nothing useful (hence no - ``SOURCE_PREFIXES`` entry either — see ``backlot/openapi.py``). Introspection is the schema - description for this endpoint. + """Linear's endpoint, in front of which Linear runs Express and ``cors``, at `/graphql` and at + each other path Express hands Apollo Server (:func:`routes_to_apollo`), which + ``backlot.main`` routes here. Not in the OpenAPI document on purpose: this is a GraphQL + service, and describing one POST route that accepts an arbitrary query would tell an + OpenAPI→MCP bridge nothing useful (hence no ``SOURCE_PREFIXES`` entry either — see + ``backlot/openapi.py``). Introspection is the schema description for this endpoint. The layers answer in this order, each measured against api.linear.app on 2026-10-25 (UTC) with - the one after it wrong too: the load balancer (:data:`_LOAD_BALANCER_501`), ``cors`` - (:func:`_preflight`), :func:`_api_key_as_bearer`, ``body-parser`` (:func:`_has_body`, + the one after it wrong too: those of :func:`_front`, ``body-parser`` (:func:`_has_body`, :func:`~backlot.graphql.body.express_reads_json`), Apollo Server (:mod:`backlot.graphql.apollo_http`) up to the choice of operation, then the credential and the run (:func:`_answer`).""" + front = _front(request) + if front is not None: + return front method = request.method - if method in _LOAD_BALANCER_501: - return Response(status_code=501) - if method == "OPTIONS" and "origin" in request.headers: - return _preflight(request) - if method != "OPTIONS" and auth.linear_api_key_under_bearer(request): - return _api_key_as_bearer() payload: Any = None content_type = request_body.joined_header(request.headers.getlist("content-type")) if _has_body(request) and request_body.express_reads_json(content_type): @@ -200,6 +216,72 @@ def _has_body(request: Request) -> bool: return request.method not in ("GET", "HEAD") +_UNRESERVED = frozenset(string.ascii_letters + string.digits + "-._~") +_ESCAPE = re.compile(r"%([0-9A-Fa-f]{2})") +_BARE = frozenset('"<>^`{|}') + + +def express_path(path: str) -> str: + """``path``, as a client sent it (:func:`backlot.routers.sent_path`), as Express reads it behind + Linear's front: a backslash is a slash, an escape of a character RFC 3986 leaves unreserved is + that character, a run of slashes is one, each of ``"<>^`{|}`` sent bare is escaped in upper + case, and any other escape is kept as sent. Express's 404 quoted `/%6Eosuch` as `/nosuch`, + `//nosuch` and `/nosuch//x` as `/nosuch` and `/nosuch/x`, `/a\\b` as `/a/b`, `/a%2fb` and + `/a%3cb` as sent, and each other printable ASCII character but `%`, `?` and `#` as sent, on + 2026-10-25 (UTC).""" + + def one(match: re.Match[str]) -> str: + char = chr(int(match[1], 16)) + return char if char in _UNRESERVED else match[0] + + path = re.sub("/{2,}", "/", _ESCAPE.sub(one, path.replace("\\", "/"))) + return "".join(f"%{ord(char):02X}" if char in _BARE else char for char in path) + + +def routes_to_apollo(path: str) -> bool: + """Whether Express hands ``path`` (:func:`express_path`) to Apollo Server: `/graphql` in any + case, alone or with a slash after it and anything after that. api.linear.app served + `/GRAPHQL`, `/GraphQL/`, `/graphql/`, `/graphql/x` and `/Graphql/X/Y` and answered + `/graphqlx`, `/graphql.json`, `/graphql;x`, `/graphql%20` and `/graphql%2F` with Express's 404 + on 2026-10-25 (UTC).""" + return re.fullmatch(r"/graphql(?:/.*)?", path, re.ASCII | re.IGNORECASE) is not None + + +_GREETING = b"Hello from the Linear GraphQL API." +_HTML = "text/html; charset=utf-8" + + +@router.api_route("/", methods=list(apollo_http.METHODS), include_in_schema=False) +async def express(request: Request): + """Linear's answer at a path Express does not hand Apollo Server (:func:`routes_to_apollo`), + each of which ``backlot.main`` routes here: + after the layers of :func:`_front`, Express's own, as api.linear.app answered on 2026-10-25 + (UTC). At `/` that is :data:`_GREETING` to a `GET` and a `HEAD`, and the methods `/` takes, as + `text/plain`, to an `OPTIONS`; at any other path, and to any other method, it is ``finalhandler``'s + 404 page (:func:`_not_found`). The body is not read: `{` under `application/json` drew the + 404.""" + front = _front(request) + if front is not None: + return front + path = express_path(sent_path(request.scope)) + if path == "/" and request.method in ("GET", "HEAD"): + return Response(_GREETING, media_type=_HTML) + if path == "/" and request.method == "OPTIONS": + return Response(b"GET, HEAD", headers={"content-type": "text/plain"}) + return Response(_not_found(request.method, path), status_code=404, media_type=_HTML) + + +def _not_found(method: str, path: str) -> bytes: + """``finalhandler``'s 404 page, `Cannot` ``method`` ``path`` with `&` and `'` escaped for HTML: + the other characters ``escape-html`` replaces are escaped in ``path`` by then + (:func:`express_path`).""" + message = f"Cannot {method} {path}".replace("&", "&").replace("'", "'") + return ( + '\n\n\n\nError\n' + f"\n\n
{message}
\n\n\n" + ).encode() + + #: The headers ``cors`` writes on each preflight, as api.linear.app answered every one sent with #: `Origin` on 2026-10-25 (UTC), and the edge's ``Cache-Control`` and ``Cache-Tag`` beside them. _PREFLIGHT = ( diff --git a/backlot/telemetry/naming.py b/backlot/telemetry/naming.py index 6d23ce41e..f01b6ca19 100644 --- a/backlot/telemetry/naming.py +++ b/backlot/telemetry/naming.py @@ -164,6 +164,9 @@ def source_of(self, path: str) -> tuple[str, str] | None: self._load() if path in self._graphql_paths: return self._graphql_paths[path], "" + for name in GRAPHQL: + if _under(path, f"/{name}"): + return name, "" if _under(path, "/batch"): # `/batch/{api}/{version}` names its API; the bare `/batch` is Gmail's batch path. if path.count("/") < 2: @@ -191,6 +194,9 @@ def name( return Named("other", "unmatched", UNKNOWN) source, strip = placed if source in self._graphql: + # Any other path under a GraphQL source's mount is one its endpoint is not served at. + if path not in self._graphql_paths: + return Named(source, "unmatched", UNKNOWN) return self._graphql_call(source, graphql_payload) if source == "s3": return self._s3_call(method, path[len(strip) :], query, headers) diff --git a/docs/supported-sources.md b/docs/supported-sources.md index fa1432736..ba7dc493b 100644 --- a/docs/supported-sources.md +++ b/docs/supported-sources.md @@ -90,7 +90,10 @@ what `search` answers a `GET` or `HEAD`, but for a 401 and a refusal of its `Acc **GraphQL only**, one endpoint: a `POST` with a JSON body, or a `GET` with the query in its query string and a JSON `Content-Type` or none. Apollo Server serves a `GET` with none only beside an `apollo-require-preflight` or `x-apollo-operation-name` header, its check that a browser -preflighted the request. Root `Query` fields: +preflighted the request. Fastify serves `/graphql` alone, an escaped letter in it decoded. Any +other path nginx passes on to Fastify, `/graphql` in any case, alone or with a slash and more after +it, once each escape is decoded and each run of slashes is one, gets Fastify's 404, and any other +path nginx's 404 page. Root `Query` fields: | Query field | Notes | |---|---| @@ -560,7 +563,11 @@ operation's 415 carry the account id alone. **GraphQL only**, one endpoint: a `POST` with a JSON body, or a `GET` with the query in its query string. Apollo Server serves a `GET` with no `Content-Type`, or a form or `text/plain` one, only beside an `apollo-require-preflight` or `x-apollo-operation-name` header, its check that a -browser preflighted the request. Root `Query` fields: +browser preflighted the request. Express hands Apollo Server `/graphql` in any case, alone or with +a slash and more after it, once Linear's front has turned a backslash into a slash, folded a run of +slashes into one and decoded an escape of a letter, a digit or one of `-._~`, so each of those +paths is the endpoint too; `/` answers a greeting, and any other path Express's 404 page. Root +`Query` fields: | Query field | Notes | |---|---| diff --git a/tests/test_acl.py b/tests/test_acl.py index b8eafde53..a916a4d4f 100644 --- a/tests/test_acl.py +++ b/tests/test_acl.py @@ -8,9 +8,10 @@ from backlot import store from tests._helpers import client_for, gql -# The two ways but a POST of JSON a document reaches each GraphQL server's resolvers: a GET with it -# in the query string, and on Fireflies a POST of a form, each sent with Apollo Server's preflight -# header (`backlot.graphql.apollo_http.csrf_refusal`). +# The ways but a POST of JSON to `/graphql` a document reaches each GraphQL server's resolvers: a +# GET with it in the query string, and on Fireflies a POST of a form, each sent with Apollo Server's +# preflight header (`backlot.graphql.apollo_http.csrf_refusal`); and on Linear a POST to another +# path Express hands Apollo Server (`backlot.routers.linear.routes_to_apollo`). _ADMIN = "admin-service-token" @@ -186,14 +187,18 @@ def test_linear_team_counts_are_acl_scoped(db, acl, tokens): def _gql(client, query, token, via="POST"): - return _sent(client, "/linear/graphql", query, token, via).json() + """``query`` sent as ``via`` names it, a method and a path after `/linear` where not + `/graphql`.""" + method, _, path = via.partition(" ") + return _sent(client, f"/linear{path or '/graphql'}", query, token, method).json() -@pytest.mark.parametrize("via", ["POST", "GET"]) +@pytest.mark.parametrize("via", ["POST", "GET", "POST /GRAPHQL/"]) def test_linear_by_id_roots_do_not_leak_entities_off_hidden_issues(sample_settings, tokens, via): """`blackops` is granted to hana only, so its "Triage" state, in no other team, must fail for ava exactly as an absent id would — and must still work for hana and the admin, proving the id - is real and the difference is the ACL, whichever way the document is sent.""" + is real and the difference is the ACL, whichever way and to whichever path the document is + sent.""" from backlot import synth with client_for(sample_settings) as client: diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index 339e8a3df..31f6c79ec 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -339,10 +339,11 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( # A request to Linear or Fireflies with `Authorization` sent more than once, `{key}` standing for -# the vendor's own spelling of the admin token, each answered with the 400 page -# `backlot.main.refuse_a_repeated_authorization_on_linear_and_fireflies` records. The last three -# rows are the control: one header reaches the vendor, and a path that only starts with the -# vendor's name is not the vendor's. +# the vendor's own spelling of the admin token, or with a path that breaks an escape, each answered +# with the 400 page `backlot.main.refuse_a_repeated_authorization_on_linear_and_fireflies` and +# `refuse_a_broken_escape_on_linear_and_fireflies` record. The last four rows are the control: one +# header reaches the vendor, a path that only starts with the vendor's name is not the vendor's, +# and a broken escape in the query string is not one in the path. _TYPENAME = b'{"query":"{ __typename }"}' _CLOUDFLARE = 400 _REPEATED = [ @@ -359,14 +360,23 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( pytest.param("HEAD", "/graphql", b"", ("{key}", "nope"), _CLOUDFLARE, id="HEAD"), pytest.param("POST", "/nosuch", _TYPENAME, ("{key}", "nope"), _CLOUDFLARE, id="another path"), pytest.param("POST", "", _TYPENAME, ("{key}", "nope"), _CLOUDFLARE, id="the root"), + pytest.param("POST", "/a%2", _TYPENAME, ("{key}",), _CLOUDFLARE, id="%2 at the end"), + pytest.param("POST", "/graphql%zz", _TYPENAME, (), _CLOUDFLARE, id="%zz"), + pytest.param("POST", "/nosucha%b", b"{}", (), _CLOUDFLARE, id="% before b"), + *( + pytest.param(m, p, b"", (), _CLOUDFLARE, id=f"{m} {p}") + for m in ("TRACE", "CONNECT", "PURGE") + for p in ("/a%zz", "/a%00b") + ), pytest.param("POST", "/graphql", _TYPENAME, ("{key}",), 200, id="one key"), pytest.param("POST", "x/graphql", _TYPENAME, ("{key}", "nope"), 404, id="a longer name"), + pytest.param("POST", "/graphql?a=%zz", _TYPENAME, ("{key}",), 200, id="%zz in the query"), ] @pytest.mark.parametrize("vendor", ["linear", "fireflies"]) @pytest.mark.parametrize("method, path, body, values, status", _REPEATED) -def test_cloudflare_refuses_a_repeated_authorization( +def test_cloudflare_refuses_a_repeated_authorization_and_a_broken_escape( client, tokens_yaml, vendor, method, path, body, values, status ): admin = tokens_yaml["admin_token"] diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index a42cd181c..858b0bcef 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -1370,12 +1370,18 @@ def _roster_emails(settings): _Q = "?query=%7B%20__typename%20%7D" +_NGINX_404 = ( + b"\r\n404 Not Found\r\n\r\n

404 Not Found

" + b"
\r\n
nginx
\r\n\r\n\r\n" +) _PAST_THE_LIMIT = b"x" * (2**20 + 10) _LANDING_PAGE = object() # Apollo Server's page for a browser, `landing_page`'s def _not_found(method: str, target: str) -> bytes: - message = b"Cannot %s /graphql%s" % (method.encode(), target.encode()) + """Fastify's 404, quoting ``target`` as the test sends it.""" + path = target if target.startswith("/") else f"/graphql{target}" + message = b"Cannot %s %s" % (method.encode(), path.encode()) return ( b'{"statusCode":404,"status":false,"code":"not_found","message":"%s","errorMessage":"%s"}' % (message, message) @@ -2119,6 +2125,126 @@ def _not_found(method: str, target: str) -> bytes: ("PUT", "", _J, _TYPENAME, 404, _not_found("PUT", "")), ) ), + # A path nginx keeps (`nginx_passes`), one it passes on to Fastify, which routes `/graphql` + # alone once it has decoded an escape, and Fastify's answers there (`unrouted`). + *( + http_row(m, p, h, b, c, 404, "text/html", _NGINX_404, id=f"{m} {p}, {c}") + for m, p, h, b, c in ( + ("POST", "/graphqlx", _J, _TYPENAME, "key"), + ("POST", "/graphql.json", _J, _TYPENAME, "key"), + ("POST", "/graphql;x", _J, _TYPENAME, "key"), + ("POST", "/graphql%20", _J, _TYPENAME, "key"), + ("POST", "/x/graphql", _J, _TYPENAME, "key"), + ("POST", "/nosuch", _J, _TYPENAME, "key"), + ("POST", "/", _J, _TYPENAME, "key"), + ("POST", "/a%3Cb%3E", _J, _TYPENAME, "key"), + ("POST", "/%C3%A9", _J, _TYPENAME, "key"), + ("POST", "/graphql%3B", _J, _TYPENAME, "none"), + ("POST", "/graphql%7E", _J, _TYPENAME, "none"), + ("POST", "/graph%FFl", _J, _TYPENAME, "none"), + ("POST", "/graphql\\", _J, _TYPENAME, "none"), + ("POST", "/nosuch", _J, b"{", "none"), + ("GET", "/nosuch" + _Q, _P, None, "key"), + ("OPTIONS", "/nosuch", _PREFLIGHT_ASKED, None, "none"), + ) + ), + http_row("HEAD", "/nosuch" + _Q, _P, None, "key", 404, "text/html", b"", id="HEAD /nosuch"), + *( + http_row(m, p, h, b, c, 404, _JSON_UTF8, _not_found(m, p), id=f"{m} {p}, {c}") + for m, p, h, b, c in ( + ("POST", "/graphql/", _J, _TYPENAME, "key"), + ("POST", "/graphql/", _J, _TYPENAME, "Bearer nope"), + ("POST", "/GRAPHQL", _J, _TYPENAME, "key"), + ("POST", "/GraphQL", _J, _TYPENAME, "key"), + ("POST", "/GRAPHQL/", _J, _TYPENAME, "key"), + ("POST", "/graphql//", _J, _TYPENAME, "key"), + ("POST", "//graphql", _J, _TYPENAME, "key"), + ("POST", "/graphql/x", _J, _TYPENAME, "key"), + ("POST", "/graphql%2F", _J, _TYPENAME, "key"), + ("POST", "/graphql%2f", _J, _TYPENAME, "none"), + ("POST", "/%2Fgraphql", _J, _TYPENAME, "none"), + ("POST", "/graphql%0A", _J, _TYPENAME, "none"), + ("POST", "/graphql/%C3%A9", _J, _TYPENAME, "none"), + ("POST", "/graphql/%25", _J, _TYPENAME, "none"), + ("POST", "/graphql/", (("Content-Type", "application/graphql"),), b"{ x }", "none"), + ("POST", "/graphql/", (), b"x", "none"), + ("POST", "/graphql/", (("Content-Type", "text/plain"),), b"x", "none"), + ("PUT", "/graphql/", (("Content-Type", "application/graphql"),), b"{ x }", "none"), + ("GET", "/graphql/" + _Q, _P, None, "key"), + ("GET", "/graphql/", (("Accept", "text/html"),), None, "none"), + ("PUT", "/graph%71l", _J, _TYPENAME, "key"), + ("PROPFIND", "/graph%71l", (), None, "key"), + ("BIND", "/graphql/", (), None, "key"), + ) + ), + *( + http_row(m, "/graphql/", _J, b, "none", s, _JSON_UTF8, a, id=f"{m} /graphql/, {b!r}") + for m, b, s, a in ( + ("POST", b"{", 400, _NOT_JSON), + ("POST", b"", 400, _EMPTY), + ("PUT", b"{", 400, _NOT_JSON), + ) + ), + http_row( + "POST", + "/graphql/", + (("Content-Type", "a/b/c"),), + b"x", + "none", + 415, + _JSON_UTF8, + _UNSUPPORTED, + id="POST /graphql/, a/b/c", + ), + http_row("HEAD", "/graphql/" + _Q, _P, None, "key", 404, _JSON_UTF8, b"", id="HEAD /graphql/"), + http_row( + "OPTIONS", + "/graphql/", + _PREFLIGHT_ASKED, + None, + "key", + 204, + None, + b"", + id="preflight at /graphql/", + answered=_FIREFLIES_PREFLIGHT, + ), + http_row( + "OPTIONS", + "/graphql/", + (), + None, + "key", + 400, + "text/plain", + b"Invalid Preflight Request", + id="OPTIONS /graphql/", + ), + http_row( + "POST", + "/graph%71l", + _J, + _TYPENAME, + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="POST /graph%71l", + ), + http_row( + "GET", + "/%67raphql" + _Q, + _P, + None, + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="GET /%67raphql", + ), + http_row( + "POST", "/graphq%6c", _J, _TYPENAME, "none", 500, _JSON_UTF8, _BAD_KEY, id="POST /graphq%6c" + ), ] _FIREFLIES_CREDENTIALS = { "none": None, @@ -2132,7 +2258,7 @@ def _not_found(method: str, target: str) -> bytes: "method,target,headers,body,credential,status,media_type,answer,expected_headers", _FIREFLIES_HTTP, ) -def test_fireflies_answers_each_method_type_and_accept_as_fireflies_does( +def test_fireflies_answers_each_method_path_type_and_accept_as_fireflies_does( client, tokens_yaml, method, @@ -2151,7 +2277,9 @@ def test_fireflies_answers_each_method_type_and_accept_as_fireflies_does( value = _FIREFLIES_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) sent += [("Authorization", value)] * (2 if credential == "twice" else 1) content = body.chunks() if isinstance(body, Chunked) else body - r = sent_as(client, method, f"/fireflies/graphql{target}", sent, content=content, bare=True) + # A target that starts with a slash is a path; any other follows `/graphql`. + path = target if target.startswith("/") else f"/graphql{target}" + r = sent_as(client, method, f"/fireflies{path}", sent, content=content, bare=True) assert r.status_code == status assert r.headers.get("content-type") == media_type if answer is _LANDING_PAGE: diff --git a/tests/test_linear.py b/tests/test_linear.py index f5d107207..672879162 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -5755,6 +5755,21 @@ def got(result): _ORIGIN = (("Origin", "https://example.com"),) _PREFLIGHT_ASKED = (*_ORIGIN, ("Access-Control-Request-Method", "POST")) _Q = "?query=%7B%20__typename%20%7D" +_HTML_UTF8 = "text/html; charset=utf-8" +_GREETING = b"Hello from the Linear GraphQL API." +_EXPRESS_API_KEY_AS_BEARER = ( + b'{"error":"It looks like you\'re trying to use an API key as a Bearer token. Remove the Bearer' + b' prefix from the Authorization header.","details":{},"extensions":{"type":"invalid input","co' + b'de":"INPUT_ERROR","statusCode":400,"userError":true},"code":400}' +) + + +def _express_404(method: str, path: str) -> bytes: + return ( + b'\n\n\n\nError' + b"\n\n\n
Cannot %s %s
\n\n\n" + % (method.encode(), path.encode()) + ) def _search_refusal(message: bytes) -> bytes: @@ -6442,6 +6457,132 @@ def _search_refusal(message: bytes) -> bytes: b'"http":{"status":400}}}]}', id="GET, variables {}, operationName empty", ), + # A path Express hands Apollo Server (`routes_to_apollo`) once Linear's front has read it + # (`express_path`), and Express's own answers at any other (`express`), behind the load + # balancer, `cors` and `_api_key_as_bearer`, whose envelope the path as sent picks. + *( + http_row("POST", p, _J, _TYPENAME, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id=f"POST {p}") + for p in ( + "/GRAPHQL", + "/GraphQL", + "/GRAPHQL/", + "/graphql/", + "//graphql", + "/graphql//", + "/graphql/x", + "/graph%71l", + ) + ), + http_row( + "POST", + "/%67RAPHQL", + _J, + _TYPENAME, + "none", + 401, + _JSON_UTF8, + _UNAUTHENTICATED, + id="POST /%67RAPHQL, no credential", + ), + *( + http_row( + "POST", p, _J, _TYPENAME, "none", 401, _JSON_UTF8, _UNAUTHENTICATED, id=f"POST {p}" + ) + for p in ("/graphql\\x", "/Graphql/X/Y") + ), + http_row( + "GET", "/GRAPHQL" + _Q, _P, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET /GRAPHQL" + ), + http_row( + "PUT", "/GRAPHQL", _J, _TYPENAME, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id="PUT /GRAPHQL" + ), + *( + http_row( + "POST", p, _J, _TYPENAME, "Bearer key", 400, _JSON_UTF8, a, id=f"POST {p}, Bearer key" + ) + for p, a in ( + ("/graphqlx", _API_KEY_AS_BEARER), + ("/graphql%2F", _API_KEY_AS_BEARER), + ("/GRAPHQL", _EXPRESS_API_KEY_AS_BEARER), + ("//graphql", _EXPRESS_API_KEY_AS_BEARER), + ("/graph%71l", _EXPRESS_API_KEY_AS_BEARER), + ("/nosuch", _EXPRESS_API_KEY_AS_BEARER), + ) + ), + http_row( + "GET", + "/", + (), + None, + "Bearer key", + 400, + _JSON_UTF8, + _EXPRESS_API_KEY_AS_BEARER, + id="GET /, Bearer key", + ), + *( + http_row(m, p, h, b, c, 404, _HTML_UTF8, _express_404(m, echo), id=f"{m} {p}, {c}") + for m, p, h, b, c, echo in ( + ("POST", "/graphqlx", _J, _TYPENAME, "key", "/graphqlx"), + ("POST", "/graphql.json", _J, _TYPENAME, "key", "/graphql.json"), + ("POST", "/graphql;x", _J, _TYPENAME, "key", "/graphql;x"), + ("POST", "/graphql%2F", _J, _TYPENAME, "key", "/graphql%2F"), + ("POST", "/graphql%20", _J, _TYPENAME, "key", "/graphql%20"), + ("POST", "/GRAPHQL%2F", _J, _TYPENAME, "none", "/GRAPHQL%2F"), + ("POST", "/graphql%0A", _J, _TYPENAME, "none", "/graphql%0A"), + ("POST", "/x/graphql", _J, _TYPENAME, "key", "/x/graphql"), + ("POST", "/nosuch", _J, _TYPENAME, "key", "/nosuch"), + ("POST", "/nosuch", _J, b"{", "none", "/nosuch"), + ("POST", "//nosuch", _J, _TYPENAME, "none", "/nosuch"), + ("POST", "/nosuch//x", _J, _TYPENAME, "none", "/nosuch/x"), + ("POST", "/%6Eosuch", _J, _TYPENAME, "none", "/nosuch"), + ("POST", "/a%41b", _J, _TYPENAME, "none", "/aAb"), + ("POST", "/a%7eb", _J, _TYPENAME, "none", "/a~b"), + ("POST", "/a%2fb", _J, _TYPENAME, "none", "/a%2fb"), + ("POST", "/a%3cb", _J, _TYPENAME, "none", "/a%3cb"), + ("POST", "/a%0Ab", _J, _TYPENAME, "none", "/a%0Ab"), + ("POST", "/a\\b", _J, _TYPENAME, "none", "/a/b"), + ("POST", "/a%3Cb%3E", _J, _TYPENAME, "key", "/a%3Cb%3E"), + ("POST", "/%C3%A9", _J, _TYPENAME, "key", "/%C3%A9"), + ("POST", "/nosucha|b", _J, b"{}", "none", "/nosucha%7Cb"), + ("POST", "/nosucha^b", _J, b"{}", "none", "/nosucha%5Eb"), + ("POST", "/nosucha'b", _J, b"{}", "none", "/nosucha'b"), + ("POST", "/nosucha&b", _J, b"{}", "none", "/nosucha&b"), + ("POST", "/nosucha[b", _J, b"{}", "none", "/nosucha[b"), + ("GET", "/nosuch", (("Accept", "application/json"),), None, "none", "/nosuch"), + ("PUT", "/nosuch", _J, _TYPENAME, "key", "/nosuch"), + ("PROPFIND", "/nosuch", (), None, "key", "/nosuch"), + ("OPTIONS", "/nosuch", (), None, "Bearer key", "/nosuch"), + ("POST", "/", _J, _TYPENAME, "key", "/"), + ("DELETE", "/", (), None, "none", "/"), + ("PROPFIND", "/", (), None, "none", "/"), + ("GET", "/%2F", (), None, "none", "/%2F"), + ("GET", "/index.html", (), None, "none", "/index.html"), + ) + ), + http_row("HEAD", "/nosuch", (), None, "none", 404, _HTML_UTF8, b"", id="HEAD /nosuch"), + http_row("BIND", "/nosuch", (), None, "key", 501, None, b"", id="BIND /nosuch"), + http_row( + "OPTIONS", + "/nosuch", + _PREFLIGHT_ASKED, + None, + "key", + 204, + None, + b"", + id="preflight at /nosuch", + answered=_LINEAR_PREFLIGHT, + ), + *( + http_row("GET", p, (), None, c, 200, _HTML_UTF8, _GREETING, id=f"GET {p}, {c}") + for p, c in (("/", "none"), ("/" + _Q, "key"), ("//", "none"), ("/?x", "none")) + ), + http_row("HEAD", "/", (), None, "none", 200, _HTML_UTF8, b"", id="HEAD /"), + *( + http_row("OPTIONS", "/", (), None, c, 200, "text/plain", b"GET, HEAD", id=f"OPTIONS /, {c}") + for c in ("none", "Bearer key") + ), ] _LINEAR_CREDENTIALS = { "none": None, @@ -6455,7 +6596,7 @@ def _search_refusal(message: bytes) -> bytes: @pytest.mark.parametrize( "method,target,headers,body,credential,status,media_type,answer,expected_headers", _LINEAR_HTTP ) -def test_linear_answers_each_method_type_and_accept_as_linear_does( +def test_linear_answers_each_method_path_type_and_accept_as_linear_does( client, tokens_yaml, method, @@ -6474,8 +6615,19 @@ def test_linear_answers_each_method_type_and_accept_as_linear_does( value = _LINEAR_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) sent += [("Authorization", value)] * (2 if credential == "twice" else 1) content = body.chunks() if isinstance(body, Chunked) else body - r = sent_as(client, method, f"/linear/graphql{target}", sent, content=content, bare=True) + # A target that starts with a slash is a path; any other follows `/graphql`. + path = target if target.startswith("/") else f"/graphql{target}" + r = sent_as(client, method, f"/linear{path}", sent, content=content, bare=True) assert r.status_code == status assert r.headers.get("content-type") == media_type assert r.content == answer assert {name: r.headers.get(name) for name in expected_headers} == expected_headers + + +@pytest.mark.parametrize("char", '"<>`{}') +def test_linears_front_escapes_a_character_httpx_escapes_first(char): + """The characters :func:`backlot.routers.linear.express_path` escapes that httpx escapes before + it sends a path, so `_LINEAR_HTTP` cannot send them bare; it sends the other two, `|` and `^`.""" + from backlot.routers import linear + + assert linear.express_path(f"/nosucha{char}b") == f"/nosucha%{ord(char):02X}b" diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index e27ae7075..093c8314d 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -635,6 +635,42 @@ def test_an_error_code_is_the_envelopes_own_identifier(source, status, body, exp {"headers": LINEAR}, ("linear", "unparsed", "unknown", 405, None, "client"), ), + ( + "POST", + "/linear/GRAPHQL/", + {"headers": LINEAR | JSON, "json": {"query": "{ __typename }"}}, + ("linear", "query.__typename", "implemented", 200, None, None), + ), + ( + "POST", + "/linear/nosuch", + {"headers": LINEAR | JSON, "json": {"query": "{ __typename }"}}, + ("linear", "unmatched", "unknown", 404, None, "client"), + ), + ( + "GET", + "/linear/", + {"headers": LINEAR}, + ("linear", "unmatched", "unknown", 200, None, None), + ), + ( + "POST", + "/linear/a%zz", + {"headers": LINEAR | JSON, "json": {"query": "{ __typename }"}}, + ("linear", "unmatched", "unknown", 400, None, "client"), + ), + ( + "POST", + "/fireflies/graphql/", + {"headers": JSON, "json": {"query": "{ __typename }"}}, + ("fireflies", "unmatched", "unknown", 404, "not_found", "client"), + ), + ( + "POST", + "/fireflies/nosuch", + {"headers": JSON, "json": {"query": "{ __typename }"}}, + ("fireflies", "unmatched", "unknown", 404, None, "client"), + ), ( "GET", "/calendar/v3/calendars/primary/events", From 07786f6119a7a842f9a0ded9d194e3634ff01cbe Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:14:38 +0900 Subject: [PATCH 19/52] linear, fireflies: one home for the target convention and the body read off the endpoint, and docstrings narrowed to what was measured --- backlot/routers/fireflies.py | 28 ++++++++++++++-------------- backlot/routers/linear.py | 15 +++++++-------- backlot/telemetry/naming.py | 3 ++- tests/_helpers.py | 3 ++- tests/test_cross_vendor.py | 2 +- tests/test_fireflies.py | 3 +-- tests/test_linear.py | 6 +++--- 7 files changed, 30 insertions(+), 30 deletions(-) diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index e4dfd9634..bd932ebfd 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -125,10 +125,10 @@ def _auth_failed(seen: dict[str, str], message: str) -> Response: @router.api_route("/graphql", methods=list(apollo_http.METHODS), include_in_schema=False) async def graphql(request: Request): """Fireflies' endpoint, the one path Fastify routes, in front of which Fireflies runs nginx - (:func:`nginx_passes`), Fastify and ``@fastify/cors``. Not in the OpenAPI document on purpose: this is a GraphQL service, and - describing one POST route that accepts an arbitrary query would tell an OpenAPI→MCP bridge - nothing useful (hence no ``SOURCE_PREFIXES`` entry either — see ``backlot/openapi.py``). - Introspection is the schema description for this endpoint. + (:func:`nginx_passes`), Fastify and ``@fastify/cors``. Not in the OpenAPI document on purpose: + this is a GraphQL service, and describing one POST route that accepts an arbitrary query would + tell an OpenAPI→MCP bridge nothing useful (hence no ``SOURCE_PREFIXES`` entry either — see + ``backlot/openapi.py``). Introspection is the schema description for this endpoint. The layers answer in this order, each measured against api.fireflies.ai on 2026-10-25 (UTC) with the one after it wrong too: ``@fastify/cors`` (:func:`_preflight`), Fastify @@ -237,9 +237,9 @@ def _not_found(request: Request) -> Response: api.fireflies.ai answered so each of the 28 methods of :data:`apollo_http.METHODS` but GET, HEAD, POST, OPTIONS and Cloudflare's three at `/graphql` on 2026-10-25 (UTC), `PUT`, `DELETE` and `PATCH` once Fastify had read their body (:func:`_fastify_read`), and quoted - `/graphql?x=%7B%20y&z&a=b+c` as sent; and each method at each path nginx passes on - (:func:`nginx_passes`) but `/graphql`, quoting `//graphql`, `/graph%71l`'s `PUT` and - `/graphql%0A` as sent.""" + `/graphql?x=%7B%20y&z&a=b+c` as sent; and `POST`, `GET`, `HEAD`, `PUT`, `PROPFIND` and `BIND` + at `/GRAPHQL`, `/graphql/x`, `//graphql` and `/graphql%2F`, `POST` at `/%2Fgraphql` and + `/graphql%0A`, and `PUT` at `/graph%71l`, quoting each path as sent.""" query = request.scope["query_string"].decode("latin-1") message = f"Cannot {request.method} {sent_path(request.scope)}{'?' if query else ''}{query}" return _body_refusal(message, 404, "not_found") @@ -267,12 +267,10 @@ def nginx_passes(path: str) -> bool: @router.api_route("/", methods=list(apollo_http.METHODS), include_in_schema=False) async def unrouted(request: Request): """Fireflies' answer at a path Fastify has no route for, each of which ``backlot.main`` routes - here: :data:`_NGINX_404` where nginx keeps - the request (:func:`nginx_passes`), and Fastify's where it passes it on, ``@fastify/cors``'s to - an `OPTIONS` (:func:`_preflight`) and its not-found handler's 404 (:func:`_not_found`) to any - other method, once it has read a body (:data:`_BODIED`). api.fireflies.ai answered `{`, an - empty body and `a/b/c` at `/graphql/` with Fastify's 400 and 415 and plain text and - `application/graphql` with its 404, on 2026-10-25 (UTC).""" + here: :data:`_NGINX_404` where nginx keeps the request (:func:`nginx_passes`), and Fastify's + where it passes it on, :func:`_preflight` to an `OPTIONS` and its not-found handler's 404 + (:func:`_not_found`) to any other method, after reading the body of one of :data:`_BODIED` + (:func:`_fastify_read`).""" if not nginx_passes(sent_path(request.scope)): return Response(_NGINX_404, status_code=404, headers={"content-type": "text/html"}) if request.method == "OPTIONS": @@ -300,7 +298,9 @@ async def _fastify_read(request: Request, routed: bool) -> Any: api.fireflies.ai answered so each body sent on 2026-10-25 (UTC) to `POST`, `PUT`, `DELETE` and `PATCH`: no type with a body was the 415 by `POST` and the 404 by `PUT`, `a/b/c` and an empty type the 415 by both, `application/graphql` the 415 by `POST`, past the limit too, and - the 404 by `PUT`, and a form or plain text past the limit the 413 by both.""" + the 404 by `PUT`, and a form or plain text past the limit the 413 by both. A `POST` to + `/graphql/`, which no route takes, was read as a `PUT` to `/graphql` is: `{` and an empty body + drew the 400s, `a/b/c` the 415, and plain text, `application/graphql` and no type the 404.""" content_type = request.headers.get("content-type") if content_type is None: length = request.headers.get("content-length") diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 172e9f5f9..73487ec96 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -224,8 +224,8 @@ def _has_body(request: Request) -> bool: def express_path(path: str) -> str: """``path``, as a client sent it (:func:`backlot.routers.sent_path`), as Express reads it behind Linear's front: a backslash is a slash, an escape of a character RFC 3986 leaves unreserved is - that character, a run of slashes is one, each of ``"<>^`{|}`` sent bare is escaped in upper - case, and any other escape is kept as sent. Express's 404 quoted `/%6Eosuch` as `/nosuch`, + that character, a run of slashes is one, each character of :data:`_BARE` sent bare is escaped in + upper case, and any other escape is kept as sent. Express's 404 quoted `/%6Eosuch` as `/nosuch`, `//nosuch` and `/nosuch//x` as `/nosuch` and `/nosuch/x`, `/a\\b` as `/a/b`, `/a%2fb` and `/a%3cb` as sent, and each other printable ASCII character but `%`, `?` and `#` as sent, on 2026-10-25 (UTC).""" @@ -254,12 +254,11 @@ def routes_to_apollo(path: str) -> bool: @router.api_route("/", methods=list(apollo_http.METHODS), include_in_schema=False) async def express(request: Request): """Linear's answer at a path Express does not hand Apollo Server (:func:`routes_to_apollo`), - each of which ``backlot.main`` routes here: - after the layers of :func:`_front`, Express's own, as api.linear.app answered on 2026-10-25 - (UTC). At `/` that is :data:`_GREETING` to a `GET` and a `HEAD`, and the methods `/` takes, as - `text/plain`, to an `OPTIONS`; at any other path, and to any other method, it is ``finalhandler``'s - 404 page (:func:`_not_found`). The body is not read: `{` under `application/json` drew the - 404.""" + each of which ``backlot.main`` routes here: after the layers of :func:`_front`, Express's own, + as api.linear.app answered on 2026-10-25 (UTC). At `/` that is :data:`_GREETING` to a `GET` and + a `HEAD`, and the methods `/` takes, as `text/plain`, to an `OPTIONS`; at any other path, and to + any other method, it is ``finalhandler``'s 404 page (:func:`_not_found`). The body is not read: + `{` under `application/json` drew the 404.""" front = _front(request) if front is not None: return front diff --git a/backlot/telemetry/naming.py b/backlot/telemetry/naming.py index f01b6ca19..a575a55ad 100644 --- a/backlot/telemetry/naming.py +++ b/backlot/telemetry/naming.py @@ -194,7 +194,8 @@ def name( return Named("other", "unmatched", UNKNOWN) source, strip = placed if source in self._graphql: - # Any other path under a GraphQL source's mount is one its endpoint is not served at. + # Any other path under a GraphQL source's mount was answered off its endpoint, as + # `backlot.main` routes each path the endpoint is served at to the endpoint's own. if path not in self._graphql_paths: return Named(source, "unmatched", UNKNOWN) return self._graphql_call(source, graphql_payload) diff --git a/tests/_helpers.py b/tests/_helpers.py index 2fe639810..7c5042e7d 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -334,7 +334,8 @@ async def chunks(self): def http_row(method, target, sent, body, credential, status, media_type, answer, id, answered=None): """A row of an HTTP layer's table: the request, the credential, and the answer, ``answered`` - naming headers on it.""" + naming headers on it. ``target`` is a path after the vendor's prefix where it starts with a + slash, and what follows the endpoint's path where it does not.""" return pytest.param( method, target, sent, body, credential, status, media_type, answer, answered or {}, id=id ) diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index 31f6c79ec..d2ba81f79 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -341,7 +341,7 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( # A request to Linear or Fireflies with `Authorization` sent more than once, `{key}` standing for # the vendor's own spelling of the admin token, or with a path that breaks an escape, each answered # with the 400 page `backlot.main.refuse_a_repeated_authorization_on_linear_and_fireflies` and -# `refuse_a_broken_escape_on_linear_and_fireflies` record. The last four rows are the control: one +# `refuse_a_broken_escape_on_linear_and_fireflies` record. The last three rows are the control: one # header reaches the vendor, a path that only starts with the vendor's name is not the vendor's, # and a broken escape in the query string is not one in the path. _TYPENAME = b'{"query":"{ __typename }"}' diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index 858b0bcef..8335e8a71 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -1379,7 +1379,7 @@ def _roster_emails(settings): def _not_found(method: str, target: str) -> bytes: - """Fastify's 404, quoting ``target`` as the test sends it.""" + """Fastify's 404, quoting ``target`` as the test sends it (:func:`tests._helpers.http_row`).""" path = target if target.startswith("/") else f"/graphql{target}" message = b"Cannot %s %s" % (method.encode(), path.encode()) return ( @@ -2277,7 +2277,6 @@ def test_fireflies_answers_each_method_path_type_and_accept_as_fireflies_does( value = _FIREFLIES_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) sent += [("Authorization", value)] * (2 if credential == "twice" else 1) content = body.chunks() if isinstance(body, Chunked) else body - # A target that starts with a slash is a path; any other follows `/graphql`. path = target if target.startswith("/") else f"/graphql{target}" r = sent_as(client, method, f"/fireflies{path}", sent, content=content, bare=True) assert r.status_code == status diff --git a/tests/test_linear.py b/tests/test_linear.py index 672879162..0f9f7287c 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -6615,7 +6615,6 @@ def test_linear_answers_each_method_path_type_and_accept_as_linear_does( value = _LINEAR_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) sent += [("Authorization", value)] * (2 if credential == "twice" else 1) content = body.chunks() if isinstance(body, Chunked) else body - # A target that starts with a slash is a path; any other follows `/graphql`. path = target if target.startswith("/") else f"/graphql{target}" r = sent_as(client, method, f"/linear{path}", sent, content=content, bare=True) assert r.status_code == status @@ -6626,8 +6625,9 @@ def test_linear_answers_each_method_path_type_and_accept_as_linear_does( @pytest.mark.parametrize("char", '"<>`{}') def test_linears_front_escapes_a_character_httpx_escapes_first(char): - """The characters :func:`backlot.routers.linear.express_path` escapes that httpx escapes before - it sends a path, so `_LINEAR_HTTP` cannot send them bare; it sends the other two, `|` and `^`.""" + """The characters :func:`backlot.routers.linear.express_path` escapes that httpx escapes + before it sends a path, so `_LINEAR_HTTP` cannot send them bare; it sends the other two, `|` + and `^`.""" from backlot.routers import linear assert linear.express_path(f"/nosucha{char}b") == f"/nosucha%{ord(char):02X}b" From 7071f7acba2134efc7c204aeccc269c8d82f9a77 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:08:09 +0900 Subject: [PATCH 20/52] linear, fireflies: Cloudflare's TRACE answer closes the connection, as both vendors' did Measured on 2026-10-26 over raw HTTP/1.1: api.linear.app and api.fireflies.ai sent `Connection: close` with the TRACE 405 and answered no second request on the connection, and kept it open after the CONNECT and PURGE 400s. --- backlot/main.py | 5 +++-- tests/test_cross_vendor.py | 11 ++++++----- tests/test_fireflies.py | 4 ++++ tests/test_linear.py | 4 ++++ 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/backlot/main.py b/backlot/main.py index 588904eb1..9cbc2f68a 100644 --- a/backlot/main.py +++ b/backlot/main.py @@ -1227,14 +1227,15 @@ async def warn_slack_post_charset(request: Request, call_next): #: Cloudflare's answer to each method it refuses in front of Linear and Fireflies, as #: api.linear.app and api.fireflies.ai answered each on `/graphql`, `/graphql/`, `/nosuch` and `/` #: on 2026-10-25 (UTC): a page with CRLF line ends, or a line of text, and the headers it puts on -#: it. +#: it. Both closed the connection after the `TRACE` answer and answered a second request on it after +#: the other two (`/graphql`, 2026-10-26). _CLOUDFLARE_METHOD = { "TRACE": ( 405, b"\r\n405 Not Allowed\r\n\r\n" b"

405 Not Allowed

\r\n
cloudflare
\r\n" b"\r\n\r\n", - {"content-type": "text/html"}, + {"content-type": "text/html", "connection": "close"}, ), "CONNECT": ( 400, diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index 8bf3691f0..eed2f295c 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -401,10 +401,10 @@ def test_cloudflare_refuses_a_repeated_authorization( b"\r\n%s\r\n\r\n

%s

\r\n" b"
cloudflare
\r\n\r\n\r\n" ) -_NOT_ALLOWED = (405, "text/html", _PAGE % ((b"405 Not Allowed",) * 2)) -_ERROR_1045 = (400, "text/plain; charset=UTF-8", b"error code: 1045\n") -_PURGED = (400, "text/html; charset=UTF-8", _PAGE % ((b"400 Bad Request",) * 2)) -_TWICE = (400, "text/html", _PAGE % ((b"400 Bad Request",) * 2)) +_NOT_ALLOWED = (405, "text/html", "close", _PAGE % ((b"405 Not Allowed",) * 2)) +_ERROR_1045 = (400, "text/plain; charset=UTF-8", None, b"error code: 1045\n") +_PURGED = (400, "text/html; charset=UTF-8", None, _PAGE % ((b"400 Bad Request",) * 2)) +_TWICE = (400, "text/html", "close", _PAGE % ((b"400 Bad Request",) * 2)) _CLOUDFLARE_METHODS = [ *( pytest.param(m, path, 1, answer, id=f"{m} {path or '/'}") @@ -430,7 +430,8 @@ def test_cloudflare_refuses_trace_connect_and_purge( if answer is None: assert b"cloudflare" not in r.content and r.status_code == 404 return - assert (r.status_code, r.headers["content-type"], r.content) == answer + got = (r.status_code, r.headers["content-type"], r.headers.get("connection"), r.content) + assert got == answer @pytest.mark.parametrize( diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index a42cd181c..099601f35 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -1394,6 +1394,7 @@ def _not_found(method: str, target: str) -> bytes: "text/html", _CLOUDFLARE_PAGE % ((b"405 Not Allowed",) * 2), id="TRACE", + answered={"connection": "close"}, ), http_row( "CONNECT", @@ -1405,6 +1406,7 @@ def _not_found(method: str, target: str) -> bytes: "text/plain; charset=UTF-8", b"error code: 1045\n", id="CONNECT", + answered={"connection": None}, ), http_row( "PURGE", @@ -1416,6 +1418,7 @@ def _not_found(method: str, target: str) -> bytes: "text/html; charset=UTF-8", _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), id="PURGE", + answered={"connection": None}, ), http_row( "TRACE", @@ -1427,6 +1430,7 @@ def _not_found(method: str, target: str) -> bytes: "text/html", _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), id="TRACE, Authorization twice", + answered={"connection": "close"}, ), # `@fastify/cors`, whatever the credential and the body. *( diff --git a/tests/test_linear.py b/tests/test_linear.py index cfa884118..0282754f3 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -5915,6 +5915,7 @@ def _search_refusal(message: bytes) -> bytes: "text/html", _CLOUDFLARE_PAGE % ((b"405 Not Allowed",) * 2), id="TRACE", + answered={"connection": "close"}, ), http_row( "CONNECT", @@ -5926,6 +5927,7 @@ def _search_refusal(message: bytes) -> bytes: "text/plain; charset=UTF-8", b"error code: 1045\n", id="CONNECT", + answered={"connection": None}, ), http_row( "PURGE", @@ -5937,6 +5939,7 @@ def _search_refusal(message: bytes) -> bytes: "text/html; charset=UTF-8", _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), id="PURGE", + answered={"connection": None}, ), http_row( "TRACE", @@ -5948,6 +5951,7 @@ def _search_refusal(message: bytes) -> bytes: "text/html", _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), id="TRACE, Authorization twice", + answered={"connection": "close"}, ), *( http_row(m, "", (*_J, *_P), None, "key", 501, None, b"", id=m) From 91cfdfc641f3c22be022fca18739b2ee9380ebb6 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:17:10 +0900 Subject: [PATCH 21/52] linear, fireflies: the broken-escape refusal's docstring records the connection both vendors closed The merge from the HTTP-layer branch sends `Connection: close` with the 400, which the shared Cloudflare test asserts. Measured on 2026-10-26 over raw HTTP/1.1: api.linear.app and api.fireflies.ai sent `Connection: close` with the 400 for `POST /graphql%zz`, `POST /a%00b` and `GET /a%2` and answered no second request on the connection. --- backlot/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backlot/main.py b/backlot/main.py index b47244be7..76a2db48d 100644 --- a/backlot/main.py +++ b/backlot/main.py @@ -1381,7 +1381,8 @@ async def refuse_a_broken_escape_on_linear_and_fireflies(request: Request, call_ :func:`refuse_cloudflares_methods_on_linear_and_fireflies`. api.linear.app and api.fireflies.ai answered so `/a%zz` and `/a%00b` by each method of :data:`_CLOUDFLARE_METHOD`, and `/a%2`, `/graphql%zz` and a `%` before `b` by `POST`, on 2026-10-25 (UTC), and passed `?a=%zz`, `?a=%`, - `?a=%2` and `?a=%00` after `/graphql` on to the vendor.""" + `?a=%2` and `?a=%00` after `/graphql` on to the vendor. Both closed the connection after the 400 + (`POST /graphql%zz`, `POST /a%00b`, `GET /a%2`, 2026-10-26).""" return Response( request_target.CLOUDFLARE_PAGE, status_code=400, From 524369df45326b69bb94aa7681524e8740a433d7 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:37:20 +0900 Subject: [PATCH 22/52] linear, fireflies: rows for PUT and PATCH at Linear's root, a PROPFIND body Fastify does not read, and a broken escape under a longer name Measured on 2026-10-25 and 2026-10-26 (UTC): api.linear.app answered `PUT /` and `PATCH /` with Express's 404 quoting `/`, and api.fireflies.ai answered a `PROPFIND` to `/graphql/` under `a/b/c` and with `{` as JSON with Fastify's 404, where a `POST` under `a/b/c` drew its 415. --- backlot/routers/fireflies.py | 3 ++- backlot/routers/linear.py | 8 ++++---- tests/test_cross_vendor.py | 6 ++++-- tests/test_fireflies.py | 2 ++ tests/test_linear.py | 2 ++ 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index bd932ebfd..0f9fb95d0 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -270,7 +270,8 @@ async def unrouted(request: Request): here: :data:`_NGINX_404` where nginx keeps the request (:func:`nginx_passes`), and Fastify's where it passes it on, :func:`_preflight` to an `OPTIONS` and its not-found handler's 404 (:func:`_not_found`) to any other method, after reading the body of one of :data:`_BODIED` - (:func:`_fastify_read`).""" + (:func:`_fastify_read`). A `PROPFIND` to `/graphql/` under `a/b/c` or with `{` as JSON drew the + 404 on 2026-10-26 (UTC), where a `POST` under `a/b/c` drew the 415.""" if not nginx_passes(sent_path(request.scope)): return Response(_NGINX_404, status_code=404, headers={"content-type": "text/html"}) if request.method == "OPTIONS": diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 73487ec96..ad3c2b900 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -255,10 +255,10 @@ def routes_to_apollo(path: str) -> bool: async def express(request: Request): """Linear's answer at a path Express does not hand Apollo Server (:func:`routes_to_apollo`), each of which ``backlot.main`` routes here: after the layers of :func:`_front`, Express's own, - as api.linear.app answered on 2026-10-25 (UTC). At `/` that is :data:`_GREETING` to a `GET` and - a `HEAD`, and the methods `/` takes, as `text/plain`, to an `OPTIONS`; at any other path, and to - any other method, it is ``finalhandler``'s 404 page (:func:`_not_found`). The body is not read: - `{` under `application/json` drew the 404.""" + as api.linear.app answered on 2026-10-25 (UTC), and `PATCH /` on 2026-10-26. At `/` that is + :data:`_GREETING` to a `GET` and a `HEAD`, and the methods `/` takes, as `text/plain`, to an + `OPTIONS`; at any other path, and to any other method, it is ``finalhandler``'s 404 page + (:func:`_not_found`). The body is not read: `{` under `application/json` drew the 404.""" front = _front(request) if front is not None: return front diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index a304edfd3..03d3453e0 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -341,9 +341,10 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( # A request to Linear or Fireflies with `Authorization` sent more than once, `{key}` standing for # the vendor's own spelling of the admin token, or with a path that breaks an escape, each answered # with the 400 page `backlot.main.refuse_a_repeated_authorization_on_linear_and_fireflies` and -# `refuse_a_broken_escape_on_linear_and_fireflies` record. The last three rows are the control: one +# `refuse_a_broken_escape_on_linear_and_fireflies` record. The last four rows are the control: one # header reaches the vendor, a path that only starts with the vendor's name is not the vendor's, -# and a broken escape in the query string is not one in the path. +# with a broken escape in it or without, and a broken escape in the query string is not one in the +# path. _TYPENAME = b'{"query":"{ __typename }"}' _CLOUDFLARE = 400 _REPEATED = [ @@ -370,6 +371,7 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( ), pytest.param("POST", "/graphql", _TYPENAME, ("{key}",), 200, id="one key"), pytest.param("POST", "x/graphql", _TYPENAME, ("{key}", "nope"), 404, id="a longer name"), + pytest.param("POST", "x/a%zz", _TYPENAME, ("{key}",), 404, id="%zz under a longer name"), pytest.param("POST", "/graphql?a=%zz", _TYPENAME, ("{key}",), 200, id="%zz in the query"), ] diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index 64556eb35..2ec395a0b 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -2174,6 +2174,8 @@ def _not_found(method: str, target: str) -> bytes: ("POST", "/graphql/", (), b"x", "none"), ("POST", "/graphql/", (("Content-Type", "text/plain"),), b"x", "none"), ("PUT", "/graphql/", (("Content-Type", "application/graphql"),), b"{ x }", "none"), + ("PROPFIND", "/graphql/", (("Content-Type", "a/b/c"),), b"x", "none"), + ("PROPFIND", "/graphql/", _J, b"{", "none"), ("GET", "/graphql/" + _Q, _P, None, "key"), ("GET", "/graphql/", (("Accept", "text/html"),), None, "none"), ("PUT", "/graph%71l", _J, _TYPENAME, "key"), diff --git a/tests/test_linear.py b/tests/test_linear.py index d38e092ca..354930479 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -6697,6 +6697,8 @@ def _search_refusal(message: bytes) -> bytes: ("PROPFIND", "/nosuch", (), None, "key", "/nosuch"), ("OPTIONS", "/nosuch", (), None, "Bearer key", "/nosuch"), ("POST", "/", _J, _TYPENAME, "key", "/"), + ("PUT", "/", _J, _TYPENAME, "key", "/"), + ("PATCH", "/", _J, _TYPENAME, "none", "/"), ("DELETE", "/", (), None, "none", "/"), ("PROPFIND", "/", (), None, "none", "/"), ("GET", "/%2F", (), None, "none", "/%2F"), From 47ef3b96ffafb86f9cbb0345083f07b26eeff8e7 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:42:21 +0900 Subject: [PATCH 23/52] fireflies: the method set Fastify reads a body for holds the measurement of the one it leaves unread --- backlot/routers/fireflies.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index 0f9fb95d0..a0969a315 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -118,7 +118,8 @@ def _auth_failed(seen: dict[str, str], message: str) -> Response: # The methods Fastify reads a body for but `OPTIONS`, which `@fastify/cors` answers first: at a path # or under a method no route takes, it reads the body, then answers its not-found handler's 404 -# (:func:`_not_found`). +# (:func:`_not_found`). Any other method's body goes unread: a `PROPFIND` to `/graphql/` under +# `a/b/c` or with `{` as JSON drew the 404 on 2026-10-26 (UTC). _BODIED = ("DELETE", "PATCH", "POST", "PUT") @@ -270,8 +271,7 @@ async def unrouted(request: Request): here: :data:`_NGINX_404` where nginx keeps the request (:func:`nginx_passes`), and Fastify's where it passes it on, :func:`_preflight` to an `OPTIONS` and its not-found handler's 404 (:func:`_not_found`) to any other method, after reading the body of one of :data:`_BODIED` - (:func:`_fastify_read`). A `PROPFIND` to `/graphql/` under `a/b/c` or with `{` as JSON drew the - 404 on 2026-10-26 (UTC), where a `POST` under `a/b/c` drew the 415.""" + (:func:`_fastify_read`).""" if not nginx_passes(sent_path(request.scope)): return Response(_NGINX_404, status_code=404, headers={"content-type": "text/html"}) if request.method == "OPTIONS": From 5bd03c634cbf626c4dd07f78ebaa6378d3d4fc12 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:30:39 +0900 Subject: [PATCH 24/52] graphql: Fireflies' 500 is graphql-depth-limit's, and each location counts as graphql-js counts Fireflies runs graphql-depth-limit (maxDepth 10) as graphql-js starts validating: its depth errors come ahead of graphql-js's own and count toward the hundred validate takes, and it throws, which Apollo Server answers with its 500, on a spread of an undefined fragment, a field-less spread cycle and a 101st report. That replaces the walk for undefined fragments, which answered the 500 under __schema, __type and __typename and for operations a later one replaces. A column counts UTF-16 code units, and each error the run makes, a resolver's too, is located as graphql-js locates it. The refusal after fifty refused variables keeps INTERNAL_SERVER_ERROR, which Linear masks as its Internal server error. --- backlot/graphql/engine.py | 63 ++++++++---- backlot/graphql/js_words.py | 12 ++- backlot/routers/fireflies.py | 107 ++++++++++++++------- backlot/routers/linear.py | 10 +- backlot/telemetry/errors.py | 4 +- tests/test_fireflies.py | 180 +++++++++++++++++++++++++++++++++-- tests/test_graphql.py | 50 +++++++++- tests/test_linear.py | 27 ++++-- 8 files changed, 371 insertions(+), 82 deletions(-) diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index b628df078..b79a4fad9 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -101,6 +101,10 @@ def _ids_as_graphql_js_reads_them(value: Any, type_: Any) -> Any: PERSISTED_QUERY_NOT_FOUND = "PERSISTED_QUERY_NOT_FOUND" INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR" +#: The errors graphql-js's ``validate`` takes, and graphql-core's: the next one is +#: ``Too many validation errors, error limit reached. Validation aborted.``, and the last. +_MAX_VALIDATION_ERRORS = 100 + @dataclass(frozen=True) class Refusal: @@ -300,7 +304,8 @@ def _with_roots(schema: GraphQLSchema, roots: tuple[str, ...]) -> GraphQLSchema: serves none of their fields. A document validated against it is refused a field of one as a field the type does not have, and ``__typename`` on one is answered with the type's name. graphql-core refuses a schema with an object type of no field, so this one is marked as - checked; it is only validated against and run for ``__typename``.""" + checked; it is only validated against, and run for an operation on a root type the SDL leaves + out.""" shadow = GraphQLSchema( query=schema.query_type, mutation=GraphQLObjectType("Mutation", {}) if "Mutation" in roots else None, @@ -365,29 +370,33 @@ def resolve( *, variables: Any = None, operation_name: str | None = None, - on_document: Callable[[DocumentNode], Result | None] | None = None, + on_document: Callable[[DocumentNode], Result | list[GraphQLError]] | None = None, ) -> Result | Operation: """``document`` read as graphql-js reads it under Apollo Server, up to the choice of the - operation to run: parse, then ``on_document``, a vendor's look at the parsed document - before it is validated, then validation and the choice of operation, each refused with - Apollo Server's code; :meth:`run` takes the operation it chose.""" + operation to run: parse, then validation, then the choice of operation, each refused with + Apollo Server's code; :meth:`run` takes the operation it chose. + + ``on_document`` is a validation rule of the vendor's that reports as graphql-js creates + it: the :class:`Result` to answer with where it throws, or the errors it reports, which + come ahead of graphql-js's own and count toward the :data:`_MAX_VALIDATION_ERRORS` it + takes.""" try: doc = parse(document) except GraphQLSyntaxError as error: return refused( Refusal(js_words.requote(error.message), PARSE_FAILED, js_words.locations(error)) ) - if on_document is not None: - answer = on_document(doc) - if answer is not None: - return answer - errors = validate(self._checked, doc) - if errors: + reported = [] if on_document is None else on_document(doc) + if isinstance(reported, Result): + return reported + errors = validate(self._checked, doc, max_errors=_MAX_VALIDATION_ERRORS - len(reported)) + if reported or errors: return refused( + *(Refusal(e.message, VALIDATION_FAILED, js_words.locations(e)) for e in reported), *( Refusal(js_words.requote(e.message), VALIDATION_FAILED, js_words.locations(e)) for e in errors - ) + ), ) operation = get_operation_ast(doc, operation_name) if operation is None: @@ -403,7 +412,7 @@ def resolve_payload( self, payload: Any, *, - on_document: Callable[[DocumentNode], Result | None] | None = None, + on_document: Callable[[DocumentNode], Result | list[GraphQLError]] | None = None, ) -> Result | Operation: """A GraphQL-over-HTTP POST body (``{query, variables, operationName}``) its router has read with :mod:`backlot.graphql.body`, up to the choice of operation: :func:`body_refusal`, @@ -431,9 +440,18 @@ def run(self, operation: Operation, *, context: Any = None) -> Result: coercion = js_words.coerce_variable_values( self.schema, list(operation.operation.variable_definitions), variables ) + # Apollo Server gives BAD_USER_INPUT to the refusal of a variable; the one saying there + # were too many has no variable and keeps the code of an error with none. if coercion: return refused( - *(Refusal(e.message, BAD_USER_INPUT, js_words.locations(e)) for e in coercion) + *( + Refusal( + e.message, + BAD_USER_INPUT if e.nodes else INTERNAL_SERVER_ERROR, + js_words.locations(e), + ) + for e in coercion + ) ) if variables: types = { @@ -444,7 +462,9 @@ def run(self, operation: Operation, *, context: Any = None) -> Result: name: _ids_as_graphql_js_reads_them(value, types.get(name)) for name, value in variables.items() } - # An operation on a root the SDL leaves out has only `__typename` once it is validated. + # An operation on a root type the SDL leaves out runs against the schema `_with_roots` + # made, where its validated selection can only be `__typename`, or where graphql-core + # refuses it when the vendor has no such root type either. on_a_root = getattr(self.schema, f"{operation.operation.operation.value}_type") is None result = execute_sync( self._checked if on_a_root else self.schema, @@ -455,19 +475,20 @@ def run(self, operation: Operation, *, context: Any = None) -> Result: ) # graphql-core's ExecutionResult.formatted writes "data" first; api.fireflies.ai and # api.linear.app open a response carrying a field error with "errors" (2026-10-21), so the - # errors come first here. An error graphql-core made itself is in graphql-js's words and - # carries the code Apollo Server gives an error with none, which a router writes in its - # vendor's envelope; one a resolver raised is the vendor's own. + # errors come first here. Each error's locations are graphql-js's. An error graphql-core + # made itself is in graphql-js's words and carries the code Apollo Server gives an error + # with none, which a router writes in its vendor's envelope; one a resolver raised is the + # vendor's own. payload: dict = {} assigned = [] if result.errors: payload["errors"] = [] for error in result.errors: entry = error.formatted + if "locations" in entry: + entry["locations"] = list(js_words.locations(error)) if _made_by_graphql(error): entry["message"] = js_words.requote(entry["message"]) - if "locations" in entry: - entry["locations"] = list(js_words.locations(error)) entry.setdefault("extensions", {})["code"] = INTERNAL_SERVER_ERROR assigned.append(len(payload["errors"])) payload["errors"].append(entry) @@ -495,7 +516,7 @@ def execute_payload( payload: Any, *, context: Any = None, - on_document: Callable[[DocumentNode], Result | None] | None = None, + on_document: Callable[[DocumentNode], Result | list[GraphQLError]] | None = None, ) -> Result: """:meth:`resolve_payload`, then :meth:`run` the operation it chose.""" resolved = self.resolve_payload(payload, on_document=on_document) diff --git a/backlot/graphql/js_words.py b/backlot/graphql/js_words.py index d2922306b..4dea551ec 100644 --- a/backlot/graphql/js_words.py +++ b/backlot/graphql/js_words.py @@ -107,10 +107,11 @@ def _format(value: Any, depth: int) -> str: def locations(error: GraphQLError) -> tuple: - """The ``locations`` graphql-js gives ``error``: each position's line and column, counting the - lines as graphql-js's ``getLocation`` does, where graphql-core's count misses a line that - starts at the position and adds one at each character Python's ``splitlines`` reads as a - break.""" + """The ``locations`` graphql-js gives ``error``: each position's line and column as + graphql-js's ``getLocation`` counts them, the lines broken only at :data:`_LINE_BREAK` and the + column in UTF-16 code units, where graphql-core's count misses a line that starts at the + position, adds one at each character Python's ``splitlines`` reads as a break and counts a + character past U+FFFF once.""" if not error.positions: return () body = error.source.body @@ -121,7 +122,8 @@ def locations(error: GraphQLError) -> tuple: if m.start() >= position: break line, start = line + 1, m.end() - out.append({"line": line, "column": position + 1 - start}) + units = len(body[start:position].encode("utf-16-le", "surrogatepass")) // 2 + out.append({"line": line, "column": units + 1}) return tuple(out) diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index c29319c39..6e44fd1e2 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -26,8 +26,10 @@ from fastapi.responses import JSONResponse, Response from graphql import ( DocumentNode, + FieldNode, FragmentDefinitionNode, FragmentSpreadNode, + GraphQLError, OperationDefinitionNode, ) @@ -136,7 +138,7 @@ async def graphql(request: Request): # scale measured) and almost none of them have a Fireflies account. See resolve_users. "roster": frozenset(state.acl.email_to_token()) if getattr(state, "acl", None) else None, } - result = ENGINE.execute_payload(payload, context=context, on_document=_fragments_unreached) + result = ENGINE.execute_payload(payload, context=context, on_document=_depth_limit) request.state.graphql_raised = result.raised return Response( request_body.node_json(_envelope(result)), @@ -186,41 +188,80 @@ def _envelope(result: engine.Result) -> dict: return {**result.payload, "errors": shaped} -def _fragments_unreached(doc: DocumentNode) -> engine.Result | None: - """Fireflies' 500 for a document whose operations reach a fragment it does not define, or a - fragment that reaches itself, ahead of validation: api.fireflies.ai answered ``{ ...F }``, a - spread of an undefined fragment inside an inline fragment, inside a fragment an operation uses - and inside an operation ``operationName`` did not choose, and ``fragment F on Query { ...F } - { ...F }``, with it on 2026-10-25 (UTC), beside another validation error too. The same spread - in a fragment no operation uses was the validation's ``Unknown fragment`` and the same cycle - its ``Cannot spread fragment``.""" - fragments = {d.name.value: d for d in doc.definitions if isinstance(d, FragmentDefinitionNode)} - - # Each fragment found to reach no fault, so that a fragment many spreads reach is read once. - done: set[str] = set() - - def reaches_a_fault(selections, path: tuple[str, ...]) -> bool: - for selection in selections: - if isinstance(selection, FragmentSpreadNode): - name = selection.name.value - if name not in fragments or name in path: - return True - if name not in done: - if reaches_a_fault(fragments[name].selection_set.selections, (*path, name)): - return True - done.add(name) - elif selection.selection_set is not None and reaches_a_fault( - selection.selection_set.selections, path - ): - return True - return False - - operations = [d for d in doc.definitions if isinstance(d, OperationDefinitionNode)] - if any(reaches_a_fault(operation.selection_set.selections, ()) for operation in operations): +#: graphql-depth-limit's ``maxDepth`` on api.fireflies.ai, which on 2026-10-26 (UTC) refused a +#: selection eleven fields deep and crawled one ten deep. +_MAX_DEPTH = 10 +#: The errors graphql-js's ``validate`` takes before it stops, which a 101st report from a rule +#: throws past. +_MAX_ERRORS = 100 + + +class _Thrown(Exception): + """graphql-depth-limit throwing out of graphql-js's validation, which Apollo Server answers with + its 500.""" + + +def _depth_limit(doc: DocumentNode) -> engine.Result | list[GraphQLError]: + """graphql-depth-limit's rule with a ``maxDepth`` of :data:`_MAX_DEPTH`, which Fireflies runs + as graphql-js starts validating ``doc``: the errors it reports, which come ahead of graphql-js's + own, or Fireflies' 500 where it throws. + + It crawls each operation, the last of each name and the unnamed ones as the last one named + ``''``: a field's selections one deeper, but none of a field whose name starts ``__``, and a + spread's fragment, the last of its name, at the spread's depth. A selection deeper than + :data:`_MAX_DEPTH` is reported and not crawled. A spread of a fragment the document does not + define, a spread back to a fragment with no field between, and a report past + :data:`_MAX_ERRORS` throw. api.fireflies.ai answered so on 2026-10-26 (UTC), and answered + ``{ ...F }`` with ``F`` undefined inside a field ten deep with the 500 and eleven deep with the + depth error.""" + fragments: dict[str, FragmentDefinitionNode] = {} + operations: dict[str, OperationDefinitionNode] = {} + for definition in doc.definitions: + if isinstance(definition, FragmentDefinitionNode): + fragments[definition.name.value] = definition + elif isinstance(definition, OperationDefinitionNode): + operations[definition.name.value if definition.name else ""] = definition + # The selections a fragment's crawl from a depth reports, which each of its spreads at that + # depth reports again; None while it is being crawled. + crawled: dict[tuple[str, int], list | None] = {} + + def crawl(selections, depth: int) -> list: + reported: list = [] + for node in selections: + if depth > _MAX_DEPTH: + reported.append(node) + elif isinstance(node, FieldNode): + if not node.name.value.startswith("__") and node.selection_set is not None: + reported += crawl(node.selection_set.selections, depth + 1) + elif isinstance(node, FragmentSpreadNode): + reported += spread(node.name.value, depth) + else: + reported += crawl(node.selection_set.selections, depth) + if len(reported) > _MAX_ERRORS: + raise _Thrown + return reported + + def spread(name: str, depth: int) -> list: + if name not in fragments or crawled.get((name, depth), []) is None: + raise _Thrown + if (name, depth) not in crawled: + crawled[name, depth] = None + crawled[name, depth] = crawl(fragments[name].selection_set.selections, depth) + return crawled[name, depth] + + errors: list[GraphQLError] = [] + try: + for name, operation in operations.items(): + for node in crawl(operation.selection_set.selections, 0): + if len(errors) == _MAX_ERRORS: + raise _Thrown + message = f"'{name}' exceeds maximum operation depth of {_MAX_DEPTH}" + errors.append(GraphQLError(message, node)) + except _Thrown: return engine.refused( engine.Refusal("Internal server error", engine.INTERNAL_SERVER_ERROR, status=500) ) - return None + return errors def _read(raw: bytes) -> Any: diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 108a79b3a..4e883ac4e 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -237,9 +237,11 @@ def _operation_missing(name: Any) -> dict: def _envelope(result: engine.Result, payload: Any) -> dict: """``result`` as Linear writes it: a request error in Linear's members (:func:`_apollo`), but a - body with no query under Linear's ``INVALID_INPUT``, a refused persisted query as Linear's - ``Internal server error`` and an operation that cannot be chosen as :func:`_operation_missing`; - and each error in ``result.assigned`` as :func:`_internal`.""" + body with no query under Linear's ``INVALID_INPUT``, a persisted query refused for its version + or its hash as Linear's ``Internal server error``, an operation that cannot be chosen as + :func:`_operation_missing`, and the request error after fifty refused variables, to which + Apollo Server gives no code of its own, as :func:`_internal`, as api.linear.app answered it + on 2026-10-26 (UTC); and each error in ``result.assigned`` as :func:`_internal`.""" if result.refusals: errors = [] for refusal in result.refusals: @@ -254,6 +256,8 @@ def _envelope(result: engine.Result, payload: Any) -> dict: ) elif refusal.message == engine.NO_QUERY: errors.append(_apollo(refusal.message, _INVALID_INPUT, refusal.status)) + elif refusal.code == engine.INTERNAL_SERVER_ERROR: + errors.append(_internal({})) else: errors.append( _apollo(refusal.message, refusal.code, refusal.status, refusal.locations) diff --git a/backlot/telemetry/errors.py b/backlot/telemetry/errors.py index 8ac63b4e5..80620c574 100644 --- a/backlot/telemetry/errors.py +++ b/backlot/telemetry/errors.py @@ -190,8 +190,8 @@ def error_code(source: str, status: int, body: bytes) -> str: # Slack sends its auth errors with a 200. _SLACK_AUTH = frozenset({"account_inactive", "invalid_auth", "not_authed"}) # Each 5xx a vendor answers a wrong request with, by source and error code, and Backlot answers too: -# Fireflies' 500 for a document that reaches a fragment it does not define -# (`backlot.routers.fireflies._fragments_unreached`). +# Fireflies' 500 for a document graphql-depth-limit throws on +# (`backlot.routers.fireflies._depth_limit`). _ANSWERED_5XX = frozenset({("fireflies", "INTERNAL_SERVER_ERROR")}) diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index b20ed68dc..1852afff9 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -117,12 +117,6 @@ def test_fireflies_reads_a_credential_as_fireflies_does(client, tokens_yaml, val # --- the body and the credential, in the order Fireflies reads them ------------------------------- -# The headers a body was sent with beyond `Content-Type: application/json`, the body, and Fireflies' -# answer to it with the key: `_EMPTY`, `_NOT_JSON` or `_TOO_LARGE`, its HTTP layer's refusals -# `backlot.routers.fireflies._body_refusal` sends at the status their `statusCode` names, or None -# where it served the query. A `WithTheKey` row is a body that layer passes, which draws one of -# `_auth_failed`'s 500s without the key; its bytes are api.fireflies.ai's without the newline real -# ends them with. def _apollo( message: bytes, code: bytes = b"BAD_REQUEST", locations: bytes = b"", status: int = 400 ) -> WithTheKey: @@ -135,6 +129,32 @@ def _apollo( _NO_KEYS = _apollo(b"POST body missing, invalid Content-Type, or JSON object has no keys.") + + +def _deeper(fields: int) -> bytes: + """A body whose query selects ``fields`` fields twelve deep in fields Fireflies does not + have.""" + selection = " ".join(f"l{n}" for n in range(fields)) + return b'{"query":"{ a{b{c{d{e{f{g{h{i{j{k{ %s }}}}}}}}}}} }"}' % selection.encode() + + +def _columns(fields: int) -> list[int]: + """The column of each field twelve deep in :func:`_deeper`'s query.""" + query = json.loads(_deeper(fields))["query"] + return [query.index(f" l{n} ") + 2 for n in range(fields)] + + +def _too_deep(column: int) -> bytes: + return ( + b'{"friendly":false,"message":"\'\' exceeds maximum operation depth of 10","locations":' + b'[{"line":1,"column":%d}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}' % column + ) + + +def _validation(*errors: bytes) -> bytes: + return b'{"errors":[%s]}' % b",".join(errors) + + _BATCHING = _apollo(b"Operation batching disabled.") _EMPTY = ( b'{"statusCode":400,"status":false,"code":"http","message":"Body cannot be empty when' @@ -173,6 +193,12 @@ def _apollo( id="utf-16", ), ] +# The headers a body was sent with beyond `Content-Type: application/json`, the body, and Fireflies' +# answer to it with the key: `_EMPTY`, `_NOT_JSON` or `_TOO_LARGE`, its HTTP layer's refusals +# `backlot.routers.fireflies._body_refusal` sends at the status their `statusCode` names, or None +# where it served the query. A `WithTheKey` row is a body that layer passes, which draws one of +# `_auth_failed`'s 500s without the key; its bytes are api.fireflies.ai's without the newline real +# ends them with. _FIREFLIES_BODIES = [ pytest.param(b'{"query":"{ __typename }"}', None, id="a query"), pytest.param(b"{", _NOT_JSON, id="{"), @@ -394,6 +420,23 @@ def _apollo( ), id="a variable not provided", ), + pytest.param( + b'{"query":"query($v: [String!]) { transcripts(participants: $v) { id } }",' + b'"variables":{"v":[%s]}}' % b",".join([b"true"] * 60), + WithTheKey( + _validation( + *( + b'{"friendly":false,"message":"Variable \\"$v\\" got invalid value true at ' + b'\\"v[%d]\\"; String cannot represent a non string value: true","locations":' + b'[{"line":1,"column":7}],"code":"BAD_USER_INPUT","extensions":{}}' % n + for n in range(50) + ), + b'{"friendly":false,"message":"Too many errors processing variables, error limit ' + b'reached. Execution aborted.","code":"INTERNAL_SERVER_ERROR","extensions":{}}', + ) + ), + id="sixty variables refused", + ), pytest.param( b'{"query":"{ __typename }","operationName":"Z"}', WithTheKey( @@ -541,6 +584,127 @@ def _apollo( ), id="an undefined spread beside a document that cannot parse", ), + pytest.param( + b'{"query":"{ __schema { ...X } }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Unknown fragment \\"X\\".","locations":[{"line":1,"column":17}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="an undefined spread under __schema", + ), + pytest.param( + b'{"query":"{ __type(name: \\"Query\\") { ...X } }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Unknown fragment \\"X\\".","locations":[{"line":1,"column":30}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="an undefined spread under __type", + ), + pytest.param( + b'{"query":"{ __typename { ...X } }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Field \\"__typename\\" must not have a selection since type \\"String!\\" has no subfields.","locations":[{"line":1,"column":14}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Unknown fragment \\"X\\".","locations":[{"line":1,"column":19}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="an undefined spread under __typename", + ), + pytest.param( + b'{"query":"{ ...X } { __typename }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"This anonymous operation must be the only defined operation.","locations":[{"line":1,"column":1}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Unknown fragment \\"X\\".","locations":[{"line":1,"column":6}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"This anonymous operation must be the only defined operation.","locations":[{"line":1,"column":10}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="an undefined spread in an unnamed operation a later one replaces", + ), + pytest.param( + b'{"query":"query A { ...X } query A { __typename }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Unknown fragment \\"X\\".","locations":[{"line":1,"column":14}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"There can be only one operation named \\"A\\".","locations":[{"line":1,"column":7},{"line":1,"column":24}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="an undefined spread in an operation a later one of its name replaces", + ), + pytest.param( + b'{"query":"fragment F on Query { transcripts { ...F } } { ...F }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"\'\' exceeds maximum operation depth of 10","locations":[{"line":1,"column":37}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Cannot spread fragment \\"F\\" within itself.","locations":[{"line":1,"column":37}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Fragment \\"F\\" cannot be spread here as objects of type \\"Transcript\\" can never be of type \\"Query\\".","locations":[{"line":1,"column":37}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="a fragment that spreads itself under a field", + ), + pytest.param( + b'{"query":"{ a{b{c{d{e{f{g{h{i{j{ ...X }}}}}}}}}} }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Internal server error","code":"INTERNAL_SERVER_ERROR","extensions":{}}]}', + 500, + ), + id="an undefined spread ten fields deep", + ), + pytest.param( + b'{"query":"{ a{b{c{d{e{f{g{h{i{j{k{ ...X }}}}}}}}}}} }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"\'\' exceeds maximum operation depth of 10","locations":[{"line":1,"column":26}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Cannot query field \\"a\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Unknown fragment \\"X\\".","locations":[{"line":1,"column":29}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="an undefined spread eleven fields deep", + ), + pytest.param( + b'{"query":"{ a{b{c{d{e{f{g{h{i{j{k{ l m }}}}}}}}}}} }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"\'\' exceeds maximum operation depth of 10","locations":[{"line":1,"column":26}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"\'\' exceeds maximum operation depth of 10","locations":[{"line":1,"column":28}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Cannot query field \\"a\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="two fields twelve deep", + ), + pytest.param( + b'{"query":"{ a{b{c{d{e{f{g{h{i{j{k{ l {m} }}}}}}}}}}} }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"\'\' exceeds maximum operation depth of 10","locations":[{"line":1,"column":26}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Cannot query field \\"a\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="a field twelve deep with a selection", + ), + pytest.param( + b'{"query":"fragment F on Query { ...X } fragment F on Query { __typename } { ...F }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Unknown fragment \\"X\\".","locations":[{"line":1,"column":26}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"There can be only one fragment named \\"F\\".","locations":[{"line":1,"column":10},{"line":1,"column":39}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="an undefined spread in the earlier of two fragments of a name", + ), + pytest.param( + b'{"query":"fragment F on Query { __typename } fragment F on Query { ...X } { ...F }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Internal server error","code":"INTERNAL_SERVER_ERROR","extensions":{}}]}', + 500, + ), + id="an undefined spread in the later of two fragments of a name", + ), + pytest.param( + b'{"query":"fragment F on Query { a{b{c{d{e{f{g{h{i{j{k{ l }}}}}}}}}}} } { ...F ...F }"}', + WithTheKey( + b'{"errors":[{"friendly":false,"message":"\'\' exceeds maximum operation depth of 10","locations":[{"line":1,"column":46}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"\'\' exceeds maximum operation depth of 10","locations":[{"line":1,"column":46}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}},{"friendly":false,"message":"Cannot query field \\"a\\" on type \\"Query\\".","locations":[{"line":1,"column":23}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' + ), + id="a fragment too deep spread twice", + ), + pytest.param( + _deeper(99), + WithTheKey( + _validation( + *(_too_deep(column) for column in _columns(99)), + b'{"friendly":false,"message":"Cannot query field \\"a\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}', + ) + ), + id="99 fields twelve deep", + ), + pytest.param( + _deeper(100), + WithTheKey( + _validation( + *(_too_deep(column) for column in _columns(100)), + b'{"friendly":false,"message":"Too many validation errors, error limit reached. Validation aborted.","code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}', + ) + ), + id="100 fields twelve deep", + ), + pytest.param( + _deeper(101), + WithTheKey( + b'{"errors":[{"friendly":false,"message":"Internal server error","code":"INTERNAL_SERVER_ERROR","extensions":{}}]}', + 500, + ), + id="101 fields twelve deep", + ), ] @@ -576,14 +740,14 @@ def test_fireflies_orders_the_body_and_the_credential( def test_fireflies_reads_a_fragment_once_however_many_spreads_reach_it(): """A document whose first twenty-five fragments each spread the next one twice reaches the - twenty-sixth by 2**25 paths, and `_fragments_unreached` reads each fragment once rather than + twenty-sixth by 2**25 paths, and `_depth_limit` reads each fragment once a depth rather than once a path.""" from backlot.routers import fireflies document = " ".join(f"fragment F{i} on Query {{ ...F{i + 1} ...F{i + 1} }}" for i in range(25)) document += " fragment F25 on Query { __typename } { ...F0 }" started = time.perf_counter() - assert fireflies._fragments_unreached(parse(document)) is None + assert fireflies._depth_limit(parse(document)) == [] assert time.perf_counter() - started < 1 diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 834d64dc9..14bc6a6df 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -195,6 +195,19 @@ def test_resolver_error_nulls_the_field_and_reports_its_path(gql): assert err["path"] == ["boom"] +@pytest.mark.parametrize( + "query, location", + [ + ('{ widget(id: "\U0001f600") { name } boom }', {"line": 1, "column": 29}), + ("# a\u2028b\n{ boom }", {"line": 2, "column": 3}), + ], +) +def test_a_resolver_error_is_located_as_graphql_js_locates_it(gql, query, location): + """A column counts UTF-16 code units and a line breaks at a line feed or a carriage return + alone, as api.linear.app located its ``Entity not found`` on 2026-10-26 (UTC).""" + assert gql.execute(query).payload["errors"][0]["locations"] == [location] + + def test_partial_data_survives_alongside_an_error(gql): r = gql.execute('{ boom widget(id: "w1") { name } }') assert r.payload["data"] == {"boom": None, "widget": {"name": "Gateway"}} @@ -1077,8 +1090,35 @@ def test_apollo_server_reads_a_body_in_its_order(gql, payload, answer): # A document over `_WORDS`, its variables as the body sent them, and graphql-js 16.14.2's answer # under Node: the stage that refused it and each error's message and locations, or "ok" where it -# coerced every variable. +# coerced every variable. An error's code is the stage's, or the third member where Apollo Server +# gives it another. _GRAPHQL_JS_WORDS = [ + pytest.param( + '{ string(v: "\U0001f600\U0001f600") nosuch }', + None, + "validate", + [('Cannot query field "nosuch" on type "Query".', [(1, 21)])], + id="two characters past U+FFFF before a validation error", + ), + pytest.param( + '{ string(v: "\U0001f600") } !', + None, + "parse", + [('Syntax Error: Unexpected "!".', [(1, 21)])], + id="a character past U+FFFF before a syntax error", + ), + pytest.param( + 'query($w: String = "\U0001f600", $v: Int) { int(v: $v) string(v: $w) }', + '{"v": "a"}', + "variables", + [ + ( + 'Variable "$v" got invalid value "a"; Int cannot represent non-integer value: "a"', + [(1, 26)], + ) + ], + id="a character past U+FFFF before a refused variable", + ), pytest.param( "# a\r{ nosuch }", None, @@ -1417,7 +1457,7 @@ def test_apollo_server_reads_a_body_in_its_order(gql, payload, answer): ) for n in range(50) ] - + [(_TOO_MANY, [])], + + [(_TOO_MANY, [], engine.INTERNAL_SERVER_ERROR)], id="the fifty-first refusal", ), pytest.param( @@ -1488,8 +1528,10 @@ def test_a_refusal_is_in_graphql_js_words(document, variables, stage, errors): (refusal.message, [(at["line"], at["column"]) for at in refusal.locations]) for refusal in r.refusals ] - assert got == errors - assert {refusal.code for refusal in r.refusals} == ({_STAGES[stage]} if errors else set()) + assert got == [error[:2] for error in errors] + assert [refusal.code for refusal in r.refusals] == [ + error[2] if len(error) > 2 else _STAGES[stage] for error in errors + ] # --- resolver binding ----------------------------------------------------------- diff --git a/tests/test_linear.py b/tests/test_linear.py index 1e924cf97..4e6644d47 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -849,12 +849,6 @@ def test_linear_unauthenticated_is_401(client): # --- the body, and where the credential is read against it --------------------------------------- -# The headers a body was sent with beyond `Content-Type: application/json`, the body, and the bytes -# Linear refused it with for the key, no credential and `nope` alike, at the status their `code` or -# `extensions.http.status` names (`_status`), or None where Linear served the query with the key and -# answered 401 without one, as `backlot.graphql.body` and `backlot.graphql.v8_json` read them. A -# `WithTheKey` row draws the 401 without the key. Apollo Server's envelopes are api.linear.app's, -# but for the newline that ends each there. def _apollo(message: bytes) -> bytes: """Apollo Server's refusal of a body as Linear writes it around ``message``, already JSON-escaped.""" @@ -873,6 +867,12 @@ def _status(answer: bytes) -> int: _NO_KEYS = _apollo(b"POST body missing, invalid Content-Type, or JSON object has no keys.") _BATCHING = _apollo(b"Operation batching disabled.") +# The headers a body was sent with beyond `Content-Type: application/json`, the body, and the bytes +# Linear refused it with for the key, no credential and `nope` alike, at the status their `code` or +# `extensions.http.status` names (`_status`), or None where Linear served the query with the key and +# answered 401 without one, as `backlot.graphql.body` and `backlot.graphql.v8_json` read them. A +# `WithTheKey` row draws the 401 without the key. Apollo Server's envelopes are api.linear.app's, +# but for the newline that ends each there. _LINEAR_BODIES = [ pytest.param( b"{", @@ -1756,6 +1756,21 @@ def _status(answer: bytes) -> int: None, id="variables of a string its operation takes", ), + pytest.param( + b'{"query":"query($v: [ID!]) { issues(first: 1, filter: {id: {in: $v}}) { nodes { id } } }",' + b'"variables":{"v":[%s]}}' % b",".join([b"true"] * 60), + WithTheKey( + b'{"errors":[%s,{"message":"Internal server error","extensions":{"code":' + b'"INTERNAL_SERVER_ERROR","type":"internal error","userError":false}}]}' + % b",".join( + b'{"message":"Variable \\"$v\\" got invalid value true at \\"v[%d]\\"; ID cannot ' + b'represent value: true","locations":[{"line":1,"column":7}],"extensions":{"code":' + b'"BAD_USER_INPUT","type":"graphql error","userError":true}}' % n + for n in range(50) + ) + ), + id="sixty variables refused", + ), pytest.param( b'{"query":"mutation { __typename }"}', WithTheKey(b'{"data":{"__typename":"Mutation"}}', 200), From 27981467a9e44c07c39ec51bb5dd60449e8d9df4 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:42:14 +0900 Subject: [PATCH 25/52] fireflies: rows for a named operation, two operations' errors, a type definition and every path's error --- tests/test_fireflies.py | 110 +++++++++++++++++++++++++++++----------- 1 file changed, 81 insertions(+), 29 deletions(-) diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index 1852afff9..c4f9e9f53 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -131,28 +131,49 @@ def _apollo( _NO_KEYS = _apollo(b"POST body missing, invalid Content-Type, or JSON object has no keys.") -def _deeper(fields: int) -> bytes: - """A body whose query selects ``fields`` fields twelve deep in fields Fireflies does not - have.""" - selection = " ".join(f"l{n}" for n in range(fields)) - return b'{"query":"{ a{b{c{d{e{f{g{h{i{j{k{ %s }}}}}}}}}}} }"}' % selection.encode() +def _twelve_deep(fields: int, name: str = "l") -> str: + """A selection of ``fields`` fields twelve deep, ``l0`` on with ``name`` as ``l``, in fields + Fireflies does not have.""" + selection = " ".join(f"{name}{n}" for n in range(fields)) + return "{ a{b{c{d{e{f{g{h{i{j{k{ %s }}}}}}}}}}} }" % selection -def _columns(fields: int) -> list[int]: - """The column of each field twelve deep in :func:`_deeper`'s query.""" - query = json.loads(_deeper(fields))["query"] - return [query.index(f" l{n} ") + 2 for n in range(fields)] +def _query(query: str) -> bytes: + return json.dumps({"query": query}, separators=(",", ":")).encode() -def _too_deep(column: int) -> bytes: +def _too_deep(query: str, fields: int, name: str = "l", operation: str = "") -> list[bytes]: + """graphql-depth-limit's error at each field :func:`_twelve_deep` put in ``query``.""" + return [ + b'{"friendly":false,"message":"\'%s\' exceeds maximum operation depth of 10","locations":' + b'[{"line":1,"column":%d}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}' + % (operation.encode(), query.index(f" {name}{n} ") + 2) + for n in range(fields) + ] + + +def _validation(*errors: bytes) -> bytes: + return b'{"errors":[%s]}' % b",".join(errors) + + +_INTERNAL = ( + b'{"errors":[{"friendly":false,"message":"Internal server error","code":"INTERNAL_SERVER_ERROR",' + b'"extensions":{}}]}' +) + + +def _no_field_a(column: int) -> bytes: return ( - b'{"friendly":false,"message":"\'\' exceeds maximum operation depth of 10","locations":' + b'{"friendly":false,"message":"Cannot query field \\"a\\" on type \\"Query\\".","locations":' b'[{"line":1,"column":%d}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}' % column ) -def _validation(*errors: bytes) -> bytes: - return b'{"errors":[%s]}' % b",".join(errors) +_TOO_MANY_ERRORS = ( + b'{"friendly":false,"message":"Too many validation errors, error limit reached. Validation' + b' aborted.","code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}' +) +_TWO_FIFTIES = "query A %s query B %s" % (_twelve_deep(50), _twelve_deep(50, "m")) _BATCHING = _apollo(b"Operation batching disabled.") @@ -678,32 +699,57 @@ def _validation(*errors: bytes) -> bytes: id="a fragment too deep spread twice", ), pytest.param( - _deeper(99), + _query(_twelve_deep(99)), + WithTheKey(_validation(*_too_deep(_twelve_deep(99), 99), _no_field_a(3))), + id="99 fields twelve deep", + ), + pytest.param( + _query(_twelve_deep(100)), + WithTheKey(_validation(*_too_deep(_twelve_deep(100), 100), _TOO_MANY_ERRORS)), + id="100 fields twelve deep", + ), + pytest.param( + _query(_twelve_deep(101)), + WithTheKey(_INTERNAL, 500), + id="101 fields twelve deep", + ), + pytest.param( + _query(_TWO_FIFTIES), WithTheKey( _validation( - *(_too_deep(column) for column in _columns(99)), - b'{"friendly":false,"message":"Cannot query field \\"a\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}', + *_too_deep(_TWO_FIFTIES, 50, operation="A"), + *_too_deep(_TWO_FIFTIES, 50, "m", "B"), + _TOO_MANY_ERRORS, ) ), - id="99 fields twelve deep", + id="50 fields twelve deep in each of two operations", + ), + pytest.param( + _query("query A %s query B %s" % (_twelve_deep(60), _twelve_deep(60, "m"))), + WithTheKey(_INTERNAL, 500), + id="60 fields twelve deep in each of two operations", ), pytest.param( - _deeper(100), + _query("query A %s" % _twelve_deep(1)), WithTheKey( _validation( - *(_too_deep(column) for column in _columns(100)), - b'{"friendly":false,"message":"Too many validation errors, error limit reached. Validation aborted.","code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}', + *_too_deep("query A %s" % _twelve_deep(1), 1, operation="A"), + _no_field_a(11), ) ), - id="100 fields twelve deep", + id="a named operation twelve deep", + ), + pytest.param( + b'{"query":"query B { ...X } query A { __typename }"}', + WithTheKey(_INTERNAL, 500), + id="an undefined spread in an operation of another name", ), pytest.param( - _deeper(101), + b'{"query":"type Foo { a: Int } { __typename }"}', WithTheKey( - b'{"errors":[{"friendly":false,"message":"Internal server error","code":"INTERNAL_SERVER_ERROR","extensions":{}}]}', - 500, + b'{"errors":[{"friendly":false,"message":"The \\"Foo\\" definition is not executable.","locations":[{"line":1,"column":1}],"code":"GRAPHQL_VALIDATION_FAILED","extensions":{}}]}' ), - id="101 fields twelve deep", + id="a type definition beside an operation", ), ] @@ -738,16 +784,22 @@ def test_fireflies_orders_the_body_and_the_credential( assert (r.status_code, r.json()) == (200, {"data": {"__typename": "Query"}}) -def test_fireflies_reads_a_fragment_once_however_many_spreads_reach_it(): +@pytest.mark.parametrize( + "last, answer", + [("__typename", []), ("a{b{c{d{e{f{g{h{i{j{k{ l }}}}}}}}}}}", 500)], + ids=["served", "an error on each path"], +) +def test_fireflies_reads_a_fragment_once_however_many_spreads_reach_it(last, answer): """A document whose first twenty-five fragments each spread the next one twice reaches the twenty-sixth by 2**25 paths, and `_depth_limit` reads each fragment once a depth rather than - once a path.""" + once a path, and stops at the 101st error the paths report.""" from backlot.routers import fireflies document = " ".join(f"fragment F{i} on Query {{ ...F{i + 1} ...F{i + 1} }}" for i in range(25)) - document += " fragment F25 on Query { __typename } { ...F0 }" + document += f" fragment F25 on Query {{ {last} }} {{ ...F0 }}" started = time.perf_counter() - assert fireflies._depth_limit(parse(document)) == [] + reported = fireflies._depth_limit(parse(document)) + assert (reported if isinstance(reported, list) else reported.status) == answer assert time.perf_counter() - started < 1 From ac90a8fb637dcb9f1a44a6e05ff1ffc9a2267440 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:47:58 +0900 Subject: [PATCH 26/52] fireflies: the crawl holds no more than the errors it stops at, and the 101st across two operations throws --- tests/test_fireflies.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index c4f9e9f53..493e853a0 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -10,6 +10,7 @@ import json import sqlite3 import time +import tracemalloc import pytest from graphql import build_client_schema, parse, validate @@ -725,9 +726,9 @@ def _no_field_a(column: int) -> bytes: id="50 fields twelve deep in each of two operations", ), pytest.param( - _query("query A %s query B %s" % (_twelve_deep(60), _twelve_deep(60, "m"))), + _query("query A %s query B %s" % (_twelve_deep(50), _twelve_deep(51, "m"))), WithTheKey(_INTERNAL, 500), - id="60 fields twelve deep in each of two operations", + id="50 and 51 fields twelve deep in two operations", ), pytest.param( _query("query A %s" % _twelve_deep(1)), @@ -792,15 +793,20 @@ def test_fireflies_orders_the_body_and_the_credential( def test_fireflies_reads_a_fragment_once_however_many_spreads_reach_it(last, answer): """A document whose first twenty-five fragments each spread the next one twice reaches the twenty-sixth by 2**25 paths, and `_depth_limit` reads each fragment once a depth rather than - once a path, and stops at the 101st error the paths report.""" + once a path, and holds no more than the 101 errors the paths report before it throws.""" from backlot.routers import fireflies document = " ".join(f"fragment F{i} on Query {{ ...F{i + 1} ...F{i + 1} }}" for i in range(25)) - document += f" fragment F25 on Query {{ {last} }} {{ ...F0 }}" + document = parse(document + f" fragment F25 on Query {{ {last} }} {{ ...F0 }}") started = time.perf_counter() - reported = fireflies._depth_limit(parse(document)) + tracemalloc.start() + try: + reported = fireflies._depth_limit(document) + held = tracemalloc.get_traced_memory()[1] + finally: + tracemalloc.stop() assert (reported if isinstance(reported, list) else reported.status) == answer - assert time.perf_counter() - started < 1 + assert (time.perf_counter() - started < 1, held < 2**20) == (True, True) def test_fireflies_admin_crawl_sees_every_stored_transcript(client, admin_h, ro_conn): From 758720b0ab10125b2fbee9abdb2536a2f80c0a33 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:55:28 +0900 Subject: [PATCH 27/52] graphql: the run comment, the depth rule's docstring and a test docstring say what they point at --- backlot/graphql/engine.py | 5 ++--- backlot/routers/fireflies.py | 4 ++-- backlot/routers/linear.py | 4 ++-- tests/test_graphql.py | 3 +-- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index b79a4fad9..dc439286b 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -462,9 +462,8 @@ def run(self, operation: Operation, *, context: Any = None) -> Result: name: _ids_as_graphql_js_reads_them(value, types.get(name)) for name, value in variables.items() } - # An operation on a root type the SDL leaves out runs against the schema `_with_roots` - # made, where its validated selection can only be `__typename`, or where graphql-core - # refuses it when the vendor has no such root type either. + # A root type only `self._checked` has runs there, and one neither schema has is + # graphql-core's refusal. on_a_root = getattr(self.schema, f"{operation.operation.operation.value}_type") is None result = execute_sync( self._checked if on_a_root else self.schema, diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index 6e44fd1e2..c94b66c91 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -203,8 +203,8 @@ class _Thrown(Exception): def _depth_limit(doc: DocumentNode) -> engine.Result | list[GraphQLError]: """graphql-depth-limit's rule with a ``maxDepth`` of :data:`_MAX_DEPTH`, which Fireflies runs - as graphql-js starts validating ``doc``: the errors it reports, which come ahead of graphql-js's - own, or Fireflies' 500 where it throws. + as graphql-js starts validating ``doc``, for :meth:`backlot.graphql.engine.Engine.resolve` to + take as ``on_document``: the depth errors, or Fireflies' 500 where the rule throws. It crawls each operation, the last of each name and the unnamed ones as the last one named ``''``: a field's selections one deeper, but none of a field whose name starts ``__``, and a diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 4e883ac4e..73d4c9d1b 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -240,8 +240,8 @@ def _envelope(result: engine.Result, payload: Any) -> dict: body with no query under Linear's ``INVALID_INPUT``, a persisted query refused for its version or its hash as Linear's ``Internal server error``, an operation that cannot be chosen as :func:`_operation_missing`, and the request error after fifty refused variables, to which - Apollo Server gives no code of its own, as :func:`_internal`, as api.linear.app answered it - on 2026-10-26 (UTC); and each error in ``result.assigned`` as :func:`_internal`.""" + Apollo Server gives no code of its own, as :func:`_internal` (api.linear.app, 2026-10-26 + UTC); and each error in ``result.assigned`` as :func:`_internal`.""" if result.refusals: errors = [] for refusal in result.refusals: diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 14bc6a6df..fc3497f2a 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -203,8 +203,7 @@ def test_resolver_error_nulls_the_field_and_reports_its_path(gql): ], ) def test_a_resolver_error_is_located_as_graphql_js_locates_it(gql, query, location): - """A column counts UTF-16 code units and a line breaks at a line feed or a carriage return - alone, as api.linear.app located its ``Entity not found`` on 2026-10-26 (UTC).""" + """A resolver's error carries the locations `js_words.locations` gives it.""" assert gql.execute(query).payload["errors"][0]["locations"] == [location] From 79ec35ec2b064da43ed8633b36fef4824b5ac9e1 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:18:15 +0900 Subject: [PATCH 28/52] linear: the not-found rows sit in the body table, and the router's docstrings name the introspection exception and the member order --- backlot/routers/linear.py | 15 +++--- tests/test_linear.py | 102 +++++++++++++++++++------------------- 2 files changed, 60 insertions(+), 57 deletions(-) diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 7db757a41..af9c0743f 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -21,11 +21,13 @@ string ``variables``, an ``extensions`` or ``operationName`` of ``5``, a persisted query's hash alone or one that does not match, a document that does not parse or validate and one of two operations with no ``operationName`` drew what the key drew on 2026-10-25 (UTC), and a document -missing a variable the 401. An API key under ``Bearer`` is refused ahead of the body: with -``Bearer ``, ``{``, an empty body and a body holding ``NaN`` drew -:func:`_api_key_as_bearer`'s 400 on 2026-10-18, and ``charset=latin1``, ``Content-Encoding: bogus`` -and a body past the size limit did on 2026-10-19, when a key in an API key's shape that Linear never -issued drew it under ``Bearer`` with ``{``, ``NaN``, ``5``, ``charset=latin1`` and an empty body. +missing a variable the 401, but for one that selects only ``__schema`` and ``__type`` +(:func:`_introspection_only`), which drew the variable's refusal as with the key. An API key under +``Bearer`` is refused ahead of the body: with ``Bearer ``, ``{``, an empty body and a body +holding ``NaN`` drew :func:`_api_key_as_bearer`'s 400 on 2026-10-18, and ``charset=latin1``, +``Content-Encoding: bogus`` and a body past the size limit did on 2026-10-19, when a key in an API +key's shape that Linear never issued drew it under ``Bearer`` with ``{``, ``NaN``, ``5``, +``charset=latin1`` and an empty body. """ from __future__ import annotations @@ -279,7 +281,8 @@ def _envelope(result: engine.Result, payload: Any) -> dict: or its hash as Linear's ``Internal server error``, an operation that cannot be chosen as :func:`_operation_missing`, and the request error after fifty refused variables, to which Apollo Server gives no code of its own, as :func:`_internal` (api.linear.app, 2026-10-26 - UTC); and each error in ``result.assigned`` as :func:`_internal`.""" + UTC); each error in ``result.assigned`` as :func:`_internal`, and each other field error in + Linear's member order (:func:`_in_linears_order`).""" if result.refusals: errors = [] for refusal in result.refusals: diff --git a/tests/test_linear.py b/tests/test_linear.py index 923a103c5..916766a68 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -720,113 +720,111 @@ def test_linear_by_id_relation_roots_answer(client, admin_h): assert label["groupType"] is None -# Each by-id root, an id it holds nothing for, and the message and presentable text of Linear's -# error for it (`linear_resolvers._not_found`, `linear_resolvers._ATTACHMENT`). +def _unknown_id(root: str, id_: str, message: str, presentable: str, row: str): + """A `_LINEAR_BODIES` row: the by-id root ``root`` sent an id it holds nothing for, and Linear's + error for it with ``message`` and ``presentable`` (`linear_resolvers._not_found`, + `linear_resolvers._ATTACHMENT`).""" + body = {"query": "query($x: String!) { %s(id: $x) { id } }" % root, "variables": {"x": id_}} + error = { + "message": message, + "path": [root], + "locations": [{"line": 1, "column": 22}], + "extensions": { + "type": "invalid input", + "code": "INPUT_ERROR", + "statusCode": 400, + "userError": True, + "userPresentableMessage": presentable, + }, + } + answer = node_json({"errors": [error], "data": None}) + return pytest.param(json.dumps(body).encode(), WithTheKey(answer, 200), id=row) + + _UNKNOWN_IDS = [ - pytest.param( + _unknown_id( "issue", "00000000-0000-0000-0000-000000000000", "Entity not found: Issue", "Could not find referenced Issue.", - id="issue 00000000", + "issue 00000000", ), - pytest.param( + _unknown_id( "issue", "NOPE-1", "Entity not found: Issue", "Could not find referenced Issue.", - id="issue NOPE-1", + "issue NOPE-1", ), - pytest.param( - "team", "nope", "Entity not found: Team", "Could not find referenced Team.", id="team nope" + _unknown_id( + "team", "nope", "Entity not found: Team", "Could not find referenced Team.", "team nope" ), - pytest.param( + _unknown_id( "user", "00000000-0000-0000-0000-000000000000", "Entity not found: User", "Could not find referenced User.", - id="user 00000000", + "user 00000000", ), - pytest.param( + _unknown_id( "workflowState", "nope", "Entity not found: WorkflowState", "Could not find referenced WorkflowState.", - id="workflowState nope", + "workflowState nope", ), - pytest.param( + _unknown_id( "project", "00000000-0000-0000-0000-000000000000", "Entity not found: Project", "Could not find referenced Project.", - id="project 00000000", + "project 00000000", ), - pytest.param( + _unknown_id( "issueLabel", "nope", "Entity not found: IssueLabel", "Could not find referenced IssueLabel.", - id="issueLabel nope", + "issueLabel nope", ), - pytest.param( + _unknown_id( "cycle", "00000000-0000-0000-0000-000000000000", "Entity not found: Cycle", "Could not find referenced Cycle.", - id="cycle 00000000", + "cycle 00000000", ), - pytest.param( + _unknown_id( "release", "nope", "Entity not found: Release", "Could not find referenced Release.", - id="release nope", + "release nope", ), - pytest.param( + _unknown_id( "issueRelation", "nope", "Entity not found: IssueRelation", "Could not find referenced IssueRelation.", - id="issueRelation nope", + "issueRelation nope", ), - pytest.param( + _unknown_id( "teamMembership", "00000000-0000-0000-0000-000000000000", "Entity not found: TeamMembership", "Could not find referenced TeamMembership.", - id="teamMembership 00000000", + "teamMembership 00000000", ), - pytest.param( + _unknown_id( "attachment", "00000000-0000-0000-0000-000000000000", "Entity not found: Attachment: could not find by url.", "Could not find referenced attachment with this url.", - id="attachment 00000000", + "attachment 00000000", ), ] -@pytest.mark.parametrize("root, id_, message, presentable", _UNKNOWN_IDS) -def test_linear_answers_an_id_it_does_not_hold_as_linear_does( - client, admin_h, root, id_, message, presentable -): - body = {"query": "query($x: String!) { %s(id: $x) { id } }" % root, "variables": {"x": id_}} - r = client.post("/linear/graphql", json=body, headers=admin_h) - error = { - "message": message, - "path": [root], - "locations": [{"line": 1, "column": 22}], - "extensions": { - "type": "invalid input", - "code": "INPUT_ERROR", - "statusCode": 400, - "userError": True, - "userPresentableMessage": presentable, - }, - } - assert (r.status_code, r.content) == (200, node_json({"errors": [error], "data": None})) - - def test_linear_workflow_states_are_per_team(client, admin_h): """Two teams' identically-named states are different objects in Linear, so their ids differ. The corpus has no shared state name, so assert the construction directly instead.""" @@ -960,10 +958,11 @@ def _status(answer: bytes) -> int: _BATCHING = _apollo(b"Operation batching disabled.") # The headers a body was sent with beyond `Content-Type: application/json`, the body, and the bytes # Linear answered it with for the key, no credential and `nope` alike, at the status their `code` or -# `extensions.http.status` names (`_status`), or None where Linear served the query with the key and -# answered 401 without one, as `backlot.graphql.body` and `backlot.graphql.v8_json` read them. A -# `WithTheKey` row draws the 401 without the key. Apollo Server's envelopes are api.linear.app's, -# but for the newline that ends each there. +# `extensions.http.status` names, 400 for an error naming neither and 200 for an answer with no +# `errors` (`_status`), or None where Linear served the query with the key and answered 401 without +# one, as `backlot.graphql.body` and `backlot.graphql.v8_json` read them. A `WithTheKey` row draws +# the 401 without the key. Apollo Server's envelopes are api.linear.app's, but for the newline that +# ends each there. _LINEAR_BODIES = [ pytest.param( b"{", @@ -2021,6 +2020,7 @@ def _status(answer: bytes) -> int: b'{"errors":[{"message":"Cannot spread fragment \\"F\\" within itself.","locations":[{"line":1,"column":23}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}', id="a fragment that spreads itself", ), + *_UNKNOWN_IDS, ] From c0a27ece7cde8f1fc97455f81db3a4e6e766dd9c Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:34:34 +0900 Subject: [PATCH 29/52] linear, fireflies: an empty Origin is no preflight, Fastify's plain text is counted in UTF-8, and the HTTP layer's docstrings name each answer --- backlot/graphql/apollo_http.py | 14 +++++++------- backlot/graphql/engine.py | 5 +++-- backlot/routers/fireflies.py | 29 +++++++++++++++++++---------- backlot/routers/linear.py | 10 ++++++---- docs/supported-sources.md | 8 ++++---- tests/test_acl.py | 6 ++---- tests/test_fireflies.py | 31 +++++++++++++++++++++++++++++++ tests/test_linear.py | 29 ++++++++++++++++++++++++++++- tests/test_telemetry.py | 4 ++-- 9 files changed, 102 insertions(+), 34 deletions(-) diff --git a/backlot/graphql/apollo_http.py b/backlot/graphql/apollo_http.py index 2fa595461..2696d85be 100644 --- a/backlot/graphql/apollo_http.py +++ b/backlot/graphql/apollo_http.py @@ -3,14 +3,14 @@ Both vendors run it. :func:`csrf_refusal` is its check that a request was preflighted, :func:`search_payload` its reading of a GET's query string, :func:`method_refusal` its 405 for a -method it does not serve, :func:`get_content_type_refusal` the 415 Apollo Server 5.5 added for a -GET carrying a content type, :func:`response_type` its choice of media type from ``Accept`` and +method it does not serve, :func:`get_content_type_refusal` its 415 for a GET carrying a content +type other than JSON, :func:`response_type` its choice of media type from ``Accept`` and :data:`NOT_ACCEPTABLE` its 406 when there is none, and :func:`prefers_html` and -:func:`landing_page` its page for a browser. Each refusal is thrown before the request pipeline, so -it takes whichever media type ``Accept`` allows and ``application/json`` where it allows none: a -:class:`~backlot.graphql.engine.Refusal` that is ``thrown``. A refusal the pipeline makes, from a -persisted query's to a variable's, is refused for the ``Accept`` in turn, with -:data:`NOT_ACCEPTABLE`. +:func:`landing_page` its page for a browser. Each refusal but :data:`NOT_ACCEPTABLE` is thrown +before the request pipeline, so it takes whichever media type ``Accept`` allows and +``application/json`` where it allows none: a :class:`~backlot.graphql.engine.Refusal` that is +``thrown``. A refusal the pipeline makes, from a persisted query's to a variable's, is refused for +the ``Accept`` in turn, with :data:`NOT_ACCEPTABLE`. The code is read off Apollo Server 5.5.1's ``runHttpQuery.ts``, ``preventCsrf.ts`` and ``ApolloServer.ts``, and the libraries they call: ``whatwg-mimetype`` 4.0.0 (:func:`mime_essence`) diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index 581c0b1b3..c2efa3a9a 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -110,8 +110,9 @@ def _ids_as_graphql_js_reads_them(value: Any, type_: Any) -> Any: class Refusal: """One request error as Apollo Server answers it: ``message`` in graphql-js's words, the ``locations`` graphql-js gives it, Apollo Server's ``code``, the status of the answer and the - ``headers`` it sets on it. ``thrown`` marks a refusal Apollo Server throws before its request - pipeline, which ``Accept`` cannot turn into its 406 (:mod:`backlot.graphql.apollo_http`), and + ``headers`` it sets on it. ``thrown`` marks a refusal Apollo Server throws, each but + :data:`~backlot.graphql.apollo_http.NOT_ACCEPTABLE` before its request pipeline, which + ``Accept`` cannot turn into its 406 (:mod:`backlot.graphql.apollo_http`), and ``http_headers`` whether the ``http`` extension it carries names its ``headers``, as all but the 406's do.""" diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index 43f073f62..511cdd6c9 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -3,7 +3,8 @@ Fireflies is **GraphQL only** — there is no REST surface to emulate — so this router is one endpoint. The schema and the resolvers live in ``backlot/graphql/`` (``fireflies.graphql`` + ``fireflies_resolvers.py``); everything here is HTTP: a request and credentials in, a GraphQL -envelope out, or its HTTP layer's for a body it cannot read (:func:`_body_refusal`). +envelope out, or the answer of a layer in front of Apollo Server's run, in the order +:func:`graphql` lists them. Auth is the bearer path (``Authorization: Bearer ``), which is what the vendor's own quickstart shows for all four of its raw-HTTP examples, read by @@ -51,6 +52,7 @@ _UNSUPPORTED = "Unsupported Media Type" _NOT_JSON = "Body is not valid JSON but content-type is set to 'application/json'" _TOO_LARGE = "Request body is too large" +_SIZE_MISMATCH = "Request body size did not match Content-Length" #: The most bytes Fireflies reads: api.fireflies.ai served a body of 1048576 bytes and answered one #: of 1048577 with :data:`_TOO_LARGE`'s 413 on 2026-10-19, with a credential, with none and with #: ``Authorization: nope``. @@ -211,18 +213,20 @@ def _answer(seen: dict[str, str], result: engine.Result) -> Response: def _preflight(request: Request) -> Response: """``@fastify/cors``'s answer to an `OPTIONS`, ahead of the body and the credential: its 204, with :data:`_PREFLIGHT` and `Access-Control-Request-Headers` echoed back as Node reads it, to - one carrying both `Origin` and `Access-Control-Request-Method`, and its 400 to any other. - api.fireflies.ai answered so with no credential and `Bearer nope`, with - `Access-Control-Request-Method` naming `POST` or `PUT`, with `Origin: null`, and with a JSON - body or `{`; it echoed ` a , b ` as `a , b`, `a` and `b` in two copies as `a, b`, and a value - with a space, a tab, U+0085 or U+00A0 inside it, U+00A0 at either end or U+0085 after it, as - sent.""" - if "origin" not in request.headers or "access-control-request-method" not in request.headers: + one carrying both `Origin` and `Access-Control-Request-Method` with more than spaces and tabs in + them, and its 400 to any other. api.fireflies.ai answered so with no credential and + `Bearer nope`, with `Access-Control-Request-Method` naming `POST` or `PUT`, with `Origin: null`, + and with a JSON body or `{`; it echoed ` a , b ` as `a , b`, `a` and `b` in two copies as + `a, b`, and a value with a space, a tab, U+0085 or U+00A0 inside it, U+00A0 at either end or + U+0085 after it, as sent. It answered an empty or blank `Origin` and an empty + `Access-Control-Request-Method` with the 400 on 2026-10-26 (UTC).""" + seen = apollo_http.headers_seen(request.headers.raw) + if "origin" not in seen or "access-control-request-method" not in seen: return Response( b"Invalid Preflight Request", status_code=400, headers={"content-type": "text/plain"} ) response = Response(status_code=204) - asked = apollo_http.headers_seen(request.headers.raw).get("access-control-request-headers") + asked = seen.get("access-control-request-headers") echoed = () if asked is None else (("access-control-allow-headers", asked),) response.raw_headers += [(k.encode(), v.encode("latin-1")) for k, v in (*_PREFLIGHT, *echoed)] return response @@ -277,7 +281,12 @@ async def _fastify_read(request: Request, routed: bool) -> Any: if len(raw) > _LIMIT: return _body_refusal(_TOO_LARGE, 413) text = raw.decode("utf-8", "replace") - return text if media_type == "text/plain" else request_body.form_fields(text) + if media_type != "text/plain": + return request_body.form_fields(text) + # Fastify reads plain text as UTF-8 and counts the text's own UTF-8 bytes against the body's: + # `text/plain` holding the byte 0xE9, with a charset or none, by POST and by PUT, was the 400 on + # 2026-10-26 (UTC), where a form holding it was served. + return text if len(text.encode("utf-8")) == len(raw) else _body_refusal(_SIZE_MISMATCH) def _entry(error: dict, code: str, friendly: bool, extensions: dict) -> dict: diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 9dd4b3ba4..ced584028 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -3,7 +3,8 @@ Linear is **GraphQL only** — there is no REST surface to emulate — so this router is one endpoint. The schema and the resolvers live in ``backlot/graphql/`` (``linear.graphql`` + ``linear_resolvers.py``); everything here is HTTP: a request and credentials in, a GraphQL -envelope out, or Express's for the bodies :func:`_express` answers. +envelope out, or the answer of a layer in front of Apollo Server's run, in the order :func:`graphql` +lists them. Auth is the one place Linear differs from every other source in this repo. A personal API key travels as the **bare** header value (``Authorization: lin_api_…``, no scheme), read by @@ -47,7 +48,7 @@ router = APIRouter(prefix="/linear", tags=["linear"]) -# The media type of Express's `res.json`, which writes each answer made ahead of Apollo Server. +# The media type of Express's `res.json`, in which `_api_key_as_bearer` and `_express` answer. _JSON = "application/json; charset=utf-8" # Linear's code for a body with no query, where Apollo Server's is `BAD_REQUEST`. @@ -106,7 +107,8 @@ def _express(message: str, status: int = 400, length: int | None = None) -> Resp #: The methods Linear's load balancer answers with an empty 501 before Linear sees them, as #: api.linear.app answered each on 2026-10-25 (UTC). Of the rest of :data:`apollo_http.METHODS`, -#: Cloudflare answers those ``backlot.main._CLOUDFLARE_METHOD`` holds and Apollo Server the others. +#: Cloudflare answers those ``backlot.main._CLOUDFLARE_METHOD`` holds and the layers :func:`graphql` +#: lists the others. _LOAD_BALANCER_501 = frozenset( {"BIND", "LINK", "M-SEARCH", "MKCALENDAR", "REBIND", "SOURCE", "UNBIND", "UNLINK"} ) @@ -129,7 +131,7 @@ async def graphql(request: Request): method = request.method if method in _LOAD_BALANCER_501: return Response(status_code=501) - if method == "OPTIONS" and "origin" in request.headers: + if method == "OPTIONS" and "origin" in apollo_http.headers_seen(request.headers.raw): return _preflight(request) if method != "OPTIONS" and auth.linear_api_key_under_bearer(request): return _api_key_as_bearer() diff --git a/docs/supported-sources.md b/docs/supported-sources.md index 7c47828cc..f8505b1ae 100644 --- a/docs/supported-sources.md +++ b/docs/supported-sources.md @@ -87,10 +87,10 @@ what `search` answers a `GET` or `HEAD`, but for a 401 and a refusal of its `Acc ### Fireflies — `/fireflies/graphql` -**GraphQL only**, one endpoint: a `POST` with a JSON body, or a `GET` with the query in its query -string and a JSON `Content-Type` or none. Apollo Server serves a `GET` with none only beside an -`apollo-require-preflight` or `x-apollo-operation-name` header, its check that a browser -preflighted the request. Root `Query` fields: +**GraphQL only**, one endpoint: a `POST` with a JSON or form body, or a `GET` with the query in its +query string and a JSON `Content-Type` or none. Apollo Server serves a form, or a `GET` with no +`Content-Type`, only beside an `apollo-require-preflight` or `x-apollo-operation-name` header, its +check that a browser preflighted the request. Root `Query` fields: | Query field | Notes | |---|---| diff --git a/tests/test_acl.py b/tests/test_acl.py index b8eafde53..55e3562f2 100644 --- a/tests/test_acl.py +++ b/tests/test_acl.py @@ -8,15 +8,13 @@ from backlot import store from tests._helpers import client_for, gql -# The two ways but a POST of JSON a document reaches each GraphQL server's resolvers: a GET with it -# in the query string, and on Fireflies a POST of a form, each sent with Apollo Server's preflight -# header (`backlot.graphql.apollo_http.csrf_refusal`). _ADMIN = "admin-service-token" def _sent(client, path, query, authorization, via, **variables): """``query`` sent to ``path`` as ``via`` names: ``POST`` as JSON (:func:`gql`), ``GET`` in the - query string and ``form`` as a form, the last two beside the preflight header.""" + query string and, on Fireflies, ``form`` as a form, the last two beside the preflight header + (`backlot.graphql.apollo_http.csrf_refusal`).""" if via == "POST": return gql(client, path, query, authorization, **variables) fields = {"query": query, **({"variables": json.dumps(variables)} if variables else {})} diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index 784edd5cc..24e64c88a 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -195,6 +195,10 @@ def _no_field_a(column: int) -> bytes: b'{"statusCode":413,"status":false,"code":"http","message":"Request body is too large",' b'"errorMessage":"Request body is too large"}' ) +_SIZE_MISMATCH = ( + b'{"statusCode":400,"status":false,"code":"http","message":"Request body size did not match' + b' Content-Length","errorMessage":"Request body size did not match Content-Length"}' +) _TYPENAME = b'{"query":"{ __typename }"}' @@ -1731,6 +1735,19 @@ def _not_found(method: str, target: str) -> bytes: ((), None, "Bearer nope", ""), ((("Origin", "https://example.com"),), None, "key", "Origin alone"), ((("Access-Control-Request-Method", "POST"),), None, "key", "no Origin"), + ((("Origin", ""), ("Access-Control-Request-Method", "POST")), None, "key", "Origin ''"), + ( + (("Origin", " "), ("Access-Control-Request-Method", "POST")), + None, + "key", + "Origin ' '", + ), + ( + (("Origin", "https://example.com"), ("Access-Control-Request-Method", "")), + None, + "key", + "Access-Control-Request-Method ''", + ), ((*_J, *_P), b"{", "key", "{"), ) ), @@ -1815,6 +1832,7 @@ def _not_found(method: str, target: str) -> bytes: ("PATCH", (*_J, *_P), None, "key", 400, _EMPTY, "empty"), ("PUT", (("Content-Type", "a/b/c"),), _TYPENAME, "key", 415, _UNSUPPORTED, "a/b/c"), ("PUT", (("Content-Type", ""),), _TYPENAME, "key", 415, _UNSUPPORTED, "an empty type"), + ("PUT", (("Content-Type", "text/plain"),), b"\xe9", "key", 400, _SIZE_MISMATCH, "0xE9"), ( "PUT", (("Content-Type", "text/plain"),), @@ -1834,6 +1852,19 @@ def _not_found(method: str, target: str) -> bytes: ("text/plain", (), _TYPENAME, "key", 400, _CSRF, ""), ("text/plain", (), _TYPENAME, "none", 400, _CSRF, ", none"), ("text/plain", _P, _TYPENAME, "key", 400, _NO_KEYS, ", preflight"), + ("text/plain", _P, b"\xe9", "key", 400, _SIZE_MISMATCH, ", preflight, 0xE9"), + ("text/plain", (), b"\xe9", "key", 400, _SIZE_MISMATCH, ", 0xE9"), + ("text/plain; charset=latin1", _P, b"\xe9", "key", 400, _SIZE_MISMATCH, ", 0xE9"), + ("text/plain", _P, "\u00e9".encode(), "key", 400, _NO_KEYS, ", preflight, U+00E9"), + ( + "application/x-www-form-urlencoded", + _P, + b"query=%7B__typename%7D&x=\xe9", + "key", + 200, + b'{"data":{"__typename":"Query"}}', + ", preflight, 0xE9", + ), ("TEXT/PLAIN;X=Y", (), _TYPENAME, "key", 400, _CSRF, ""), ("application/x-www-form-urlencoded", (), _TYPENAME, "key", 400, _CSRF, ""), ("", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), diff --git a/tests/test_linear.py b/tests/test_linear.py index 4acd0276c..1342dafc1 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -5919,7 +5919,8 @@ def _search_refusal(message: bytes) -> bytes: _LINEAR_HTTP = [ - # Cloudflare, then Linear's load balancer, ahead of everything. + # Cloudflare, then Linear's load balancer, ahead of everything; then Apollo Server's 405 for + # each other method. http_row( "TRACE", "", @@ -6069,6 +6070,32 @@ def _search_refusal(message: bytes) -> bytes: ) ), http_row("OPTIONS", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="OPTIONS"), + *( + http_row( + "OPTIONS", + "", + (("Origin", origin), ("Access-Control-Request-Method", "POST")), + None, + c, + 400, + _JSON_UTF8, + _CSRF, + id=f"Origin {origin!r}, {c}", + ) + for origin in ("", " ") + for c in ("key", "none") + ), + http_row( + "OPTIONS", + "", + (("Origin", ""), *_J), + None, + "key", + 405, + _JSON_UTF8, + _ONLY_GET_POST, + id="Origin '', JSON", + ), http_row( "OPTIONS", "", (), None, "Bearer key", 400, _JSON_UTF8, _CSRF, id="OPTIONS, Bearer key" ), diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index d9515c506..91dd7e77a 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -598,8 +598,8 @@ def test_an_error_code_is_the_envelopes_own_identifier(source, status, body, exp {"headers": LINEAR | JSON, "content": b"{"}, ("linear", "unparsed", "unknown", 400, None, "client"), ), - # The HTTP layer in front of each GraphQL server answers methods but POST, a GET's query - # among them, and Fireflies' credential's 500 is a refusal of the credential. + # The HTTP layer in front of each GraphQL server answers each method and `Accept`, a GET's + # query among them, and Fireflies' credential's 500 is a refusal of the credential. ( "GET", "/fireflies/graphql" + _TYPENAME_SEARCH, From b240ac1750d891d83714becd6d25af811e2e967c Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:36:28 +0900 Subject: [PATCH 30/52] fireflies: a row for plain text past the limit holding a byte that is not UTF-8 --- backlot/routers/fireflies.py | 2 +- tests/test_fireflies.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index 511cdd6c9..80a6921a2 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -285,7 +285,7 @@ async def _fastify_read(request: Request, routed: bool) -> Any: return request_body.form_fields(text) # Fastify reads plain text as UTF-8 and counts the text's own UTF-8 bytes against the body's: # `text/plain` holding the byte 0xE9, with a charset or none, by POST and by PUT, was the 400 on - # 2026-10-26 (UTC), where a form holding it was served. + # 2026-10-26 (UTC), where a form holding it was served and 0xE9 past the limit was the 413. return text if len(text.encode("utf-8")) == len(raw) else _body_refusal(_SIZE_MISMATCH) diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index 24e64c88a..ad7d12aae 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -1883,6 +1883,15 @@ def _not_found(method: str, target: str) -> bytes: ), ("application/json ,", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), ("text/plain", (), _PAST_THE_LIMIT, "key", 413, _TOO_LARGE, ", past the limit"), + ( + "text/plain", + _P, + b"\xe9" * 2**20 + b"\xe9", + "key", + 413, + _TOO_LARGE, + ", past the limit, 0xE9", + ), ( "application/x-www-form-urlencoded", (), From a933bf3f9556b24eb7f8a5a52caac68ff7c9234a Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:48:27 +0900 Subject: [PATCH 31/52] linear, fireflies: a path is routed once its dot segments are resolved, Cloudflare's broken escape comes ahead of a raw byte, and the path docstrings name each answer --- backlot/main.py | 10 ++------- backlot/request_target.py | 16 +++++++++++++++ backlot/routers/fireflies.py | 7 +++---- backlot/routers/linear.py | 39 +++++++++++++++++++++++++++++------- docs/supported-sources.md | 4 ++-- tests/test_cross_vendor.py | 5 +++-- tests/test_linear.py | 17 ++++++++++++++++ tests/test_request_target.py | 13 ++++++++++++ 8 files changed, 88 insertions(+), 23 deletions(-) diff --git a/backlot/main.py b/backlot/main.py index 76a2db48d..49e7cdf76 100644 --- a/backlot/main.py +++ b/backlot/main.py @@ -770,8 +770,7 @@ async def route_a_linear_or_fireflies_path_as_its_front_does(request: Request, c (``linear.express``, ``fireflies.unrouted``), each of which reads the path as sent off the raw path, which is left alone. Starlette's own routing is not asked: its pattern for the endpoint takes a path with a newline after it (`/graphql%0A`), and its `path` convertor none with a - newline inside it, where each vendor's front reads both as another path. The path the - telemetry names a call by is then the endpoint's where the endpoint answers.""" + newline inside it, where each vendor's front reads both as another path.""" vendor = request.scope["path"].split("/")[1] served = vendor == "linear" and linear.routes_to_apollo( linear.express_path(sent_path(request.scope)) @@ -1361,13 +1360,8 @@ async def refuse_a_repeated_authorization_on_linear_and_fireflies(request: Reque ) -#: A `%` two hex digits do not follow. -_BROKEN_ESCAPE = re.compile(rb"%(?![0-9A-Fa-f]{2})") - - def _a_broken_escape(scope: Scope) -> bool: - raw = scope.get("raw_path") or scope["path"].encode() - return b"%00" in raw or _BROKEN_ESCAPE.search(raw) is not None + return request_target.broken_escape(scope.get("raw_path") or scope["path"].encode()) @_middleware( diff --git a/backlot/request_target.py b/backlot/request_target.py index 3ad1b5f72..da44637ff 100644 --- a/backlot/request_target.py +++ b/backlot/request_target.py @@ -194,6 +194,20 @@ def _google(path_kinds: list[str], query_kinds: list[str]) -> Response | None: return None +#: A `%` two hex digits do not follow. +_BROKEN_ESCAPE = re.compile(rb"%(?![0-9A-Fa-f]{2})") + + +def broken_escape(path: bytes) -> bool: + """Whether ``path`` holds `%00` or a `%` two hex digits do not follow, which Cloudflare + refuses in front of Linear and Fireflies with :data:`CLOUDFLARE_PAGE` + (``backlot.main.refuse_a_broken_escape_on_linear_and_fireflies``), ahead of its answer to a raw + byte: api.linear.app and api.fireflies.ai answered `/a%zz` with a raw `é` before or after it, + with `\\x01` before it, and `/a%00` after a raw `é`, so on 2026-10-26 (UTC), where a raw `é` + alone drew the empty 400.""" + return b"%00" in path or _BROKEN_ESCAPE.search(path) is not None + + def _cloudflare(scope: Scope, path_kinds: list[str], query_kinds: list[str]) -> Response | None: """:data:`CLOUDFLARE_PAGE` where a `nul` or a `cr` is anywhere, and where `Authorization` comes more than once (``auth.repeated_authorization``); then an empty 400 with no `Content-Type` for @@ -313,6 +327,8 @@ def answer(scope: Scope) -> Response | None: return _google(in_path, in_query) if errors.atlassian.owns(path): return _atlassian(scope, in_path, in_query) + if any(_under(path, p) for p in ("/linear", "/fireflies")) and broken_escape(sent_path): + return _page(CLOUDFLARE_PAGE, "text/html") if any(_under(path, p) for p in ("/notion", "/hubspot", "/linear", "/fireflies")): return _cloudflare(scope, in_path, in_query) if errors.s3.owns(path): diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index c6d1690d1..633012671 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -262,10 +262,9 @@ def nginx_passes(path: str) -> bool: """Whether nginx in front of Fastify passes on ``path``, as a client sent it (:func:`~backlot.routers.sent_path`): once each escape is decoded and each run of slashes is one, it starts with `/graphql`, in any case, followed by a slash, by nothing or by a newline - that ends it. api.fireflies.ai answered with Fastify's 404 at `/GRAPHQL`, `/graphql/x`, - `//graphql`, `/%2Fgraphql`, `/graphql%2F` and `/graphql%0A`, and with nginx's - (:data:`_NGINX_404`) at `/graphqlx`, `/graphql.json`, `/graphql;x`, `/graphql%20`, - `/graphql%7E`, `/x/graphql` and `/`, on 2026-10-25 (UTC).""" + that ends it. api.fireflies.ai answered with Fastify's 404 at each path :func:`_not_found` + names, and with nginx's (:data:`_NGINX_404`) at `/graphqlx`, `/graphql.json`, `/graphql;x`, + `/graphql%20`, `/graphql%7E`, `/x/graphql` and `/`, on 2026-10-25 (UTC).""" decoded = re.sub(rb"/{2,}", b"/", urllib.parse.unquote_to_bytes(path)) return _NGINX_LOCATION.match(decoded) is not None diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index a2f4f5db1..95e855ab9 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -229,8 +229,8 @@ def express_path(path: str) -> str: that character, a run of slashes is one, each character of :data:`_BARE` sent bare is escaped in upper case, and any other escape is kept as sent. Express's 404 quoted `/%6Eosuch` as `/nosuch`, `//nosuch` and `/nosuch//x` as `/nosuch` and `/nosuch/x`, `/a\\b` as `/a/b`, `/a%2fb` and - `/a%3cb` as sent, and each other printable ASCII character but `%`, `?` and `#` as sent, on - 2026-10-25 (UTC).""" + `/a%3cb` as sent, each character of :data:`_BARE` escaped in upper case (`|` as `%7C`), and + each other printable ASCII character but `%`, `?` and `#` as sent, on 2026-10-25 (UTC).""" def one(match: re.Match[str]) -> str: char = chr(int(match[1], 16)) @@ -242,11 +242,36 @@ def one(match: re.Match[str]) -> str: def routes_to_apollo(path: str) -> bool: """Whether Express hands ``path`` (:func:`express_path`) to Apollo Server: `/graphql` in any - case, alone or with a slash after it and anything after that. api.linear.app served - `/GRAPHQL`, `/GraphQL/`, `/graphql/`, `/graphql/x` and `/Graphql/X/Y` and answered - `/graphqlx`, `/graphql.json`, `/graphql;x`, `/graphql%20` and `/graphql%2F` with Express's 404 - on 2026-10-25 (UTC).""" - return re.fullmatch(r"/graphql(?:/.*)?", path, re.ASCII | re.IGNORECASE) is not None + case, alone or with a slash after it and anything after that, once its dot segments are + resolved (:func:`_dots_resolved`). api.linear.app served `/GRAPHQL`, `/GraphQL/`, `/graphql/`, + `/graphql/x` and `/Graphql/X/Y` and answered `/graphqlx`, `/graphql.json`, `/graphql;x`, + `/graphql%20` and `/graphql%2F` with Express's 404 on 2026-10-25 (UTC). On 2026-10-26 it served + `/./graphql`, `/x/../graphql`, `/graphql/.`, `/graphql/x/..` and `/graphql/x/%2E%2E`, and + answered `/graphql/..`, `/graphql/../x`, `/graphql/%2e%2E/x` and `/graphql/x/../..` with + Express's 404 and `/../graphql` with Cloudflare's 400.""" + resolved = _dots_resolved(path) + return ( + resolved is not None + and re.fullmatch(r"/graphql(?:/.*)?", resolved, re.ASCII | re.IGNORECASE) is not None + ) + + +def _dots_resolved(path: str) -> str | None: + """``path`` with each `.` segment dropped and each `..` segment taking the one before it away, + as RFC 3986 removes dot segments, or None where a `..` has no segment before it to take.""" + out: list[str] = [] + segments = path.split("/")[1:] + for i, segment in enumerate(segments): + if segment in (".", ".."): + if segment == "..": + if not out: + return None + out.pop() + if i == len(segments) - 1: + out.append("") + else: + out.append(segment) + return "/" + "/".join(out) _GREETING = b"Hello from the Linear GraphQL API." diff --git a/docs/supported-sources.md b/docs/supported-sources.md index 411e6f7a3..99e15bd91 100644 --- a/docs/supported-sources.md +++ b/docs/supported-sources.md @@ -572,8 +572,8 @@ beside an `apollo-require-preflight` or `x-apollo-operation-name` header, its ch browser preflighted the request. Express hands Apollo Server `/graphql` in any case, alone or with a slash and more after it, once Linear's front has turned a backslash into a slash, folded a run of slashes into one and decoded an escape of a letter, a digit or one of `-._~`, so each of those -paths is the endpoint too; `/` answers a greeting, and any other path Express's 404 page. Root -`Query` fields: +paths is the endpoint too; `/` answers a `GET` or `HEAD` with a greeting, and any other method +there, or any other path, Express's 404 page. Root `Query` fields: | Query field | Notes | |---|---| diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index 03d3453e0..80ab9468c 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -339,8 +339,9 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( # A request to Linear or Fireflies with `Authorization` sent more than once, `{key}` standing for -# the vendor's own spelling of the admin token, or with a path that breaks an escape, each answered -# with the 400 page `backlot.main.refuse_a_repeated_authorization_on_linear_and_fireflies` and +# the vendor's own spelling of the admin token, or with a path holding `%00` or a broken escape, +# each answered with the 400 page +# `backlot.main.refuse_a_repeated_authorization_on_linear_and_fireflies` and # `refuse_a_broken_escape_on_linear_and_fireflies` record. The last four rows are the control: one # header reaches the vendor, a path that only starts with the vendor's name is not the vendor's, # with a broken escape in it or without, and a broken escape in the query string is not one in the diff --git a/tests/test_linear.py b/tests/test_linear.py index 5e9697f51..201cb48f5 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -6810,6 +6810,23 @@ def test_linear_answers_each_method_path_type_and_accept_as_linear_does( assert {name: r.headers.get(name) for name in expected_headers} == expected_headers +@pytest.mark.parametrize( + "path, served", + [ + *((p, True) for p in ("/./graphql", "/x/../graphql", "/graphql/.", "/graphql/x/..")), + ("/graphql/x/%2E%2E", True), + *((p, False) for p in ("/graphql/..", "/graphql/../x", "/graphql/%2e%2E/x")), + *((p, False) for p in ("/graphql/x/../..", "/../graphql")), + ], +) +def test_linears_front_routes_a_path_once_its_dot_segments_are_resolved(path, served): + """httpx resolves a dot segment before it sends a path, so `_LINEAR_HTTP` cannot send these: + whether Express hands each to Apollo Server, as `routes_to_apollo` has it.""" + from backlot.routers import linear + + assert linear.routes_to_apollo(linear.express_path(path)) is served + + @pytest.mark.parametrize("char", '"<>`{}') def test_linears_front_escapes_a_character_httpx_escapes_first(char): """The characters :func:`backlot.routers.linear.express_path` escapes that httpx escapes diff --git a/tests/test_request_target.py b/tests/test_request_target.py index 0cc8123ec..c1ddf6ce9 100644 --- a/tests/test_request_target.py +++ b/tests/test_request_target.py @@ -118,6 +118,19 @@ ("/notion/v1/", b"nope x?b=\x80x", "cloudflare empty"), ("/notion/v1/", b"nope\x01x?b= x", "cloudflare empty"), ("/notion/v1/", b"nope\x80y\x0dx", "cloudflare page"), + # Cloudflare's refusal of a broken escape in front of Linear and Fireflies, ahead of its answer + # to a raw byte (`request_target.broken_escape`) + *( + (base, rest, "cloudflare page") + for base in ("/linear/", "/fireflies/") + for rest in ( + b"a\xc3\xa9%zz", + b"a%zz\xc3\xa9", + b"a\x01%zz", + b"a\xc3\xa9%00", + b"a%zz?b=\xc3\xa9", + ) + ), ("/s3/no-such-bucket/", b"nope\x0dy x", "s3 empty"), ("/s3/no-such-bucket/", b"nope\x09y x", "s3 505"), ("/s3/no-such-bucket/", b"nope y\x09x", "s3 505"), From 19cb771073e14171a68495221f2f1f4968d411aa Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:50:29 +0900 Subject: [PATCH 32/52] linear, fireflies: control rows for a broken escape in the query beside a raw byte --- backlot/routers/linear.py | 18 +++++++----------- tests/test_request_target.py | 9 +++++++-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 95e855ab9..b9f21d752 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -258,18 +258,14 @@ def routes_to_apollo(path: str) -> bool: def _dots_resolved(path: str) -> str | None: """``path`` with each `.` segment dropped and each `..` segment taking the one before it away, - as RFC 3986 removes dot segments, or None where a `..` has no segment before it to take.""" + or None where a `..` has no segment before it to take.""" out: list[str] = [] - segments = path.split("/")[1:] - for i, segment in enumerate(segments): - if segment in (".", ".."): - if segment == "..": - if not out: - return None - out.pop() - if i == len(segments) - 1: - out.append("") - else: + for segment in path.split("/")[1:]: + if segment == "..": + if not out: + return None + out.pop() + elif segment != ".": out.append(segment) return "/" + "/".join(out) diff --git a/tests/test_request_target.py b/tests/test_request_target.py index c1ddf6ce9..9e5b66cad 100644 --- a/tests/test_request_target.py +++ b/tests/test_request_target.py @@ -118,8 +118,8 @@ ("/notion/v1/", b"nope x?b=\x80x", "cloudflare empty"), ("/notion/v1/", b"nope\x01x?b= x", "cloudflare empty"), ("/notion/v1/", b"nope\x80y\x0dx", "cloudflare page"), - # Cloudflare's refusal of a broken escape in front of Linear and Fireflies, ahead of its answer - # to a raw byte (`request_target.broken_escape`) + # Cloudflare's refusal of a broken escape in the path in front of Linear and Fireflies, ahead of + # its answer to a raw byte (`request_target.broken_escape`), and none for one in the query *( (base, rest, "cloudflare page") for base in ("/linear/", "/fireflies/") @@ -131,6 +131,11 @@ b"a%zz?b=\xc3\xa9", ) ), + *( + (base, rest, "cloudflare empty") + for base in ("/linear/", "/fireflies/") + for rest in (b"a\xc3\xa9?b=%zz", b"a\x01?b=%00") + ), ("/s3/no-such-bucket/", b"nope\x0dy x", "s3 empty"), ("/s3/no-such-bucket/", b"nope\x09y x", "s3 505"), ("/s3/no-such-bucket/", b"nope y\x09x", "s3 505"), From f66b361d788d5ad7b247efcf41ef7a818d7bd7b5 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:57:35 +0900 Subject: [PATCH 33/52] linear, fireflies: the broken escape's docstring points at the middleware that refuses it --- backlot/request_target.py | 12 +++++------- tests/test_linear.py | 4 ++-- tests/test_request_target.py | 4 ++-- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/backlot/request_target.py b/backlot/request_target.py index da44637ff..d8536e07e 100644 --- a/backlot/request_target.py +++ b/backlot/request_target.py @@ -194,17 +194,15 @@ def _google(path_kinds: list[str], query_kinds: list[str]) -> Response | None: return None -#: A `%` two hex digits do not follow. +#: A `%` with fewer than two hex digits after it. _BROKEN_ESCAPE = re.compile(rb"%(?![0-9A-Fa-f]{2})") def broken_escape(path: bytes) -> bool: - """Whether ``path`` holds `%00` or a `%` two hex digits do not follow, which Cloudflare - refuses in front of Linear and Fireflies with :data:`CLOUDFLARE_PAGE` - (``backlot.main.refuse_a_broken_escape_on_linear_and_fireflies``), ahead of its answer to a raw - byte: api.linear.app and api.fireflies.ai answered `/a%zz` with a raw `é` before or after it, - with `\\x01` before it, and `/a%00` after a raw `é`, so on 2026-10-26 (UTC), where a raw `é` - alone drew the empty 400.""" + """Whether ``backlot.main.refuse_a_broken_escape_on_linear_and_fireflies`` refuses ``path``. + Cloudflare's page for it comes ahead of its answer to a raw byte: on 2026-10-26 (UTC) both hosts + drew :data:`CLOUDFLARE_PAGE` for `/a%zz` with a raw `é` before or after it or `\\x01` before + it, and for `/a%00` after a raw `é`, where a raw `é` alone drew the empty 400.""" return b"%00" in path or _BROKEN_ESCAPE.search(path) is not None diff --git a/tests/test_linear.py b/tests/test_linear.py index 201cb48f5..aaeca4801 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -6820,8 +6820,8 @@ def test_linear_answers_each_method_path_type_and_accept_as_linear_does( ], ) def test_linears_front_routes_a_path_once_its_dot_segments_are_resolved(path, served): - """httpx resolves a dot segment before it sends a path, so `_LINEAR_HTTP` cannot send these: - whether Express hands each to Apollo Server, as `routes_to_apollo` has it.""" + """`routes_to_apollo` on paths holding dot segments, which httpx would resolve were + `_LINEAR_HTTP` to send them.""" from backlot.routers import linear assert linear.routes_to_apollo(linear.express_path(path)) is served diff --git a/tests/test_request_target.py b/tests/test_request_target.py index 9e5b66cad..24393ec91 100644 --- a/tests/test_request_target.py +++ b/tests/test_request_target.py @@ -118,8 +118,8 @@ ("/notion/v1/", b"nope x?b=\x80x", "cloudflare empty"), ("/notion/v1/", b"nope\x01x?b= x", "cloudflare empty"), ("/notion/v1/", b"nope\x80y\x0dx", "cloudflare page"), - # Cloudflare's refusal of a broken escape in the path in front of Linear and Fireflies, ahead of - # its answer to a raw byte (`request_target.broken_escape`), and none for one in the query + # Linear and Fireflies: a broken escape in the path beside a raw byte, Cloudflare's page + # (`request_target.broken_escape`), and one in the query, the empty 400 *( (base, rest, "cloudflare page") for base in ("/linear/", "/fireflies/") From 0a9e4bf951737fd44a4e90cf3855923e80d5b019 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:01:50 +0900 Subject: [PATCH 34/52] docs: Linear's endpoint is each path Express hands Apollo Server once its dot segments are resolved --- docs/supported-sources.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/supported-sources.md b/docs/supported-sources.md index 99e15bd91..047959cd0 100644 --- a/docs/supported-sources.md +++ b/docs/supported-sources.md @@ -571,9 +571,10 @@ string. Apollo Server serves a `GET` with no `Content-Type`, or a form or `text/ beside an `apollo-require-preflight` or `x-apollo-operation-name` header, its check that a browser preflighted the request. Express hands Apollo Server `/graphql` in any case, alone or with a slash and more after it, once Linear's front has turned a backslash into a slash, folded a run of -slashes into one and decoded an escape of a letter, a digit or one of `-._~`, so each of those -paths is the endpoint too; `/` answers a `GET` or `HEAD` with a greeting, and any other method -there, or any other path, Express's 404 page. Root `Query` fields: +slashes into one and decoded an escape of a letter, a digit or one of `-._~`, and once its dot +segments are resolved, so each of those paths is the endpoint too, but for one whose `..` climbs +past the root; `/` answers a `GET` or `HEAD` with a greeting, and any other method there, or any +other path, Express's 404 page. Root `Query` fields: | Query field | Notes | |---|---| From 21c1af2a919115091beb9ed44e3575b8dbb46351 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:24:52 +0900 Subject: [PATCH 35/52] linear tests: _linear_status's docstring points at the table that reads each status --- tests/test_linear.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_linear.py b/tests/test_linear.py index c3cadabe6..5b1d99d63 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -5403,8 +5403,8 @@ def test_linear_field_error_is_a_200_and_a_syntax_error_is_a_400(tmp_path): def _linear_status(client, tokens_yaml, value: str) -> int: - """The status `Authorization: value` draws, `{t}` the admin key, a 200 serving the query, a 400 - being `_API_KEY_AS_BEARER` and a 401 `_UNAUTHENTICATED`.""" + """The status `Authorization: value` draws, read as the comment over `_LINEAR_SPELLINGS` reads + it.""" header = value.replace("{t}", tokens_yaml["admin_token"]) r = sent_as( client, From 78c3be01c7684e78cdddecb785b76b2a8c35b6c6 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:34:30 +0900 Subject: [PATCH 36/52] tests: each measured table of the HTTP layer runs as one test `_LINEAR_HTTP`, `_FIREFLIES_HTTP` and `_CLOUDFLARE_METHODS` are sent row by row inside one test that compares every row's answer at once, as main's tests now do; two Linear rows that shared an id with another take their own. --- tests/test_cross_vendor.py | 23 ++++++++------ tests/test_fireflies.py | 63 ++++++++++++++++++-------------------- tests/test_linear.py | 49 +++++++++++++---------------- 3 files changed, 64 insertions(+), 71 deletions(-) diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index f335c4832..f92505106 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -440,18 +440,21 @@ def test_cloudflare_refuses_a_repeated_authorization(client, tokens_yaml, vendor @pytest.mark.parametrize("vendor", ["linear", "fireflies"]) -@pytest.mark.parametrize("method, path, copies, answer", _CLOUDFLARE_METHODS) -def test_cloudflare_refuses_trace_connect_and_purge( - client, tokens_yaml, vendor, method, path, copies, answer -): +def test_cloudflare_refuses_trace_connect_and_purge(client, tokens_yaml, vendor): admin = tokens_yaml["admin_token"] key = admin if vendor == "linear" else f"Bearer {admin}" - r = client.request(method, f"/{vendor}{path}", headers=[("Authorization", key)] * copies) - if answer is None: - assert b"cloudflare" not in r.content and r.status_code == 404 - return - got = (r.status_code, r.headers["content-type"], r.headers.get("connection"), r.content) - assert got == answer + answered, expected = {}, {} + for row in _CLOUDFLARE_METHODS: + method, path, copies, answer = row.values + r = client.request(method, f"/{vendor}{path}", headers=[("Authorization", key)] * copies) + if answer is None: + answered[row.id] = (r.status_code, b"cloudflare" in r.content) + expected[row.id] = (404, False) + continue + got = (r.status_code, r.headers["content-type"], r.headers.get("connection"), r.content) + answered[row.id], expected[row.id] = got, answer + assert len(expected) == len(_CLOUDFLARE_METHODS) + assert answered == expected @pytest.mark.parametrize( diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index cf6be38a0..fda51088d 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -2421,37 +2421,34 @@ def _not_found(method: str, target: str) -> bytes: } -@pytest.mark.parametrize( - "method,target,headers,body,credential,status,media_type,answer,expected_headers", - _FIREFLIES_HTTP, -) -def test_fireflies_answers_each_method_type_and_accept_as_fireflies_does( - client, - tokens_yaml, - method, - target, - headers, - body, - credential, - status, - media_type, - answer, - expected_headers, -): +def _fireflies_body(r, answer): + """``r``'s body, or `_LANDING_PAGE` where ``answer`` is and ``r`` is Apollo Server's page: one + nonce of 64 hex digits, 3,522 bytes and its title.""" + if answer is not _LANDING_PAGE: + return r.content + page = r.text + nonces = set(re.findall(r"nonce-([0-9a-f]+)", page)) + whole = len(page.encode()) == 3522 and "Apollo Server" in page + return _LANDING_PAGE if len(nonces) == 1 and len(nonces.pop()) == 64 and whole else r.content + + +def test_fireflies_answers_each_method_type_and_accept_as_fireflies_does(client, tokens_yaml): """Each row of `_FIREFLIES_HTTP` is answered as api.fireflies.ai answered it.""" - sent = list(headers) - if _FIREFLIES_CREDENTIALS[credential] is not None: - value = _FIREFLIES_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) - sent += [("Authorization", value)] * (2 if credential == "twice" else 1) - content = body.chunks() if isinstance(body, Chunked) else body - r = sent_as(client, method, f"/fireflies/graphql{target}", sent, content=content, bare=True) - assert r.status_code == status - assert r.headers.get("content-type") == media_type - if answer is _LANDING_PAGE: - page = r.text - nonces = set(re.findall(r"nonce-([0-9a-f]+)", page)) - assert len(nonces) == 1 and len(nonces.pop()) == 64 - assert len(page.encode()) == 3522 and "Apollo Server" in page - else: - assert r.content == answer - assert {name: r.headers.get(name) for name in expected_headers} == expected_headers + answered, expected = {}, {} + for row in _FIREFLIES_HTTP: + method, target, headers, body, credential, status, media_type, answer, named = row.values + sent = list(headers) + if _FIREFLIES_CREDENTIALS[credential] is not None: + value = _FIREFLIES_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) + sent += [("Authorization", value)] * (2 if credential == "twice" else 1) + content = body.chunks() if isinstance(body, Chunked) else body + r = sent_as(client, method, f"/fireflies/graphql{target}", sent, content=content, bare=True) + answered[row.id] = ( + r.status_code, + r.headers.get("content-type"), + _fireflies_body(r, answer), + {name: r.headers.get(name) for name in named}, + ) + expected[row.id] = (status, media_type, answer, named) + assert len(expected) == len(_FIREFLIES_HTTP) + assert answered == expected diff --git a/tests/test_linear.py b/tests/test_linear.py index a22b7e13f..2273d71e3 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -6106,7 +6106,7 @@ def _search_refusal(message: bytes) -> bytes: ("a,,b", "a,,b"), ) ), - http_row("OPTIONS", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="OPTIONS"), + http_row("OPTIONS", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="OPTIONS, unpreflighted"), *( http_row( "OPTIONS", @@ -6180,7 +6180,7 @@ def _search_refusal(message: bytes) -> bytes: _CSRF, id="PUT, text/plain", ), - http_row("DELETE", "", (), None, "nope", 400, _JSON_UTF8, _CSRF, id="DELETE"), + http_row("DELETE", "", (), None, "nope", 400, _JSON_UTF8, _CSRF, id="DELETE, unpreflighted"), # A POST's content type: read as JSON, refused as a request a browser sends unpreflighted, or # passed with nothing read. *( @@ -6674,30 +6674,23 @@ def _search_refusal(message: bytes) -> bytes: } -@pytest.mark.parametrize( - "method,target,headers,body,credential,status,media_type,answer,expected_headers", _LINEAR_HTTP -) -def test_linear_answers_each_method_type_and_accept_as_linear_does( - client, - tokens_yaml, - method, - target, - headers, - body, - credential, - status, - media_type, - answer, - expected_headers, -): +def test_linear_answers_each_method_type_and_accept_as_linear_does(client, tokens_yaml): """Each row of `_LINEAR_HTTP` is answered as api.linear.app answered it.""" - sent = list(headers) - if _LINEAR_CREDENTIALS[credential] is not None: - value = _LINEAR_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) - sent += [("Authorization", value)] * (2 if credential == "twice" else 1) - content = body.chunks() if isinstance(body, Chunked) else body - r = sent_as(client, method, f"/linear/graphql{target}", sent, content=content, bare=True) - assert r.status_code == status - assert r.headers.get("content-type") == media_type - assert r.content == answer - assert {name: r.headers.get(name) for name in expected_headers} == expected_headers + answered, expected = {}, {} + for row in _LINEAR_HTTP: + method, target, headers, body, credential, status, media_type, answer, named = row.values + sent = list(headers) + if _LINEAR_CREDENTIALS[credential] is not None: + value = _LINEAR_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) + sent += [("Authorization", value)] * (2 if credential == "twice" else 1) + content = body.chunks() if isinstance(body, Chunked) else body + r = sent_as(client, method, f"/linear/graphql{target}", sent, content=content, bare=True) + answered[row.id] = ( + r.status_code, + r.headers.get("content-type"), + r.content, + {name: r.headers.get(name) for name in named}, + ) + expected[row.id] = (status, media_type, answer, named) + assert len(expected) == len(_LINEAR_HTTP) + assert answered == expected From 3193fc488bcd5458e7c61783b17eda7df89026b4 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:53:57 +0900 Subject: [PATCH 37/52] linear tests: the rows whose answer without a key turns on what the document selects go with every credential Main's body table sends a credential other than the key only with the rows `_EACH_WAY_READ` names; the introspection rows this branch adds answer differently without a key, so `_WHAT_IT_SELECTS` sends them with each. --- tests/test_linear.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/test_linear.py b/tests/test_linear.py index 5b1d99d63..176df5245 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -2374,7 +2374,30 @@ def _charset(name: str) -> tuple: "an array", "a variable not provided", ) -assert set(_EACH_WAY_READ) <= {row.id for row in (*_LINEAR_BODIES, *_LINEAR_READS)} +# The rows whose answer without a key turns on what the document selects +# (`backlot.routers.linear._introspection_only`), each sent with every credential too. +_WHAT_IT_SELECTS = ( + "introspection", + "__type", + "an alias of __schema", + "__schema and __type", + "__schema in a fragment", + "__schema in an inline fragment", + "__schema under @skip(if: false)", + "a named query of __schema", + "the introspection operationName chose", + "introspection with a variable not provided", + "__typename beside __schema", + "__schema beside a skipped field", + "a skipped field alone", + "__typename in a fragment", + "__typename in an inline fragment", + "__schema and __typename in a fragment", + "__typename in a fragment beside an inline __schema", +) +assert set(_EACH_WAY_READ + _WHAT_IT_SELECTS) <= { + row.id for row in (*_LINEAR_BODIES, *_LINEAR_READS) +} def _linear_body_cases() -> list: @@ -2383,7 +2406,7 @@ def _linear_body_cases() -> list: pytest.param(credential, *row.values, id=f"{row.id}-{credential}") for credential in ("key", "none", "nope", "Bearer key", "Bearer, key-shaped") for row in rows - if credential == "key" or row.id in _EACH_WAY_READ + if credential == "key" or row.id in _EACH_WAY_READ + _WHAT_IT_SELECTS ] @@ -2392,8 +2415,9 @@ def test_linear_orders_the_body_and_the_credential( client, tokens_yaml, credential, headers, body, answer ): """Each row answers as the comment over `_LINEAR_BODIES` says with the key, and each row - `_EACH_WAY_READ` names answers so under no credential and `nope` too; an API key under `Bearer`, - and `_KEY_SHAPED`, are `_API_KEY_AS_BEARER` ahead of each of those bodies.""" + `_EACH_WAY_READ` or `_WHAT_IT_SELECTS` names answers so under no credential and `nope` too; an + API key under `Bearer`, and `_KEY_SHAPED`, are `_API_KEY_AS_BEARER` ahead of each of those + bodies.""" admin = tokens_yaml["admin_token"] if not any(name == "Content-Type" for name, _ in headers): headers = (("Content-Type", "application/json"), *headers) From 257efc866a5d77771eb238299506b547bc9e9447 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:55:56 +0900 Subject: [PATCH 38/52] linear tests: each stage of Apollo Server's that refuses ahead of the credential goes with every credential --- tests/test_linear.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_linear.py b/tests/test_linear.py index 520e041a1..45ca702b9 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -2178,8 +2178,10 @@ def _charset(name: str) -> tuple: _KEY_SHAPED = "Bearer lin_api_" + "0" * 40 # The rows every credential but the key is sent with: one for each way `_read` refuses a body (V8's # words, `strict`'s, graphql-js's, a body it cannot decode, an unsupported encoding, an unsupported -# charset, past the limit), one it reads and serves, Apollo Server's refusal of a batch, and a -# `WithTheKey` row. +# charset, past the limit), one it reads and serves, one for each stage of Apollo Server's that +# refuses a document ahead of the credential (a batch, a query that is not a string, a persisted +# query's hash alone and one that does not match, a parse, a validation, the choice of operation), +# and a `WithTheKey` row. _EACH_WAY_READ = ( "{", "top-level 5", @@ -2190,6 +2192,12 @@ def _charset(name: str) -> tuple: "past the limit", "__proto__ key", "an array", + "a query of null", + "a persisted query's hash alone", + "a persisted query's hash that does not match", + "a document that does not parse", + "a document that does not validate", + "two operations and no operationName", "a variable not provided", ) assert set(_EACH_WAY_READ) <= {row.id for row in (*_LINEAR_BODIES, *_LINEAR_READS)} From 8dc27059f80705d09aa84bdfe26556d75ac353a6 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:09:26 +0900 Subject: [PATCH 39/52] fireflies tests: _sent's docstring wraps at the line length --- tests/test_fireflies.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index 35c8f69b0..e18f062ab 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -1632,7 +1632,8 @@ def _roster_emails(settings): def _sent(headers, body) -> str: - """What a row of `_FIREFLIES_HTTP` sends besides a JSON type and `{ __typename }`, for its id.""" + """What a row of `_FIREFLIES_HTTP` sends besides a JSON type and `{ __typename }`, for its + id.""" types = [ value for name, value in headers if name == "Content-Type" and value != "application/json" ] From b347ab135d84dd4f9b77909e71bb1b4b2bf0b274 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:46:49 +0900 Subject: [PATCH 40/52] fireflies: _envelope names every code _FRIENDLY holds --- backlot/routers/fireflies.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index a8ec1a630..b04fd40b9 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -166,10 +166,10 @@ def _entry(error: dict, code: str, friendly: bool, extensions: dict) -> dict: def _envelope(result: engine.Result) -> dict: """``result`` as Fireflies writes it, as api.fireflies.ai answered on 2026-10-25 (UTC): each request error, and each error in ``result.assigned``, with ``friendly: false``, Apollo Server's - code beside the message and ``extensions`` empty. The ``invalid_arguments`` and - ``INTERNAL_SERVER_ERROR`` errors a resolver raises keep the extensions it gave them, - ``friendly`` as ``_FRIENDLY`` has it, as api.fireflies.ai answered the refusal on 2026-10-21 - and the INTERNAL_SERVER_ERROR on 2026-10-22.""" + code beside the message and ``extensions`` empty. The errors a resolver raises with a code in + ``_FRIENDLY`` keep the extensions it gave them, ``friendly`` as ``_FRIENDLY`` has it, as + api.fireflies.ai answered ``invalid_arguments`` on 2026-10-21, ``INTERNAL_SERVER_ERROR`` on + 2026-10-22, and ``not_in_team`` and ``object_not_found`` on 2026-10-27.""" if result.refusals: errors = [] for refusal in result.refusals: From 326fc66753cc333aa225b2073b5c76e7df10db57 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:11:00 +0900 Subject: [PATCH 41/52] graphql: a query a persisted query's hash matches is parsed however empty, and Fireflies refuses an operation named __proto__ Apollo Server refuses a body for having no query only when it sends no persistedQuery extension; with one, a string query whose hash matches, "" included, goes on to be parsed. Both vendors answered "" with its hash with graphql-js's Syntax Error on 2026-10-28 (UTC). graphql-depth-limit keeps the operations in a plain object, so an operation named __proto__ becomes its prototype and its for...in throws. Fireflies answers any document holding one with its 500. The engine's MAX_VALIDATION_ERRORS is now the one constant the Fireflies depth rule reads. Docstrings say a refusal's message is Apollo Server's, in graphql-js's words where graphql-js made it, and js_words names graphql-js 16 as its scope. --- backlot/graphql/engine.py | 64 +++++++++++++++++++++--------------- backlot/graphql/js_words.py | 14 +++++--- backlot/routers/fireflies.py | 23 ++++++++----- tests/test_fireflies.py | 35 ++++++++++++++++++++ tests/test_graphql.py | 5 +++ tests/test_linear.py | 13 ++++++++ 6 files changed, 113 insertions(+), 41 deletions(-) diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index dc439286b..e2fc2a380 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -10,10 +10,11 @@ does: Apollo Server's checks of the body and of a persisted query come first, then graphql-js's parse, validation and choice of operation (:meth:`Engine.resolve_payload`), then the coercion of variables and the run itself (:meth:`Engine.run`), each refusal a :class:`Refusal` carrying Apollo -Server's ``code`` and the message in graphql-js's words (:mod:`backlot.graphql.js_words`). A router -that reads the credential between the two stages calls them apart. A refusal made before execution -carries no ``data`` entry; an error made as the operation runs comes back beside ``data``, at 200. -Each router turns the refusals into its vendor's envelope. +Server's ``code`` and the message as Apollo Server writes it, in graphql-js's words where +graphql-js made the refusal (:mod:`backlot.graphql.js_words`). A router that reads the credential +between the two stages calls them apart. A refusal made before execution carries no ``data`` entry; +an error made as the operation runs comes back beside ``data``, at 200. Each router turns the +refusals into its vendor's envelope. """ from __future__ import annotations @@ -103,13 +104,14 @@ def _ids_as_graphql_js_reads_them(value: Any, type_: Any) -> Any: #: The errors graphql-js's ``validate`` takes, and graphql-core's: the next one is #: ``Too many validation errors, error limit reached. Validation aborted.``, and the last. -_MAX_VALIDATION_ERRORS = 100 +MAX_VALIDATION_ERRORS = 100 @dataclass(frozen=True) class Refusal: - """One request error as Apollo Server answers it: ``message`` in graphql-js's words, the - ``locations`` graphql-js gives it, Apollo Server's ``code``, and the status of the answer.""" + """One request error as Apollo Server answers it: ``message`` as Apollo Server writes it, in + graphql-js's words where graphql-js made the refusal, the ``locations`` graphql-js gives it, + Apollo Server's ``code``, and the status of the answer.""" message: str code: str @@ -259,17 +261,24 @@ def body_refusal(payload: Any) -> Result | None: PERSISTED_QUERY_MISMATCH = "provided sha does not match query" -def persisted_query_refusal(payload: dict) -> Result | None: - """Apollo Server's answer to the ``persistedQuery`` extension of ``payload``, or ``None`` for - a request it runs, as a server that keeps no query answers: a hash sent with no ``query``, or - with ``null``, ``false`` or ``0``, is ``PersistedQueryNotFound`` at 200. Both vendors answered - so on 2026-10-25 (UTC). Once a query had been sent with its hash and served, Linear answered - its hash alone so to each of 16 requests, and Fireflies to 15 of 16, serving the query to the - other. A version other than the number 1 is refused, then a hash that is not the SHA-256 of the - query sent, in lower-case hex, before the query is parsed.""" +def _persisted_query(payload: dict) -> Any: + """The ``persistedQuery`` extension of ``payload``, or ``None`` where it sends none JavaScript + reads as true.""" extensions = payload.get("extensions") persisted = extensions.get("persistedQuery") if isinstance(extensions, dict) else None - if not _js_truthy(persisted): + return persisted if _js_truthy(persisted) else None + + +def persisted_query_refusal(payload: dict) -> Result | None: + """Apollo Server's answer to the ``persistedQuery`` extension of ``payload``, or ``None`` for + a request it goes on with, as a server that keeps no query answers. A version other than the + number 1 is refused first. Then a hash sent with no ``query``, or with ``null``, ``false`` or + ``0``, is ``PersistedQueryNotFound`` at 200, as both vendors answered on 2026-10-25 (UTC); once + a query had been sent with its hash and served, Linear answered its hash alone so to each of 16 + requests, and Fireflies to 15 of 16, serving the query to the other. Then a hash that is not the + SHA-256 of the query sent, in lower-case hex, is refused before the query is parsed.""" + persisted = _persisted_query(payload) + if persisted is None: return None version = persisted.get("version") if isinstance(persisted, dict) else None if isinstance(version, bool) or version != 1: @@ -378,7 +387,7 @@ def resolve( ``on_document`` is a validation rule of the vendor's that reports as graphql-js creates it: the :class:`Result` to answer with where it throws, or the errors it reports, which - come ahead of graphql-js's own and count toward the :data:`_MAX_VALIDATION_ERRORS` it + come ahead of graphql-js's own and count toward the :data:`MAX_VALIDATION_ERRORS` it takes.""" try: doc = parse(document) @@ -389,7 +398,7 @@ def resolve( reported = [] if on_document is None else on_document(doc) if isinstance(reported, Result): return reported - errors = validate(self._checked, doc, max_errors=_MAX_VALIDATION_ERRORS - len(reported)) + errors = validate(self._checked, doc, max_errors=MAX_VALIDATION_ERRORS - len(reported)) if reported or errors: return refused( *(Refusal(e.message, VALIDATION_FAILED, js_words.locations(e)) for e in reported), @@ -416,15 +425,17 @@ def resolve_payload( ) -> Result | Operation: """A GraphQL-over-HTTP POST body (``{query, variables, operationName}``) its router has read with :mod:`backlot.graphql.body`, up to the choice of operation: :func:`body_refusal`, - then :func:`persisted_query_refusal`, then Apollo Server's refusal of a body with no query, - then :meth:`resolve`.""" + then :func:`persisted_query_refusal`, then Apollo Server's refusal of a body with no query + and no ``persistedQuery`` extension, then :meth:`resolve`. A query whose hash a + ``persistedQuery`` extension matches is parsed however empty it is, as both vendors parsed + ``""`` on 2026-10-28 (UTC).""" answer = body_refusal(payload) if answer is None: answer = persisted_query_refusal(payload) if answer is not None: return answer query = payload.get("query") - if not _js_truthy(query): + if not _js_truthy(query) and _persisted_query(payload) is None: return _refusal(NO_QUERY) return self.resolve( query, @@ -472,12 +483,11 @@ def run(self, operation: Operation, *, context: Any = None) -> Result: variable_values=variables, operation_name=operation.name, ) - # graphql-core's ExecutionResult.formatted writes "data" first; api.fireflies.ai and - # api.linear.app open a response carrying a field error with "errors" (2026-10-21), so the - # errors come first here. Each error's locations are graphql-js's. An error graphql-core - # made itself is in graphql-js's words and carries the code Apollo Server gives an error - # with none, which a router writes in its vendor's envelope; one a resolver raised is the - # vendor's own. + # api.fireflies.ai and api.linear.app open a response carrying a field error with "errors" + # (2026-10-21), so the errors come first here. Each error's locations are graphql-js's. An + # error graphql-core made itself is in graphql-js's words and carries the code Apollo Server + # gives an error with none, which a router writes in its vendor's envelope; one a resolver + # raised is the vendor's own. payload: dict = {} assigned = [] if result.errors: diff --git a/backlot/graphql/js_words.py b/backlot/graphql/js_words.py index 4dea551ec..4c9d6eaec 100644 --- a/backlot/graphql/js_words.py +++ b/backlot/graphql/js_words.py @@ -1,14 +1,18 @@ -"""graphql-js's words for the errors graphql-core makes with its own. +"""graphql-js 16's words for the errors graphql-core 3.2 makes with its own. + +Linear and Fireflies answered a validation error, a syntax error and a variable's refusal in +graphql-js 16.14.2's words on 2026-10-25 (UTC), and Linear refused an array sent for an input object +as 16.14.2 does, where 16.9.0 reads the array's indices as field names. graphql-js 17.0.2 words +these otherwise (run on 2026-10-28 (UTC)): a variable of the wrong type, a null or missing non-null +variable, a list item its type refuses, and a missing argument. A vendor's move to 17 shows here as +answers that diverge, not as an error. Linear and Fireflies run graphql-js under Apollo Server, and graphql-core is a port of graphql-js whose messages differ from it: graphql-core quotes a name, a type, a token or a character with ``'`` where graphql-js quotes it with ``"``, names a directive's location by its value (``query``) where graphql-js names it ``QUERY``, prints a variable's value with Python's ``repr`` where graphql-js prints it with its own ``inspect``, words a variable's refusals its own way (``to be a -mapping``), and counts a line at characters graphql-js does not break at. Both vendors answered a -validation error, a syntax error and a variable's refusal in graphql-js's words on 2026-10-25 -(UTC), quotes and values as graphql-js 16.14.2 writes them: Linear also refused an array sent for -an input object as graphql-js 16.14.2 does, where 16.9.0 reads the array's indices as field names. +mapping``), and counts a line at characters graphql-js does not break at. Here are :func:`requote` for a message, :func:`locations` for where it points, and :func:`coerce_variable_values` for a variable's refusals, which it makes from the value as the diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index b04fd40b9..dd4ea8eaa 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -196,9 +196,6 @@ def _envelope(result: engine.Result) -> dict: #: graphql-depth-limit's ``maxDepth`` on api.fireflies.ai, which on 2026-10-26 (UTC) refused a #: selection eleven fields deep and crawled one ten deep. _MAX_DEPTH = 10 -#: The errors graphql-js's ``validate`` takes before it stops, which a 101st report from a rule -#: throws past. -_MAX_ERRORS = 100 class _Thrown(Exception): @@ -215,10 +212,16 @@ def _depth_limit(doc: DocumentNode) -> engine.Result | list[GraphQLError]: ``''``: a field's selections one deeper, but none of a field whose name starts ``__``, and a spread's fragment, the last of its name, at the spread's depth. A selection deeper than :data:`_MAX_DEPTH` is reported and not crawled. A spread of a fragment the document does not - define, a spread back to a fragment with no field between, and a report past - :data:`_MAX_ERRORS` throw. api.fireflies.ai answered so on 2026-10-26 (UTC), and answered - ``{ ...F }`` with ``F`` undefined inside a field ten deep with the 500 and eleven deep with the - depth error.""" + define, a spread back to a fragment with no field between, and a report past the + :data:`backlot.graphql.engine.MAX_VALIDATION_ERRORS` graphql-js's ``validate`` takes throw. + api.fireflies.ai answered so on 2026-10-26 (UTC), and answered ``{ ...F }`` with ``F`` undefined + inside a field ten deep with the 500 and eleven deep with the depth error. + + A document with an operation named ``__proto__`` throws too, whichever operation is chosen: + graphql-depth-limit keeps the operations in a plain object, which takes an operation of that + name as its prototype, and its ``for…in`` crawls the operation's own members. api.fireflies.ai + answered it with the 500 on 2026-10-28 (UTC), and served an operation named ``constructor`` or + ``__defineGetter__`` and a spread of a fragment named ``__proto__``.""" fragments: dict[str, FragmentDefinitionNode] = {} operations: dict[str, OperationDefinitionNode] = {} for definition in doc.definitions: @@ -242,7 +245,7 @@ def crawl(selections, depth: int) -> list: reported += spread(node.name.value, depth) else: reported += crawl(node.selection_set.selections, depth) - if len(reported) > _MAX_ERRORS: + if len(reported) > engine.MAX_VALIDATION_ERRORS: raise _Thrown return reported @@ -256,9 +259,11 @@ def spread(name: str, depth: int) -> list: errors: list[GraphQLError] = [] try: + if "__proto__" in operations: + raise _Thrown for name, operation in operations.items(): for node in crawl(operation.selection_set.selections, 0): - if len(errors) == _MAX_ERRORS: + if len(errors) == engine.MAX_VALIDATION_ERRORS: raise _Thrown message = f"'{name}' exceeds maximum operation depth of {_MAX_DEPTH}" errors.append(GraphQLError(message, node)) diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index 06d31f726..2d5133fc4 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -7,6 +7,7 @@ from __future__ import annotations import gzip +import hashlib import json import sqlite3 import time @@ -409,6 +410,17 @@ def _no_field_a(column: int) -> bytes: ), id="a query of null", ), + pytest.param( + b'{"query":"","extensions":{"persistedQuery":{"version":1,"sha256Hash":"' + + hashlib.sha256(b"").hexdigest().encode() + + b'"}}}', + _apollo( + b"Syntax Error: Unexpected .", + b"GRAPHQL_PARSE_FAILED", + b'"locations":[{"line":1,"column":1}],', + ), + id="a persisted query's hash matching an empty query", + ), pytest.param( b'{"query":"{"}', _apollo( @@ -548,6 +560,29 @@ def _no_field_a(column: int) -> bytes: ), id="a fragment not defined", ), + pytest.param( + b'{"query":"query __proto__ { __typename }"}', + WithTheKey(_INTERNAL, 500), + id="an operation named __proto__", + ), + pytest.param( + b'{"query":"query A { __typename } query __proto__ { __typename }","operationName":"A"}', + WithTheKey(_INTERNAL, 500), + id="an operation named __proto__ beside the one chosen", + ), + pytest.param( + b'{"query":"query constructor { __typename }"}', None, id="an operation named constructor" + ), + pytest.param( + b'{"query":"query __defineGetter__ { __typename }"}', + None, + id="an operation named __defineGetter__", + ), + pytest.param( + b'{"query":"query A { ...__proto__ } fragment __proto__ on Query { __typename }"}', + None, + id="a spread of a fragment named __proto__", + ), pytest.param( b'{"query":"fragment F on Query { ...F } { ...F }"}', WithTheKey( diff --git a/tests/test_graphql.py b/tests/test_graphql.py index fc3497f2a..6e357721a 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -951,6 +951,11 @@ def _refused(message, code="BAD_REQUEST", status=400, locations=()) -> tuple: pytest.param(_persisted(sha256=_HASH.upper(), query=_TYPENAME), _MISMATCH, id="pq upper hash"), pytest.param(_persisted(sha256=5, query=_TYPENAME), _MISMATCH, id="pq hash 5"), pytest.param(_persisted(query=""), _MISMATCH, id="pq, an empty query"), + pytest.param( + _persisted(sha256=hashlib.sha256(b"").hexdigest(), query=""), + _refused("Syntax Error: Unexpected .", "GRAPHQL_PARSE_FAILED", locations=[(1, 1)]), + id="pq matching an empty query", + ), pytest.param(_persisted(query=False), _NOT_FOUND, id="pq, query false"), pytest.param(_persisted(query=0), _NOT_FOUND, id="pq, query 0"), pytest.param( diff --git a/tests/test_linear.py b/tests/test_linear.py index d4537c858..82b2a2134 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -10,6 +10,7 @@ from __future__ import annotations import gzip +import hashlib import itertools import json import zlib @@ -1679,6 +1680,18 @@ def _status(answer: bytes) -> int: b'"code":"INVALID_INPUT","type":"invalid input","userError":true}}]}', id="a query of null", ), + pytest.param( + b'{"query":"","extensions":{"persistedQuery":{"version":1,"sha256Hash":"' + + hashlib.sha256(b"").hexdigest().encode() + + b'"}}}', + b'{"errors":[{"message":"Syntax Error: Unexpected .","locations":[{"line":1,' + b'"column":1}],"extensions":{"http":{"status":400,"headers":{}},' + b'"code":"GRAPHQL_PARSE_FAILED","type":"graphql error","userError":true}}]}', + id="a persisted query's hash matching an empty query", + ), + pytest.param( + b'{"query":"query __proto__ { __typename }"}', None, id="an operation named __proto__" + ), pytest.param( b'{"query":"{"}', b'{"errors":[{"message":"Syntax Error: Expected Name, found .","locations":[{"line":1,' From 6aa5d5e25e85a11de3a11d78f5788c84a9b0cc42 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:14:16 +0900 Subject: [PATCH 42/52] tests: a hidden by-id root's answer equals an absent one's, body for body --- tests/test_acl.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_acl.py b/tests/test_acl.py index 13d809e8c..5712146c3 100644 --- a/tests/test_acl.py +++ b/tests/test_acl.py @@ -191,10 +191,7 @@ def test_linear_by_id_roots_do_not_leak_entities_off_hidden_issues(sample_settin % synth.linear_state_id("No Such State", "engineering"), tokens["ava@acme.com"], ) - assert ( - absent["errors"][0]["message"].split("id=")[0] - == hidden["errors"][0]["message"].split("id=")[0] - ) + assert absent == hidden def test_linear_by_id_roots_still_answer_for_visible_entities(sample_settings, tokens): From b11dda5de7339d9af288852231ccd25b383349e2 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:21:52 +0900 Subject: [PATCH 43/52] tests: Cloudflare's answers ahead of Linear and Fireflies are one table, each row with its answer The rows for TRACE, CONNECT and PURGE join the table of a repeated Authorization, each with the status, media type, length, Connection and body Cloudflare sent (measured 2026-10-28 UTC). TRACE with Authorization twice is the vendor tables' row already; If-None-Match twice ahead of each method is new, as main now refuses it too. The route docstrings describe one route, not one POST route. headers_seen keeps its rule and date and leaves the cases to the vendor tables, and Refusal's thrown keeps only what Accept cannot do. main.py names the renamed repeated-header middleware. NOTICE.md carries Apollo Server's MIT notice for the landing page copied from it. --- NOTICE.md | 30 +++++++ backlot/graphql/apollo_http.py | 10 +-- backlot/graphql/engine.py | 4 +- backlot/main.py | 4 +- backlot/routers/fireflies.py | 6 +- backlot/routers/linear.py | 8 +- tests/test_cross_vendor.py | 144 +++++++++++++++------------------ 7 files changed, 107 insertions(+), 99 deletions(-) diff --git a/NOTICE.md b/NOTICE.md index 38071bddf..6a0d57b4e 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -32,6 +32,36 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## Apollo Server's landing page + +`backlot/graphql/apollo_landing_page.html` is the page Apollo Server 5.5.1 (`@apollo/server`) +serves a browser, as api.fireflies.ai served it, with its nonce left as a placeholder. Apollo +Server is released under the MIT License: + +```text +The MIT License (MIT) + +Copyright (c) 2016-2020 Apollo Graph, Inc. (Formerly Meteor Development Group, Inc.) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + ## Trademarks Backlot is an independent project, **not affiliated with, endorsed by, or sponsored by** any of the diff --git a/backlot/graphql/apollo_http.py b/backlot/graphql/apollo_http.py index 2696d85be..044b25bc8 100644 --- a/backlot/graphql/apollo_http.py +++ b/backlot/graphql/apollo_http.py @@ -242,14 +242,8 @@ def headers_seen(raw: list[tuple[bytes, bytes]]) -> dict[str, str]: """The request headers as Apollo Server reads them behind Node, from the ASGI ``raw`` list: each name in lower case and each value without the spaces and tabs at its ends, the values of a name sent more than once joined with ``", "`` but ``content-type``'s, of which Node keeps the - first, and a header with no value left out. Each vendor answered so on 2026-10-25 (UTC): - `Accept` sent as `application/json` and as `text/html`, in either order, was served; - `apollo-require-preflight` sent empty and as `1`, in either order, let a GET through, and an - `x-apollo-operation-name` of a space did not; a `Content-Type` sent twice was the first to - Fireflies; an empty `Content-Type` was none to Linear, which refused a POST carrying one as a - POST with none, and to Fireflies, which served a GET carrying one with the preflight header - where it refuses a GET carrying one that is not JSON. Linear reads `Content-Type` on its own - (``backlot.routers.linear``).""" + first, and a header with no value left out, as each vendor answered on 2026-10-25 (UTC). Linear + reads `Content-Type` on its own (``backlot.routers.linear``).""" seen: dict[str, str] = {} for name, value in raw: key, text = name.decode("latin-1").lower(), value.decode("latin-1").strip(" \t") diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index 0a2db59bc..c344fc1b7 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -112,9 +112,7 @@ class Refusal: """One request error as Apollo Server answers it: ``message`` as Apollo Server writes it, in graphql-js's words where graphql-js made the refusal, the ``locations`` graphql-js gives it, Apollo Server's ``code``, the status of the answer and the ``headers`` it sets on it. - ``thrown`` marks a refusal Apollo Server throws, each but - :data:`~backlot.graphql.apollo_http.NOT_ACCEPTABLE` before its request pipeline, which - ``Accept`` cannot turn into its 406 (:mod:`backlot.graphql.apollo_http`), and + ``thrown`` marks a refusal Apollo Server throws, which ``Accept`` cannot turn into its 406, and ``http_headers`` whether the ``http`` extension it carries names its ``headers``, as all but the 406's do.""" diff --git a/backlot/main.py b/backlot/main.py index f015b08eb..29f6b99fc 100644 --- a/backlot/main.py +++ b/backlot/main.py @@ -1321,9 +1321,9 @@ async def warn_slack_post_charset(request: Request, call_next): async def refuse_cloudflares_methods_on_linear_and_fireflies(request: Request, call_next): """Answer a `/linear` or `/fireflies` request by one of :data:`_CLOUDFLARE_METHOD`'s methods with Cloudflare's answer, ahead of the route and the credential. Registered before - :func:`refuse_a_repeated_authorization_on_linear_and_fireflies`, which answers first: a + :func:`refuse_a_repeated_header_on_notion_linear_fireflies_and_hubspot`, which answers first: a `TRACE`, `CONNECT` or `PURGE` carrying `Authorization` twice drew its 400 from both vendors on - 2026-10-25 (UTC).""" + 2026-10-25 (UTC), and one carrying `If-None-Match` or `If-Modified-Since` twice on 2026-10-28.""" status, body, headers = _CLOUDFLARE_METHOD[request.method] return Response(body, status_code=status, headers=headers) diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index 5f7952eb6..57922f273 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -130,9 +130,9 @@ def _auth_failed(seen: dict[str, str], message: str) -> Response: async def graphql(request: Request): """The single Fireflies endpoint, in front of which Fireflies runs Fastify and ``@fastify/cors``. Not in the OpenAPI document on purpose: this is a GraphQL service, and - describing one POST route that accepts an arbitrary query would tell an OpenAPI→MCP bridge - nothing useful (hence no ``SOURCE_PREFIXES`` entry either — see ``backlot/openapi.py``). - Introspection is the schema description for this endpoint. + describing one route that accepts an arbitrary query would tell an OpenAPI→MCP bridge nothing + useful (hence no ``SOURCE_PREFIXES`` entry either — see ``backlot/openapi.py``). Introspection + is the schema description for this endpoint. The layers answer in this order, each measured against api.fireflies.ai on 2026-10-25 (UTC) with the one after it wrong too: ``@fastify/cors`` (:func:`_preflight`), Fastify diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index ced584028..415ba76c1 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -117,10 +117,10 @@ def _express(message: str, status: int = 400, length: int | None = None) -> Resp @router.api_route("/graphql", methods=list(apollo_http.METHODS), include_in_schema=False) async def graphql(request: Request): """The single Linear endpoint, in front of which Linear runs Express and ``cors``. Not in the - OpenAPI document on purpose: this is a GraphQL service, and describing one POST route that - accepts an arbitrary query would tell an OpenAPI→MCP bridge nothing useful (hence no - ``SOURCE_PREFIXES`` entry either — see ``backlot/openapi.py``). Introspection is the schema - description for this endpoint. + OpenAPI document on purpose: this is a GraphQL service, and describing one route that accepts an + arbitrary query would tell an OpenAPI→MCP bridge nothing useful (hence no ``SOURCE_PREFIXES`` + entry either — see ``backlot/openapi.py``). Introspection is the schema description for this + endpoint. The layers answer in this order, each measured against api.linear.app on 2026-10-25 (UTC) with the one after it wrong too: the load balancer (:data:`_LOAD_BALANCER_501`), ``cors`` diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index eeac16e91..8a0a3cc7d 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -346,61 +346,90 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( assert answered == {row.id: (400, True, True) for row in _NUMBERS} -# A request to Linear or Fireflies with `Authorization` sent more than once, `{key}` standing for -# the vendor's own spelling of the admin token, each answered with the 400 page -# `backlot.main.refuse_a_repeated_header_on_notion_linear_fireflies_and_hubspot` records. The last -# three rows are the control: one header reaches the vendor, and a path that only starts with the -# vendor's name is not the vendor's. +# A request to Linear or Fireflies that Cloudflare answers itself, `{key}` standing for the vendor's +# own spelling of the admin token, and the status, media type, length, `Connection` and body of the +# answer: the 400 page +# `backlot.main.refuse_a_repeated_header_on_notion_linear_fireflies_and_hubspot` records for +# `Authorization` or `If-None-Match` sent more than once, ahead of +# `backlot.main._CLOUDFLARE_METHOD`'s answer to each of its methods. The last three rows are the +# control: one header reaches the vendor, and a path that only starts with the vendor's name is not +# the vendor's. _TYPENAME = b'{"query":"{ __typename }"}' _CLOUDFLARE = 400 -_REPEATED = [ - pytest.param("POST", "/graphql", _TYPENAME, ("{key}", "{key}"), _CLOUDFLARE, id="two keys"), - pytest.param("POST", "/graphql", _TYPENAME, ("{key}", "nope"), _CLOUDFLARE, id="a key, nope"), - pytest.param("POST", "/graphql", _TYPENAME, ("nope", "{key}"), _CLOUDFLARE, id="nope, a key"), - pytest.param("POST", "/graphql", _TYPENAME, ("{key}", ""), _CLOUDFLARE, id="a key, empty"), - pytest.param("POST", "/graphql", _TYPENAME, ("", ""), _CLOUDFLARE, id="two empty"), - pytest.param("POST", "/graphql", _TYPENAME, ("{key}",) * 3, _CLOUDFLARE, id="three keys"), - pytest.param("POST", "/graphql", b"", ("{key}", "nope"), _CLOUDFLARE, id="no bytes"), - pytest.param("POST", "/graphql", b"{", ("{key}", "nope"), _CLOUDFLARE, id="{"), - pytest.param("POST", "/graphql", None, ("{key}", "nope"), _CLOUDFLARE, id="past the limit"), - pytest.param("GET", "/graphql", b"", ("{key}", "nope"), _CLOUDFLARE, id="GET"), - pytest.param("HEAD", "/graphql", b"", ("{key}", "nope"), _CLOUDFLARE, id="HEAD"), - pytest.param("POST", "/nosuch", _TYPENAME, ("{key}", "nope"), _CLOUDFLARE, id="another path"), - pytest.param("POST", "", _TYPENAME, ("{key}", "nope"), _CLOUDFLARE, id="the root"), - pytest.param("POST", "/graphql", _TYPENAME, ("{key}",), 200, id="one key"), - pytest.param("POST", "x/graphql", _TYPENAME, ("{key}", "nope"), 404, id="a longer name"), +_PAGE = ( + b"\r\n%s\r\n\r\n

%s

\r\n" + b"
cloudflare
\r\n\r\n\r\n" +) +_TWICE = (_CLOUDFLARE, "text/html", "155", "close", _PAGE % ((b"400 Bad Request",) * 2)) +_NOT_ALLOWED = (405, "text/html", "155", "close", _PAGE % ((b"405 Not Allowed",) * 2)) +_ERROR_1045 = (400, "text/plain; charset=UTF-8", "17", None, b"error code: 1045\n") +_PURGED = (400, "text/html; charset=UTF-8", "155", None, _PAGE % ((b"400 Bad Request",) * 2)) + + +def _auth(*values: str) -> tuple: + return tuple(("Authorization", value) for value in values) + + +_ETAG_TWICE = (("If-None-Match", '"x"'),) * 2 +_CLOUDFLARE_FIRST = [ + pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}", "{key}"), _TWICE, id="two keys"), + pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}", "nope"), _TWICE, id="a key, nope"), + pytest.param("POST", "/graphql", _TYPENAME, _auth("nope", "{key}"), _TWICE, id="nope, a key"), + pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}", ""), _TWICE, id="a key, empty"), + pytest.param("POST", "/graphql", _TYPENAME, _auth("", ""), _TWICE, id="two empty"), + pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}") * 3, _TWICE, id="three keys"), + pytest.param("POST", "/graphql", b"", _auth("{key}", "nope"), _TWICE, id="no bytes"), + pytest.param("POST", "/graphql", b"{", _auth("{key}", "nope"), _TWICE, id="{"), + pytest.param("POST", "/graphql", None, _auth("{key}", "nope"), _TWICE, id="past the limit"), + pytest.param("GET", "/graphql", b"", _auth("{key}", "nope"), _TWICE, id="GET"), + pytest.param("HEAD", "/graphql", b"", _auth("{key}", "nope"), _TWICE, id="HEAD"), + pytest.param("POST", "/nosuch", _TYPENAME, _auth("{key}", "nope"), _TWICE, id="another path"), + pytest.param("POST", "", _TYPENAME, _auth("{key}", "nope"), _TWICE, id="the root"), + *( + pytest.param(m, path, b"", _auth("{key}"), answer, id=f"{m} {path or '/'}") + for m, answer in (("TRACE", _NOT_ALLOWED), ("CONNECT", _ERROR_1045), ("PURGE", _PURGED)) + for path in ("/graphql/", "/nosuch", "") + ), + *( + pytest.param(m, "/graphql", b"", _auth("{key}", "{key}"), _TWICE, id=f"{m}, two keys") + for m in ("CONNECT", "PURGE") + ), + *( + pytest.param( + m, "/graphql", b"", (*_auth("{key}"), *_ETAG_TWICE), _TWICE, id=f"{m}, ETag twice" + ) + for m in ("TRACE", "CONNECT", "PURGE") + ), + pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}"), 200, id="one key"), + pytest.param("POST", "x/graphql", _TYPENAME, _auth("{key}", "nope"), 404, id="a longer name"), + pytest.param("TRACE", "x/graphql", b"", _auth("{key}"), 404, id="TRACE, a longer name"), ] @pytest.mark.parametrize("vendor", ["linear", "fireflies"]) -def test_cloudflare_refuses_a_repeated_authorization(client, tokens_yaml, vendor): +def test_cloudflare_answers_ahead_of_linear_and_fireflies(client, tokens_yaml, vendor): admin = tokens_yaml["admin_token"] key = admin if vendor == "linear" else f"Bearer {admin}" - page = ( - b"\r\n400 Bad Request\r\n\r\n" - b"

400 Bad Request

\r\n
cloudflare
\r\n" - b"\r\n\r\n" - ) answered, expected = {}, {} - for row in _REPEATED: - method, path, body, values, status = row.values + for row in _CLOUDFLARE_FIRST: + method, path, body, sent, answer = row.values if body is None: body = padded_query({"linear": 10 * 2**20, "fireflies": 2**20}[vendor] + 1) headers = [("Content-Type", "application/json")] - headers += [("Authorization", value.format(key=key)) for value in values] + headers += [(name, value.format(key=key)) for name, value in sent] r = client.request(method, f"/{vendor}{path}", content=body, headers=headers) - if status != _CLOUDFLARE: + if isinstance(answer, int): answered[row.id] = ( r.status_code, - page in r.content, + b"cloudflare" in r.content, "connection" in r.headers, r.json() if r.status_code == 200 else None, ) expected[row.id] = ( - status, + answer, False, False, - {"data": {"__typename": "Query"}} if status == 200 else None, + {"data": {"__typename": "Query"}} if answer == 200 else None, ) continue answered[row.id] = ( @@ -408,52 +437,9 @@ def test_cloudflare_refuses_a_repeated_authorization(client, tokens_yaml, vendor r.headers.get("content-type"), r.headers.get("content-length"), r.headers.get("connection"), - None if method == "HEAD" else r.content, + r.content, ) - expected[row.id] = (status, "text/html", "155", "close", None if method == "HEAD" else page) - assert answered == expected - - -# Each of `backlot.main._CLOUDFLARE_METHOD`'s answers on the paths other than `/graphql` it records, -# and the 400 ahead of it for `Authorization` sent twice. The last row is the control: a path that -# only starts with the vendor's name is not the vendor's. -_PAGE = ( - b"\r\n%s\r\n\r\n

%s

\r\n" - b"
cloudflare
\r\n\r\n\r\n" -) -_NOT_ALLOWED = (405, "text/html", "close", _PAGE % ((b"405 Not Allowed",) * 2)) -_ERROR_1045 = (400, "text/plain; charset=UTF-8", None, b"error code: 1045\n") -_PURGED = (400, "text/html; charset=UTF-8", None, _PAGE % ((b"400 Bad Request",) * 2)) -_TWICE = (400, "text/html", "close", _PAGE % ((b"400 Bad Request",) * 2)) -_CLOUDFLARE_METHODS = [ - *( - pytest.param(m, path, 1, answer, id=f"{m} {path or '/'}") - for m, answer in (("TRACE", _NOT_ALLOWED), ("CONNECT", _ERROR_1045), ("PURGE", _PURGED)) - for path in ("/graphql/", "/nosuch", "") - ), - *( - pytest.param(m, "/graphql", 2, _TWICE, id=f"{m}, twice") - for m in ("TRACE", "CONNECT", "PURGE") - ), - pytest.param("TRACE", "x/graphql", 1, None, id="a longer name"), -] - - -@pytest.mark.parametrize("vendor", ["linear", "fireflies"]) -def test_cloudflare_refuses_trace_connect_and_purge(client, tokens_yaml, vendor): - admin = tokens_yaml["admin_token"] - key = admin if vendor == "linear" else f"Bearer {admin}" - answered, expected = {}, {} - for row in _CLOUDFLARE_METHODS: - method, path, copies, answer = row.values - r = client.request(method, f"/{vendor}{path}", headers=[("Authorization", key)] * copies) - if answer is None: - answered[row.id] = (r.status_code, b"cloudflare" in r.content) - expected[row.id] = (404, False) - continue - got = (r.status_code, r.headers["content-type"], r.headers.get("connection"), r.content) - answered[row.id], expected[row.id] = got, answer - assert len(expected) == len(_CLOUDFLARE_METHODS) + expected[row.id] = (*answer[:4], b"" if method == "HEAD" else answer[4]) assert answered == expected From 8052b14316b4e60316b396e45061e8a35e8dad9b Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:42:28 +0900 Subject: [PATCH 44/52] main, tests, NOTICE.md: the table comment points at the middlewares, and the notice names the nonce placeholder --- NOTICE.md | 4 ++-- backlot/main.py | 3 ++- tests/test_cross_vendor.py | 9 +++------ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/NOTICE.md b/NOTICE.md index 6a0d57b4e..77af94d0e 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -35,8 +35,8 @@ SOFTWARE. ## Apollo Server's landing page `backlot/graphql/apollo_landing_page.html` is the page Apollo Server 5.5.1 (`@apollo/server`) -serves a browser, as api.fireflies.ai served it, with its nonce left as a placeholder. Apollo -Server is released under the MIT License: +serves a browser, as api.fireflies.ai served it, with `NONCE` where the page holds its nonce. +Apollo Server is released under the MIT License: ```text The MIT License (MIT) diff --git a/backlot/main.py b/backlot/main.py index 29f6b99fc..6234e51d5 100644 --- a/backlot/main.py +++ b/backlot/main.py @@ -1323,7 +1323,8 @@ async def refuse_cloudflares_methods_on_linear_and_fireflies(request: Request, c with Cloudflare's answer, ahead of the route and the credential. Registered before :func:`refuse_a_repeated_header_on_notion_linear_fireflies_and_hubspot`, which answers first: a `TRACE`, `CONNECT` or `PURGE` carrying `Authorization` twice drew its 400 from both vendors on - 2026-10-25 (UTC), and one carrying `If-None-Match` or `If-Modified-Since` twice on 2026-10-28.""" + 2026-10-25 (UTC), and one carrying `If-None-Match` or `If-Modified-Since` twice on + 2026-10-28.""" status, body, headers = _CLOUDFLARE_METHOD[request.method] return Response(body, status_code=status, headers=headers) diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index 8a0a3cc7d..d7c1fc914 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -348,12 +348,9 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( # A request to Linear or Fireflies that Cloudflare answers itself, `{key}` standing for the vendor's # own spelling of the admin token, and the status, media type, length, `Connection` and body of the -# answer: the 400 page -# `backlot.main.refuse_a_repeated_header_on_notion_linear_fireflies_and_hubspot` records for -# `Authorization` or `If-None-Match` sent more than once, ahead of -# `backlot.main._CLOUDFLARE_METHOD`'s answer to each of its methods. The last three rows are the -# control: one header reaches the vendor, and a path that only starts with the vendor's name is not -# the vendor's. +# answer, as `backlot.main.refuse_cloudflares_methods_on_linear_and_fireflies` and the +# repeated-header middleware it names record them. The last three rows are the control: one header +# reaches the vendor, and a path that only starts with the vendor's name is not the vendor's. _TYPENAME = b'{"query":"{ __typename }"}' _CLOUDFLARE = 400 _PAGE = ( From ce5cc228025071e0eedb023b09c45e00c26ead2b Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:02:01 +0900 Subject: [PATCH 45/52] graphql: the module docstring names a Refusal's code and message, and Refusal says whose words they are --- backlot/graphql/engine.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index e2fc2a380..359d0a7d0 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -10,11 +10,9 @@ does: Apollo Server's checks of the body and of a persisted query come first, then graphql-js's parse, validation and choice of operation (:meth:`Engine.resolve_payload`), then the coercion of variables and the run itself (:meth:`Engine.run`), each refusal a :class:`Refusal` carrying Apollo -Server's ``code`` and the message as Apollo Server writes it, in graphql-js's words where -graphql-js made the refusal (:mod:`backlot.graphql.js_words`). A router that reads the credential -between the two stages calls them apart. A refusal made before execution carries no ``data`` entry; -an error made as the operation runs comes back beside ``data``, at 200. Each router turns the -refusals into its vendor's envelope. +Server's ``code`` and message. A router that reads the credential between the two stages calls them +apart. A refusal made before execution carries no ``data`` entry; an error made as the operation +runs comes back beside ``data``, at 200. Each router turns the refusals into its vendor's envelope. """ from __future__ import annotations From 8b7d08ac6af8e3533896f671330a27a584816a60 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:10:51 +0900 Subject: [PATCH 46/52] graphql: js_words names the five refusals graphql-js 17 words otherwise --- backlot/graphql/js_words.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backlot/graphql/js_words.py b/backlot/graphql/js_words.py index 4c9d6eaec..f2320c384 100644 --- a/backlot/graphql/js_words.py +++ b/backlot/graphql/js_words.py @@ -3,9 +3,9 @@ Linear and Fireflies answered a validation error, a syntax error and a variable's refusal in graphql-js 16.14.2's words on 2026-10-25 (UTC), and Linear refused an array sent for an input object as 16.14.2 does, where 16.9.0 reads the array's indices as field names. graphql-js 17.0.2 words -these otherwise (run on 2026-10-28 (UTC)): a variable of the wrong type, a null or missing non-null -variable, a list item its type refuses, and a missing argument. A vendor's move to 17 shows here as -answers that diverge, not as an error. +five refusals otherwise (run on 2026-10-28 (UTC)): a variable of the wrong type, a null non-null +variable, a missing non-null variable, a list item its type refuses, and a missing argument. A +vendor's move to 17 shows here as answers that diverge, not as an error. Linear and Fireflies run graphql-js under Apollo Server, and graphql-core is a port of graphql-js whose messages differ from it: graphql-core quotes a name, a type, a token or a character with From a3640564b13f44fc620d1f6b72d529b60635b764 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:52:47 +0900 Subject: [PATCH 47/52] linear, tests: the route docstring describes one route, and the Cloudflare table names its 400 page by what it is --- backlot/routers/linear.py | 6 ++--- tests/test_cross_vendor.py | 52 +++++++++++++++++++++++--------------- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index b9f21d752..ec08c51bf 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -137,9 +137,9 @@ async def graphql(request: Request): """Linear's endpoint, in front of which Linear runs Express and ``cors``, at `/graphql` and at each other path Express hands Apollo Server (:func:`routes_to_apollo`), which ``backlot.main`` routes here. Not in the OpenAPI document on purpose: this is a GraphQL - service, and describing one POST route that accepts an arbitrary query would tell an - OpenAPI→MCP bridge nothing useful (hence no ``SOURCE_PREFIXES`` entry either — see - ``backlot/openapi.py``). Introspection is the schema description for this endpoint. + service, and describing one route that accepts an arbitrary query would tell an OpenAPI→MCP + bridge nothing useful (hence no ``SOURCE_PREFIXES`` entry either — see ``backlot/openapi.py``). + Introspection is the schema description for this endpoint. The layers answer in this order, each measured against api.linear.app on 2026-10-25 (UTC) with the one after it wrong too: those of :func:`_front`, ``body-parser`` (:func:`_has_body`, diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index 4b3329c6a..f02e259e5 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -359,7 +359,7 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( b"\r\n%s\r\n\r\n

%s

\r\n" b"
cloudflare
\r\n\r\n\r\n" ) -_TWICE = (_CLOUDFLARE, "text/html", "155", "close", _PAGE % ((b"400 Bad Request",) * 2)) +_BAD_REQUEST = (_CLOUDFLARE, "text/html", "155", "close", _PAGE % ((b"400 Bad Request",) * 2)) _NOT_ALLOWED = (405, "text/html", "155", "close", _PAGE % ((b"405 Not Allowed",) * 2)) _ERROR_1045 = (400, "text/plain; charset=UTF-8", "17", None, b"error code: 1045\n") _PURGED = (400, "text/html; charset=UTF-8", "155", None, _PAGE % ((b"400 Bad Request",) * 2)) @@ -371,39 +371,51 @@ def _auth(*values: str) -> tuple: _ETAG_TWICE = (("If-None-Match", '"x"'),) * 2 _CLOUDFLARE_FIRST = [ - pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}", "{key}"), _TWICE, id="two keys"), - pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}", "nope"), _TWICE, id="a key, nope"), - pytest.param("POST", "/graphql", _TYPENAME, _auth("nope", "{key}"), _TWICE, id="nope, a key"), - pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}", ""), _TWICE, id="a key, empty"), - pytest.param("POST", "/graphql", _TYPENAME, _auth("", ""), _TWICE, id="two empty"), - pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}") * 3, _TWICE, id="three keys"), - pytest.param("POST", "/graphql", b"", _auth("{key}", "nope"), _TWICE, id="no bytes"), - pytest.param("POST", "/graphql", b"{", _auth("{key}", "nope"), _TWICE, id="{"), - pytest.param("POST", "/graphql", None, _auth("{key}", "nope"), _TWICE, id="past the limit"), - pytest.param("GET", "/graphql", b"", _auth("{key}", "nope"), _TWICE, id="GET"), - pytest.param("HEAD", "/graphql", b"", _auth("{key}", "nope"), _TWICE, id="HEAD"), - pytest.param("POST", "/nosuch", _TYPENAME, _auth("{key}", "nope"), _TWICE, id="another path"), - pytest.param("POST", "", _TYPENAME, _auth("{key}", "nope"), _TWICE, id="the root"), + pytest.param( + "POST", "/graphql", _TYPENAME, _auth("{key}", "{key}"), _BAD_REQUEST, id="two keys" + ), + pytest.param( + "POST", "/graphql", _TYPENAME, _auth("{key}", "nope"), _BAD_REQUEST, id="a key, nope" + ), + pytest.param( + "POST", "/graphql", _TYPENAME, _auth("nope", "{key}"), _BAD_REQUEST, id="nope, a key" + ), + pytest.param( + "POST", "/graphql", _TYPENAME, _auth("{key}", ""), _BAD_REQUEST, id="a key, empty" + ), + pytest.param("POST", "/graphql", _TYPENAME, _auth("", ""), _BAD_REQUEST, id="two empty"), + pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}") * 3, _BAD_REQUEST, id="three keys"), + pytest.param("POST", "/graphql", b"", _auth("{key}", "nope"), _BAD_REQUEST, id="no bytes"), + pytest.param("POST", "/graphql", b"{", _auth("{key}", "nope"), _BAD_REQUEST, id="{"), + pytest.param( + "POST", "/graphql", None, _auth("{key}", "nope"), _BAD_REQUEST, id="past the limit" + ), + pytest.param("GET", "/graphql", b"", _auth("{key}", "nope"), _BAD_REQUEST, id="GET"), + pytest.param("HEAD", "/graphql", b"", _auth("{key}", "nope"), _BAD_REQUEST, id="HEAD"), + pytest.param( + "POST", "/nosuch", _TYPENAME, _auth("{key}", "nope"), _BAD_REQUEST, id="another path" + ), + pytest.param("POST", "", _TYPENAME, _auth("{key}", "nope"), _BAD_REQUEST, id="the root"), *( pytest.param(m, path, b"", _auth("{key}"), answer, id=f"{m} {path or '/'}") for m, answer in (("TRACE", _NOT_ALLOWED), ("CONNECT", _ERROR_1045), ("PURGE", _PURGED)) for path in ("/graphql/", "/nosuch", "") ), *( - pytest.param(m, "/graphql", b"", _auth("{key}", "{key}"), _TWICE, id=f"{m}, two keys") + pytest.param(m, "/graphql", b"", _auth("{key}", "{key}"), _BAD_REQUEST, id=f"{m}, two keys") for m in ("CONNECT", "PURGE") ), *( pytest.param( - m, "/graphql", b"", (*_auth("{key}"), *_ETAG_TWICE), _TWICE, id=f"{m}, ETag twice" + m, "/graphql", b"", (*_auth("{key}"), *_ETAG_TWICE), _BAD_REQUEST, id=f"{m}, ETag twice" ) for m in ("TRACE", "CONNECT", "PURGE") ), - pytest.param("POST", "/a%2", _TYPENAME, _auth("{key}"), _TWICE, id="%2 at the end"), - pytest.param("POST", "/graphql%zz", _TYPENAME, (), _TWICE, id="%zz"), - pytest.param("POST", "/nosucha%b", b"{}", (), _TWICE, id="% before b"), + pytest.param("POST", "/a%2", _TYPENAME, _auth("{key}"), _BAD_REQUEST, id="%2 at the end"), + pytest.param("POST", "/graphql%zz", _TYPENAME, (), _BAD_REQUEST, id="%zz"), + pytest.param("POST", "/nosucha%b", b"{}", (), _BAD_REQUEST, id="% before b"), *( - pytest.param(m, p, b"", (), _TWICE, id=f"{m} {p}") + pytest.param(m, p, b"", (), _BAD_REQUEST, id=f"{m} {p}") for m in ("TRACE", "CONNECT", "PURGE") for p in ("/a%zz", "/a%00b") ), From 62f6351879c096ea187abb43e8c3274ddac84bd2 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:41:47 +0900 Subject: [PATCH 48/52] graphql: js_words names each kind of refusal graphql-js 17 answers otherwise The five refusals the docstring listed were a sample. Run over documents for each of graphql-js 16.14.2's validation rules, lexer and parser errors and value coercions, 17.0.2 also words otherwise the refusal of an argument's value as the field runs, a null literal for a non-null type, an input object literal of the wrong shape, a custom scalar's literal refusal, and the syntax errors at `.5` and `..`; it refuses a subscription to a schema without one as it validates, adds refusals for a subscription root's @skip/@include and for @defer/@stream, and orders an unused variable's refusal first. --- backlot/graphql/js_words.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/backlot/graphql/js_words.py b/backlot/graphql/js_words.py index f2320c384..18abe05ca 100644 --- a/backlot/graphql/js_words.py +++ b/backlot/graphql/js_words.py @@ -2,10 +2,17 @@ Linear and Fireflies answered a validation error, a syntax error and a variable's refusal in graphql-js 16.14.2's words on 2026-10-25 (UTC), and Linear refused an array sent for an input object -as 16.14.2 does, where 16.9.0 reads the array's indices as field names. graphql-js 17.0.2 words -five refusals otherwise (run on 2026-10-28 (UTC)): a variable of the wrong type, a null non-null -variable, a missing non-null variable, a list item its type refuses, and a missing argument. A -vendor's move to 17 shows here as answers that diverge, not as an error. +as 16.14.2 does, where 16.9.0 reads the array's indices as field names. Of the refusals this module +words, graphql-js 17.0.2 words otherwise each refusal of a variable's value, of an argument's value +as the field runs, of a missing argument, of a ``null`` written for a non-null type, of an input +object written as another kind of value, without a required field or with an unknown one, and of a +literal a custom scalar refuses, unless it refuses with a ``GraphQLError``, and the syntax errors at +``.5`` and ``..``. It also refuses a subscription where the schema has no subscription type as it +validates, in words of its own, adds a refusal of ``@skip`` or ``@include`` at a subscription's root +and, beside the unknown directive, the refusals its rules for ``@defer`` and ``@stream`` make, and +puts a variable's being unused ahead of its type's being unknown or not an input type. That was run +on 2026-10-28 (UTC) over documents for each of 16.14.2's validation rules, lexer and parser errors +and coercions of a value. A vendor's move to 17 shows here as answers that diverge, not as an error. Linear and Fireflies run graphql-js under Apollo Server, and graphql-core is a port of graphql-js whose messages differ from it: graphql-core quotes a name, a type, a token or a character with From fbb2247ea50eaea8bfdea61981fb8192f20b623c Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:45:42 +0900 Subject: [PATCH 49/52] linear: introspection is answered without a credential, the 401 is Linear's, and an unknown id is Linear's INPUT_ERROR (#1028) --- backlot/graphql/linear_resolvers.py | 49 +++-- backlot/routers/linear.py | 96 +++++++--- backlot/telemetry/errors.py | 15 +- tests/test_acl.py | 5 +- tests/test_linear.py | 282 ++++++++++++++++++++++++---- tests/test_telemetry.py | 15 +- 6 files changed, 374 insertions(+), 88 deletions(-) diff --git a/backlot/graphql/linear_resolvers.py b/backlot/graphql/linear_resolvers.py index f54b3ab33..10a055efc 100644 --- a/backlot/graphql/linear_resolvers.py +++ b/backlot/graphql/linear_resolvers.py @@ -1175,6 +1175,27 @@ def resolve_documents(_root, info, first=None, after=None, last=None, before=Non return _list_page([], first, after, last, before) +def _not_found(entity: str) -> GraphQLError: + """Linear's error for a by-id root given an id it holds no ``entity`` for, as api.linear.app + answered each of ``issue``, ``team``, ``user``, ``workflowState``, ``project``, ``issueLabel``, + ``cycle``, ``release``, ``issueRelation`` and ``teamMembership`` with an unknown UUID and with + ``nope`` on 2026-10-25 (UTC). ``attachment`` answered in words of its own (``_ATTACHMENT``).""" + return _input_error(f"Entity not found: {entity}", f"Could not find referenced {entity}.") + + +def _input_error(message: str, presentable: str) -> GraphQLError: + return GraphQLError( + message, + extensions={ + "type": "invalid input", + "code": "INPUT_ERROR", + "statusCode": 400, + "userError": True, + "userPresentableMessage": presentable, + }, + ) + + def resolve_issue(_root, info, id): """``issue(id:)`` takes a UUID or a human identifier, as the real API does. Linear declares this non-null, so a miss is an error rather than a null — the same thing the real API does @@ -1185,7 +1206,7 @@ def resolve_issue(_root, info, id): if row is None: row = store.linear_issue_by_identifier(conn, str(id), visible_ids=visible) if row is None: - raise GraphQLError(f"Entity not found: Issue - Could not find referenced Issue. id={id}") + raise _not_found("Issue") return _issue(row, info) @@ -1224,7 +1245,7 @@ def resolve_team(_root, info, id): ): container = None if container is None: - raise GraphQLError(f"Entity not found: Team - Could not find referenced Team. id={id}") + raise _not_found("Team") return _team(container, info) @@ -1384,9 +1405,7 @@ def _by_id(info, kind: str, id_value, entity: str, *, unstored=None): ): found = None # exists in the corpus, but not for this caller — answer as if absent if found is None: - raise GraphQLError( - f"Entity not found: {entity} - Could not find referenced {entity}. id={id_value}" - ) + raise _not_found(entity) return found @@ -1429,15 +1448,21 @@ def resolve_release(_root, info, id) -> dict: return _release(_by_id(info, "release", id, "Release"), info) +# What api.linear.app answered `attachment(id:)` with for an unknown UUID and for `nope` on +# 2026-10-25 (UTC): its message and presentable text speak of a url either way. +_ATTACHMENT = ( + "Entity not found: Attachment: could not find by url.", + "Could not find referenced attachment with this url.", +) + + def resolve_attachment(_root, info, id) -> dict: """Attachments live in their own table, so this resolves by row id and scopes on the parent issue rather than through the name-keyed entities the other roots use.""" ctx = _ctx(info) row = store.linear_attachment_by_id(ctx["conn"], str(id), visible_ids=ctx["visible_ids"]) if row is None: - raise GraphQLError( - f"Entity not found: Attachment - Could not find referenced Attachment. id={id}" - ) + raise _input_error(*_ATTACHMENT) return _attachment(row, info) @@ -1445,9 +1470,7 @@ def resolve_issue_relation(_root, info, id) -> dict: ctx = _ctx(info) row = store.linear_relation_by_id(ctx["conn"], str(id), visible_ids=ctx["visible_ids"]) if row is None: - raise GraphQLError( - f"Entity not found: IssueRelation - Could not find referenced IssueRelation. id={id}" - ) + raise _not_found("IssueRelation") return _relation(row, info) @@ -1582,7 +1605,7 @@ def resolve_team_membership(_root, info, id) -> dict: if synth.linear_team_membership_id(container, user_id) == str(id).lower(): user = _user(row["email"], row["display_name"], info) return _membership(container, user, info) - raise GraphQLError("Entity not found: TeamMembership") + raise _not_found("TeamMembership") def resolve_team_membership_of(team, info, userId) -> dict | None: @@ -1890,7 +1913,7 @@ def _issue_by_id(info, issue_id): ctx = _ctx(info) row = store.get_document(ctx["conn"], "linear", issue_id, visible_ids=ctx["visible_ids"]) if row is None: - raise GraphQLError("Entity not found: Issue - Could not find referenced Issue.") + raise _not_found("Issue") return _issue(row, info) diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index 73d4c9d1b..af9c0743f 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -21,11 +21,13 @@ string ``variables``, an ``extensions`` or ``operationName`` of ``5``, a persisted query's hash alone or one that does not match, a document that does not parse or validate and one of two operations with no ``operationName`` drew what the key drew on 2026-10-25 (UTC), and a document -missing a variable the 401. An API key under ``Bearer`` is refused ahead of the body: with -``Bearer ``, ``{``, an empty body and a body holding ``NaN`` drew -:func:`_api_key_as_bearer`'s 400 on 2026-10-18, and ``charset=latin1``, ``Content-Encoding: bogus`` -and a body past the size limit did on 2026-10-19, when a key in an API key's shape that Linear never -issued drew it under ``Bearer`` with ``{``, ``NaN``, ``5``, ``charset=latin1`` and an empty body. +missing a variable the 401, but for one that selects only ``__schema`` and ``__type`` +(:func:`_introspection_only`), which drew the variable's refusal as with the key. An API key under +``Bearer`` is refused ahead of the body: with ``Bearer ``, ``{``, an empty body and a body +holding ``NaN`` drew :func:`_api_key_as_bearer`'s 400 on 2026-10-18, and ``charset=latin1``, +``Content-Encoding: bogus`` and a body past the size limit did on 2026-10-19, when a key in an API +key's shape that Linear never issued drew it under ``Bearer`` with ``{``, ``NaN``, ``5``, +``charset=latin1`` and an empty body. """ from __future__ import annotations @@ -35,6 +37,7 @@ from fastapi import APIRouter, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import JSONResponse, Response +from graphql import FieldNode, FragmentDefinitionNode, FragmentSpreadNode from backlot import auth from backlot.config import get_settings @@ -122,21 +125,10 @@ async def graphql(request: Request): return _answer(request, resolved, payload) caller = auth.resolve_api_key(request) if caller is None: - # Real Linear answers a bad credential with a GraphQL error envelope and a 401, not a - # framework 403 — clients parse `errors[0].message`. - return JSONResponse( - { - "errors": [ - { - "message": "Authentication required", - "extensions": { - "type": "authentication error", - "userPresentableMessage": "Invalid API key", - }, - } - ] - }, - status_code=401, + if _introspection_only(resolved): + return _answer(request, ENGINE.run(resolved), payload) + return Response( + request_body.node_json(_UNAUTHENTICATED), status_code=401, media_type="application/json" ) state = request.app.state context = { @@ -155,6 +147,54 @@ async def graphql(request: Request): return _answer(request, ENGINE.run(resolved, context=context), payload) +# Linear's 401, as api.linear.app answered `{ viewer { id } }` with no `Authorization`, with +# `Bearer nope`, `nope`, `Bearer` and `Basic Zm9vOmJhcg==` on 2026-10-25 (UTC). +_UNAUTHENTICATED = { + "errors": [ + { + "message": "Authentication required, not authenticated", + "extensions": { + "type": "authentication error", + "code": "AUTHENTICATION_ERROR", + "statusCode": 401, + "userError": True, + "userPresentableMessage": "You need to authenticate to access this operation.", + "meta": {}, + "http": {"status": 401}, + }, + } + ] +} +_INTROSPECTION = frozenset({"__schema", "__type"}) + + +def _introspection_only(operation: engine.Operation) -> bool: + """Whether each root field ``operation`` selects, through its fragments and whatever their + directives say, is ``__schema`` or ``__type``. Linear answered such a document with no + credential on 2026-10-25 (UTC), aliased, in a named or an inline fragment, under ``@skip(if: + false)`` and as the operation ``operationName`` chose, and answered the 401 to one that also + selects ``__typename`` or a field it skips.""" + fragments = { + d.name.value: d + for d in operation.document.definitions + if isinstance(d, FragmentDefinitionNode) + } + + def only(selections) -> bool: + for selection in selections: + if isinstance(selection, FieldNode): + if selection.name.value not in _INTROSPECTION: + return False + elif isinstance(selection, FragmentSpreadNode): + if not only(fragments[selection.name.value].selection_set.selections): + return False + elif not only(selection.selection_set.selections): + return False + return True + + return only(operation.operation.selection_set.selections) + + def _answer(request: Request, result: engine.Result, payload: Any) -> Response: request.state.graphql_raised = result.raised return Response( @@ -241,7 +281,8 @@ def _envelope(result: engine.Result, payload: Any) -> dict: or its hash as Linear's ``Internal server error``, an operation that cannot be chosen as :func:`_operation_missing`, and the request error after fifty refused variables, to which Apollo Server gives no code of its own, as :func:`_internal` (api.linear.app, 2026-10-26 - UTC); and each error in ``result.assigned`` as :func:`_internal`.""" + UTC); each error in ``result.assigned`` as :func:`_internal`, and each other field error in + Linear's member order (:func:`_in_linears_order`).""" if result.refusals: errors = [] for refusal in result.refusals: @@ -263,15 +304,24 @@ def _envelope(result: engine.Result, payload: Any) -> dict: _apollo(refusal.message, refusal.code, refusal.status, refusal.locations) ) return {"errors": errors} - if not result.assigned: + if "errors" not in result.payload: return result.payload errors = [ - _internal(error) if i in result.assigned else error + _internal(error) if i in result.assigned else _in_linears_order(error) for i, error in enumerate(result.payload["errors"]) ] return {**result.payload, "errors": errors} +def _in_linears_order(error: dict) -> dict: + """``error``'s members in the order Linear writes a field error's: api.linear.app put ``path`` + before ``locations`` in each by-id root's not-found and in an ``Argument Validation Error`` on + 2026-10-25 (UTC).""" + return { + key: error[key] for key in ("message", "path", "locations", "extensions") if key in error + } + + def _read(raw: bytes, content_type: str | None, content_encoding: str) -> Any: """The payload Linear reads from the body ``raw``, or the response this router refuses it with. diff --git a/backlot/telemetry/errors.py b/backlot/telemetry/errors.py index 80620c574..1ce60d4f8 100644 --- a/backlot/telemetry/errors.py +++ b/backlot/telemetry/errors.py @@ -3,11 +3,10 @@ An error code is the vendor's own identifier where its envelope has one: Slack's ``error``, S3's ````, Google's ``reason`` or else its ``status``, Notion's ``code``, HubSpot's ``category``, Microsoft Graph's ``error.code``, a GraphQL error's ``extensions.code``, or the ``code`` Fireflies -writes beside the message. GitHub, Jira, Confluence, Graph's OData validator, Linear's 401 and the -Linear resolvers' refusals that carry no code answer with a message only; those messages are -Backlot's own text, so each maps to a fixed slug here and the message itself, which can carry a -key, a sha or a field name from the request, is never sent. A message this table does not know is -``unrecognized``. +writes beside the message. GitHub, Jira, Confluence, Graph's OData validator and the Linear +resolvers' refusals that carry no code answer with a message only; those messages are Backlot's own +text, so each maps to a fixed slug here and the message itself, which can carry a key, a sha or a +field name from the request, is never sent. A message this table does not know is ``unrecognized``. The HTTP layer in front of Linear's and Fireflies' GraphQL refuses a body in an envelope of its own, with no ``errors``. Fireflies' HTTP layer names its code in ``code``. Linear's is Express, which @@ -98,11 +97,7 @@ (r"Failed to convert value|cannot be less than zero", "invalid_parameter"), (r"Cannot find custom content type", "content_type_not_found"), ] -_GRAPHQL = [ - (r"^Entity not found", "entity_not_found"), - (r"^unsupported ", "unsupported_filter"), - (r"^Authentication required", "authentication_required"), -] +_GRAPHQL = [(r"^unsupported ", "unsupported_filter")] # Graph's OData validator answers with an empty `error.code`; its message names the option. _MSGRAPH = [(r"^The query specified in the URI is not valid\.", "invalid_query_option")] _S3_CODE = re.compile(rb"([A-Za-z0-9_.-]{1,64})") diff --git a/tests/test_acl.py b/tests/test_acl.py index 13d809e8c..5712146c3 100644 --- a/tests/test_acl.py +++ b/tests/test_acl.py @@ -191,10 +191,7 @@ def test_linear_by_id_roots_do_not_leak_entities_off_hidden_issues(sample_settin % synth.linear_state_id("No Such State", "engineering"), tokens["ava@acme.com"], ) - assert ( - absent["errors"][0]["message"].split("id=")[0] - == hidden["errors"][0]["message"].split("id=")[0] - ) + assert absent == hidden def test_linear_by_id_roots_still_answer_for_visible_entities(sample_settings, tokens): diff --git a/tests/test_linear.py b/tests/test_linear.py index 82b2a2134..fd3ce5fa8 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -23,6 +23,7 @@ from backlot import synth from backlot.fidelity.graphql_diff import backlot_schema from backlot.graphql import linear_filters, mcp_tools +from backlot.graphql.body import node_json from tests._helpers import ( SWEPT_BYTES, WithTheKey, @@ -181,19 +182,6 @@ def test_linear_comment_url_is_the_issue_url_with_the_ids_first_eight(client, ad assert same[0]["url"] == from_comment["url"] -def test_linear_missing_issue_is_a_field_error_not_a_400(client, admin_h): - """Linear declares `issue` non-null, so a miss nulls `data` and reports an error — but the - request itself was fine, so the status stays 200.""" - r = gql(client, '{ issue(id: "NOPE-1") { identifier } }', admin_h) - assert r.status_code == 200 - assert r.content.startswith(b'{"errors":[') - assert r.json()["data"] is None - assert "Entity not found" in r.json()["errors"][0]["message"] - # control: an issue that exists still opens with "data" - ok = gql(client, '{ issue(id: "ENG-101") { identifier } }', admin_h) - assert ok.content.startswith(b'{"data":') - - def test_linear_team_resolves_by_key_and_uuid(client, admin_h, ro_conn): key = gql(client, '{ team(id: "ENG") { id key name } }', admin_h).json()["data"]["team"] assert (key["key"], key["name"]) == ("ENG", "engineering") @@ -733,6 +721,111 @@ def test_linear_by_id_relation_roots_answer(client, admin_h): assert label["groupType"] is None +def _unknown_id(root: str, id_: str, message: str, presentable: str, row: str): + """A `_LINEAR_BODIES` row: the by-id root ``root`` sent an id it holds nothing for, and Linear's + error for it with ``message`` and ``presentable`` (`linear_resolvers._not_found`, + `linear_resolvers._ATTACHMENT`).""" + body = {"query": "query($x: String!) { %s(id: $x) { id } }" % root, "variables": {"x": id_}} + error = { + "message": message, + "path": [root], + "locations": [{"line": 1, "column": 22}], + "extensions": { + "type": "invalid input", + "code": "INPUT_ERROR", + "statusCode": 400, + "userError": True, + "userPresentableMessage": presentable, + }, + } + answer = node_json({"errors": [error], "data": None}) + return pytest.param(json.dumps(body).encode(), WithTheKey(answer, 200), id=row) + + +_UNKNOWN_IDS = [ + _unknown_id( + "issue", + "00000000-0000-0000-0000-000000000000", + "Entity not found: Issue", + "Could not find referenced Issue.", + "issue 00000000", + ), + _unknown_id( + "issue", + "NOPE-1", + "Entity not found: Issue", + "Could not find referenced Issue.", + "issue NOPE-1", + ), + _unknown_id( + "team", "nope", "Entity not found: Team", "Could not find referenced Team.", "team nope" + ), + _unknown_id( + "user", + "00000000-0000-0000-0000-000000000000", + "Entity not found: User", + "Could not find referenced User.", + "user 00000000", + ), + _unknown_id( + "workflowState", + "nope", + "Entity not found: WorkflowState", + "Could not find referenced WorkflowState.", + "workflowState nope", + ), + _unknown_id( + "project", + "00000000-0000-0000-0000-000000000000", + "Entity not found: Project", + "Could not find referenced Project.", + "project 00000000", + ), + _unknown_id( + "issueLabel", + "nope", + "Entity not found: IssueLabel", + "Could not find referenced IssueLabel.", + "issueLabel nope", + ), + _unknown_id( + "cycle", + "00000000-0000-0000-0000-000000000000", + "Entity not found: Cycle", + "Could not find referenced Cycle.", + "cycle 00000000", + ), + _unknown_id( + "release", + "nope", + "Entity not found: Release", + "Could not find referenced Release.", + "release nope", + ), + _unknown_id( + "issueRelation", + "nope", + "Entity not found: IssueRelation", + "Could not find referenced IssueRelation.", + "issueRelation nope", + ), + _unknown_id( + "teamMembership", + "00000000-0000-0000-0000-000000000000", + "Entity not found: TeamMembership", + "Could not find referenced TeamMembership.", + "teamMembership 00000000", + ), + _unknown_id( + "attachment", + "00000000-0000-0000-0000-000000000000", + "Entity not found: Attachment: could not find by url.", + "Could not find referenced attachment with this url.", + "attachment 00000000", + ), +] + + def test_linear_workflow_states_are_per_team(client, admin_h): """Two teams' identically-named states are different objects in Linear, so their ids differ. The corpus has no shared state name, so assert the construction directly instead.""" @@ -843,12 +936,6 @@ def test_linear_malformed_document_is_a_400_with_a_graphql_envelope(client, admi assert "Syntax Error" in body["errors"][0]["message"] -def test_linear_unauthenticated_is_401(client): - r = client.post("/linear/graphql", json={"query": "{ viewer { email } }"}) - assert r.status_code == 401 - assert r.json()["errors"][0]["message"] == "Authentication required" - - # --- the body, and where the credential is read against it --------------------------------------- def _apollo(message: bytes) -> bytes: """Apollo Server's refusal of a body as Linear writes it around ``message``, already @@ -863,17 +950,20 @@ def _status(answer: bytes) -> int: envelope = json.loads(answer) if "code" in envelope: return envelope["code"] - return envelope["errors"][0]["extensions"]["http"]["status"] + if "errors" not in envelope: + return 200 + return envelope["errors"][0]["extensions"].get("http", {}).get("status", 400) _NO_KEYS = _apollo(b"POST body missing, invalid Content-Type, or JSON object has no keys.") _BATCHING = _apollo(b"Operation batching disabled.") # The headers a body was sent with beyond `Content-Type: application/json`, the body, and the bytes -# Linear refused it with for the key, no credential and `nope` alike, at the status their `code` or -# `extensions.http.status` names (`_status`), or None where Linear served the query with the key and -# answered 401 without one, as `backlot.graphql.body` and `backlot.graphql.v8_json` read them. A -# `WithTheKey` row draws the 401 without the key. Apollo Server's envelopes are api.linear.app's, -# but for the newline that ends each there. +# Linear answered it with for the key, no credential and `nope` alike, at the status their `code` or +# `extensions.http.status` names, 400 for an error naming neither and 200 for an answer with no +# `errors` (`_status`), or None where Linear served the query with the key and answered 401 without +# one, as `backlot.graphql.body` and `backlot.graphql.v8_json` read them. A `WithTheKey` row draws +# the 401 without the key. Apollo Server's envelopes are api.linear.app's, but for the newline that +# ends each there. _LINEAR_BODIES = [ pytest.param( b"{", @@ -1842,6 +1932,97 @@ def _status(answer: bytes) -> int: ), id="null for a non-null argument after U+2028 in a comment", ), + pytest.param( + b'{"query":"{ __schema { queryType { name } } }"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', + id="introspection", + ), + pytest.param( + b'{"query":"{ __type(name: \\"Query\\") { name } }"}', + b'{"data":{"__type":{"name":"Query"}}}', + id="__type", + ), + pytest.param( + b'{"query":"{ s: __schema { queryType { name } } }"}', + b'{"data":{"s":{"queryType":{"name":"Query"}}}}', + id="an alias of __schema", + ), + pytest.param( + b'{"query":"{ __schema { queryType { name } } __type(name: \\"Issue\\") { name } }"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}},"__type":{"name":"Issue"}}}', + id="__schema and __type", + ), + pytest.param( + b'{"query":"{ ...F } fragment F on Query { __schema { queryType { name } } }"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', + id="__schema in a fragment", + ), + pytest.param( + b'{"query":"{ ... on Query { __schema { queryType { name } } } }"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', + id="__schema in an inline fragment", + ), + pytest.param( + b'{"query":"{ __schema @skip(if: false) { queryType { name } } }"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', + id="__schema under @skip(if: false)", + ), + pytest.param( + b'{"query":"query Q { __schema { queryType { name } } }"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', + id="a named query of __schema", + ), + pytest.param( + b'{"query":"query A { __schema { queryType { name } } } query B { viewer { id } }","operationName":"A"}', + b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', + id="the introspection operationName chose", + ), + pytest.param( + b'{"query":"query($b: Boolean!) { __schema @include(if: $b) { queryType { name } } }"}', + b'{"errors":[{"message":"Variable \\"$b\\" of required type \\"Boolean!\\" was not provided.","locations":[{"line":1,"column":7}],"extensions":{"code":"BAD_USER_INPUT","type":"graphql error","userError":true}}]}', + id="introspection with a variable not provided", + ), + pytest.param( + b'{"query":"{ __typename __schema { queryType { name } } }"}', + WithTheKey( + b'{"data":{"__typename":"Query","__schema":{"queryType":{"name":"Query"}}}}', 200 + ), + id="__typename beside __schema", + ), + pytest.param( + b'{"query":"{ __schema { queryType { name } } viewer @skip(if: true) { id } }"}', + WithTheKey(b'{"data":{"__schema":{"queryType":{"name":"Query"}}}}', 200), + id="__schema beside a skipped field", + ), + pytest.param( + b'{"query":"{ viewer @skip(if: true) { id } }"}', + WithTheKey(b'{"data":{}}', 200), + id="a skipped field alone", + ), + pytest.param( + b'{"query":"{ ...F } fragment F on Query { __typename }"}', + None, + id="__typename in a fragment", + ), + pytest.param( + b'{"query":"{ ... on Query { __typename } }"}', + None, + id="__typename in an inline fragment", + ), + pytest.param( + b'{"query":"{ __schema { queryType { name } } ...F } fragment F on Query { __typename }"}', + WithTheKey( + b'{"data":{"__schema":{"queryType":{"name":"Query"}},"__typename":"Query"}}', 200 + ), + id="__schema and __typename in a fragment", + ), + pytest.param( + b'{"query":"{ ... on Query { __schema { queryType { name } } } ...F } fragment F on Query { __typename }"}', + WithTheKey( + b'{"data":{"__schema":{"queryType":{"name":"Query"}},"__typename":"Query"}}', 200 + ), + id="__typename in a fragment beside an inline __schema", + ), pytest.param( b'{"query":"{ ...F }"}', b'{"errors":[{"message":"Unknown fragment \\"F\\".","locations":[{"line":1,"column":6}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}', @@ -1852,6 +2033,7 @@ def _status(answer: bytes) -> int: b'{"errors":[{"message":"Cannot spread fragment \\"F\\" within itself.","locations":[{"line":1,"column":23}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}', id="a fragment that spreads itself", ), + *_UNKNOWN_IDS, ] @@ -2213,7 +2395,30 @@ def _charset(name: str) -> tuple: "two operations and no operationName", "a variable not provided", ) -assert set(_EACH_WAY_READ) <= {row.id for row in (*_LINEAR_BODIES, *_LINEAR_READS)} +# The rows whose answer without a key turns on what the document selects +# (`backlot.routers.linear._introspection_only`), each sent with every credential too. +_WHAT_IT_SELECTS = ( + "introspection", + "__type", + "an alias of __schema", + "__schema and __type", + "__schema in a fragment", + "__schema in an inline fragment", + "__schema under @skip(if: false)", + "a named query of __schema", + "the introspection operationName chose", + "introspection with a variable not provided", + "__typename beside __schema", + "__schema beside a skipped field", + "a skipped field alone", + "__typename in a fragment", + "__typename in an inline fragment", + "__schema and __typename in a fragment", + "__typename in a fragment beside an inline __schema", +) +assert set(_EACH_WAY_READ + _WHAT_IT_SELECTS) <= { + row.id for row in (*_LINEAR_BODIES, *_LINEAR_READS) +} def _linear_body_cases() -> list: @@ -2222,7 +2427,7 @@ def _linear_body_cases() -> list: pytest.param(credential, *row.values, id=f"{row.id}-{credential}") for credential in ("key", "none", "nope", "Bearer key", "Bearer, key-shaped") for row in rows - if credential == "key" or row.id in _EACH_WAY_READ + if credential == "key" or row.id in _EACH_WAY_READ + _WHAT_IT_SELECTS ] @@ -2231,8 +2436,9 @@ def test_linear_orders_the_body_and_the_credential( client, tokens_yaml, credential, headers, body, answer ): """Each row answers as the comment over `_LINEAR_BODIES` says with the key, and each row - `_EACH_WAY_READ` names answers so under no credential and `nope` too; an API key under `Bearer`, - and `_KEY_SHAPED`, are `_API_KEY_AS_BEARER` ahead of each of those bodies.""" + `_EACH_WAY_READ` or `_WHAT_IT_SELECTS` names answers so under no credential and `nope` too; an + API key under `Bearer`, and `_KEY_SHAPED`, are `_API_KEY_AS_BEARER` ahead of each of those + bodies.""" admin = tokens_yaml["admin_token"] if not any(name == "Content-Type" for name, _ in headers): headers = (("Content-Type", "application/json"), *headers) @@ -2245,7 +2451,7 @@ def test_linear_orders_the_body_and_the_credential( if credential.startswith("Bearer"): assert (r.status_code, r.content) == (400, _API_KEY_AS_BEARER) elif credential != "key" and (answer is None or isinstance(answer, WithTheKey)): - assert r.status_code == 401 + assert (r.status_code, r.content) == (401, _UNAUTHENTICATED) elif answer is None: assert (r.status_code, r.json()) == (200, {"data": {"__typename": "Query"}}) elif isinstance(answer, WithTheKey): @@ -5180,8 +5386,13 @@ def test_linear_field_error_is_a_200_and_a_syntax_error_is_a_400(tmp_path): b' Remove the Bearer prefix from the Authorization header.","extensions":{"type":"invalid' b' input","code":"INPUT_ERROR","statusCode":400,"userError":true}}]}' ) +_UNAUTHENTICATED = ( + b'{"errors":[{"message":"Authentication required, not authenticated","extensions":{"type":"a' + b'uthentication error","code":"AUTHENTICATION_ERROR","statusCode":401,"userError":true,"user' + b'PresentableMessage":"You need to authenticate to access this operation.","meta":{},"http":{"status":401}}}]}' +) # `Authorization` values, `{t}` for an API key that resolves, and the status Linear gave each: 200 -# serving the query and 400 being `_API_KEY_AS_BEARER`. Backlot's 401 keeps its own body. +# serving the query, 400 being `_API_KEY_AS_BEARER` and 401 `_UNAUTHENTICATED`. _LINEAR_SPELLINGS = [ pytest.param("{t}", 200, id="bare"), pytest.param(" {t}", 200, id="a space before"), @@ -5220,6 +5431,9 @@ def test_linear_field_error_is_a_200_and_a_syntax_error_is_a_400(tmp_path): pytest.param("Bearer nope", 401, id="Bearer nope"), pytest.param("bearer nope", 401, id="bearer nope"), pytest.param("Token {t}", 401, id="Token"), + pytest.param("nope", 401, id="nope"), + pytest.param("Bearer", 401, id="Bearer alone"), + pytest.param("Basic Zm9vOmJhcg==", 401, id="Basic"), ] @@ -5235,8 +5449,8 @@ def test_linear_field_error_is_a_200_and_a_syntax_error_is_a_400(tmp_path): def _linear_status(client, tokens_yaml, value: str) -> int: - """The status `Authorization: value` draws, `{t}` the admin key, a 200 serving the query and a - 400 being `_API_KEY_AS_BEARER`.""" + """The status `Authorization: value` draws, read as the comment over `_LINEAR_SPELLINGS` reads + it.""" header = value.replace("{t}", tokens_yaml["admin_token"]) r = sent_as( client, @@ -5249,6 +5463,8 @@ def _linear_status(client, tokens_yaml, value: str) -> int: assert r.json()["data"]["viewer"]["id"] elif r.status_code == 400: assert r.content == _API_KEY_AS_BEARER + else: + assert r.content == _UNAUTHENTICATED return r.status_code diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index f64332662..dbdda9abb 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -316,7 +316,12 @@ def test_a_failure_is_classed_by_its_answer(source, support, status, code, spent b'{"errors":[{"message":"x","extensions":{"code":"invalid_argument"}}]}', "invalid_argument", ), - ("linear", 200, b'{"errors":[{"message":"Entity not found: Issue"}]}', "entity_not_found"), + ( + "linear", + 200, + b'{"errors":[{"message":"unsupported comparator \'x\'"}]}', + "unsupported_filter", + ), ("linear", 200, b'{"errors":[{"message":"x","extensions":[1]}]}', "unrecognized"), ("linear", 400, b'{"errors":[{"message":"x","extensions":[1]}]}', "unrecognized"), ( @@ -648,7 +653,7 @@ def test_a_call_is_named_by_the_baseline(client, method, path, kwargs, expected) ( '{ issue(id: "x") { id } teams { nodes { id } } }', None, - ("query.issue,teams", "implemented", 200, "entity_not_found", "client"), + ("query.issue,teams", "implemented", 200, "INPUT_ERROR", "client"), ), ( "{ projects { nodes { id } } }", @@ -811,8 +816,8 @@ def test_each_message_slug_is_one_backlot_sends(client, method, path, kwargs, ex # `transcripts` rows there are Fireflies' own. _GRAPHQL_MESSAGES = [ ({"query": "{"}, LINEAR, "GRAPHQL_PARSE_FAILED"), - ({"query": '{ issue(id: "nope") { id } }'}, LINEAR, "entity_not_found"), - ({"query": "{ __typename }"}, {}, "authentication_required"), + ({"query": '{ issue(id: "nope") { id } }'}, LINEAR, "INPUT_ERROR"), + ({"query": "{ __typename }"}, {}, "AUTHENTICATION_ERROR"), ({"query": "{ nosuch }"}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), ({"query": '{ issue(id: "x", bogus: 1) { id } }'}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), ({"query": 'query($x: Nope) { issue(id: "x") { id } }'}, LINEAR, "GRAPHQL_VALIDATION_FAILED"), @@ -1081,7 +1086,7 @@ def boom(): "path, headers, broken, query, expected", [ ("/linear/graphql", LINEAR, "linear_by_id", '{ issue(id: "x") { id } }', "internal_error"), - ("/linear/graphql", LINEAR, None, '{ issue(id: "nope") { id } }', "entity_not_found"), + ("/linear/graphql", LINEAR, None, '{ issue(id: "nope") { id } }', "INPUT_ERROR"), ( "/fireflies/graphql", ADMIN, From 8aa579017b8d6fd7ab0b6d16333e106b0967e7e8 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:57:04 +0900 Subject: [PATCH 50/52] linear, fireflies: each method, content type and Accept is answered by the layer that answers it at the vendor (#1029) --- NOTICE.md | 30 + backlot/graphql/apollo_http.py | 341 +++++++++ backlot/graphql/apollo_landing_page.html | 85 +++ backlot/graphql/body.py | 93 +++ backlot/graphql/engine.py | 55 +- backlot/main.py | 57 ++ backlot/routers/fireflies.py | 238 ++++-- backlot/routers/linear.py | 258 +++++-- backlot/telemetry/errors.py | 12 +- docs/fidelity.md | 2 +- docs/supported-sources.md | 14 +- pyproject.toml | 1 + scripts/gen_docs.py | 2 +- tests/_helpers.py | 28 +- tests/test_acl.py | 50 +- tests/test_cross_vendor.py | 102 ++- tests/test_fireflies.py | 882 +++++++++++++++++++++++ tests/test_graphql.py | 193 ++++- tests/test_linear.py | 815 ++++++++++++++++++++- tests/test_telemetry.py | 63 ++ 20 files changed, 3130 insertions(+), 191 deletions(-) create mode 100644 backlot/graphql/apollo_http.py create mode 100644 backlot/graphql/apollo_landing_page.html diff --git a/NOTICE.md b/NOTICE.md index 38071bddf..77af94d0e 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -32,6 +32,36 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## Apollo Server's landing page + +`backlot/graphql/apollo_landing_page.html` is the page Apollo Server 5.5.1 (`@apollo/server`) +serves a browser, as api.fireflies.ai served it, with `NONCE` where the page holds its nonce. +Apollo Server is released under the MIT License: + +```text +The MIT License (MIT) + +Copyright (c) 2016-2020 Apollo Graph, Inc. (Formerly Meteor Development Group, Inc.) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + ## Trademarks Backlot is an independent project, **not affiliated with, endorsed by, or sponsored by** any of the diff --git a/backlot/graphql/apollo_http.py b/backlot/graphql/apollo_http.py new file mode 100644 index 000000000..044b25bc8 --- /dev/null +++ b/backlot/graphql/apollo_http.py @@ -0,0 +1,341 @@ +"""Apollo Server's HTTP layer, in front of the request pipeline :mod:`backlot.graphql.engine` runs: +what it answers before it reads a document, and the media type of what it answers. + +Both vendors run it. :func:`csrf_refusal` is its check that a request was preflighted, +:func:`search_payload` its reading of a GET's query string, :func:`method_refusal` its 405 for a +method it does not serve, :func:`get_content_type_refusal` its 415 for a GET carrying a content +type other than JSON, :func:`response_type` its choice of media type from ``Accept`` and +:data:`NOT_ACCEPTABLE` its 406 when there is none, and :func:`prefers_html` and +:func:`landing_page` its page for a browser. Each refusal but :data:`NOT_ACCEPTABLE` is thrown +before the request pipeline, so it takes whichever media type ``Accept`` allows and +``application/json`` where it allows none: a :class:`~backlot.graphql.engine.Refusal` that is +``thrown``. A refusal the pipeline makes, from a persisted query's to a variable's, is refused for +the ``Accept`` in turn, with :data:`NOT_ACCEPTABLE`. + +The code is read off Apollo Server 5.5.1's ``runHttpQuery.ts``, ``preventCsrf.ts`` and +``ApolloServer.ts``, and the libraries they call: ``whatwg-mimetype`` 4.0.0 (:func:`mime_essence`) +and ``negotiator`` 1.0.0 (:func:`preferred_media_type`). ``negotiator`` 1.1.0 parses ``Accept`` +anew; api.linear.app and api.fireflies.ai each answered nine values that 1.0.0 and 1.1.0 negotiate +apart as 1.0.0 does on 2026-10-25 (UTC), among them ``application/json;q``, which 1.0.0 refuses. +""" + +from __future__ import annotations + +import functools +import math +import re +import secrets +from pathlib import Path +from typing import Any, Mapping +from urllib.parse import parse_qsl + +from backlot.graphql import body as request_body +from backlot.graphql.engine import BAD_REQUEST, Refusal + +#: The methods uvicorn passes on to the application under httptools 0.9.0: 35 of the 49 sent to it +#: on 2026-10-25 (UTC), the IANA registry's, Node's, ``FOO`` and ``get``. It answered the other 14 +#: with its own 400 before any route ran. A route that answers whatever method its vendor's server +#: answers takes these. +METHODS = ( + "ACL", "BIND", "CHECKOUT", "CONNECT", "COPY", "DELETE", "GET", "HEAD", "LINK", "LOCK", + "M-SEARCH", "MERGE", "MKACTIVITY", "MKCALENDAR", "MKCOL", "MOVE", "NOTIFY", "OPTIONS", "PATCH", + "POST", "PROPFIND", "PROPPATCH", "PURGE", "PUT", "QUERY", "REBIND", "REPORT", "SEARCH", "SOURCE", + "SUBSCRIBE", "TRACE", "UNBIND", "UNLINK", "UNLOCK", "UNSUBSCRIBE", +) # fmt: skip + +#: The media types Apollo Server answers a single result in, its ``MEDIA_TYPES``. +JSON = "application/json; charset=utf-8" +GRAPHQL_RESPONSE = "application/graphql-response+json; charset=utf-8" +CALLBACK = "application/json; callbackSpec=1.0; charset=utf-8" +_HTML = "text/html" +# The incremental-delivery types `prefersHTML` puts ahead of `text/html`. +_MULTIPART = ( + "multipart/mixed; deferSpec=20220824", + "multipart/mixed; incrementalSpec=v0.2", + "multipart/mixed", +) + +_JS_SPACE = request_body.JS_SPACE +# A character JavaScript's `.` matches: any but a LineTerminator. +_JS_ANY = r"[^\n\r\u2028\u2029]" +# negotiator's `simpleMediaTypeRegExp`. +_S = re.escape(_JS_SPACE) +_ACCEPT_ENTRY = re.compile(rf"[{_S}]*([^{_S}/;]+)/([^;{_S}]+)[{_S}]*(?:;({_JS_ANY}*))?\Z") +# The literal JavaScript's `parseFloat` reads, past the white space it skips, each of which Python's +# `float` reads alike. +_FLOAT = re.compile(r"[+-]?(?:Infinity|(?:[0-9]+[.]?[0-9]*|[.][0-9]+)(?:[eE][+-]?[0-9]+)?)") + + +def _js_or(*values: float) -> float: + """``a || b || ...`` over numbers, as JavaScript reads it: the first that is neither zero nor + ``NaN``, or else the last.""" + for value in values[:-1]: + if value and not math.isnan(value): + return value + return values[-1] + + +def _parse_float(text: str) -> float: + """JavaScript's ``parseFloat``: the longest decimal literal at the start of ``text`` past its + white space, or ``NaN``.""" + match = _FLOAT.match(text.lstrip(_JS_SPACE)) + return math.nan if match is None else float(match.group()) + + +def _split_quoted(text: str, separator: str) -> list[str]: + """``text`` split at ``separator`` but where the piece so far holds an odd number of ``"``, as + negotiator's ``splitMediaTypes`` and ``splitParameters`` split.""" + pieces = text.split(separator) + joined = [pieces[0]] + for piece in pieces[1:]: + if joined[-1].count('"') % 2 == 0: + joined.append(piece) + else: + joined[-1] += separator + piece + return joined + + +def _media_range(text: str, index: int | None) -> dict | None: + """negotiator's ``parseMediaType``: the type, subtype, parameters and quality of one entry of + ``Accept``, read up to the first ``q`` parameter, or ``None`` for one it cannot read.""" + match = _ACCEPT_ENTRY.match(text) + if match is None: + return None + params: dict[str, str] = {} + q = 1.0 + if match.group(3): + for parameter in _split_quoted(match.group(3), ";"): + # A parameter with no `=` has an undefined value there, which reads as "" does. + key, _, value = parameter.strip(_JS_SPACE).partition("=") + if value and value[0] == '"' and value[-1] == '"': + value = value[1:-1] + if key.lower() == "q": + q = _parse_float(value) + break + params[key.lower()] = value + return {"type": match.group(1), "subtype": match.group(2), "params": params, "q": q, "i": index} + + +def _specificity(provided: dict, spec: dict, index: int) -> dict | None: + """negotiator's ``specify``: how closely the range ``spec`` names the type ``provided``, or + ``None`` where it does not take it.""" + s = 0 + if spec["type"].lower() == provided["type"].lower(): + s |= 4 + elif spec["type"] != "*": + return None + if spec["subtype"].lower() == provided["subtype"].lower(): + s |= 2 + elif spec["subtype"] != "*": + return None + if spec["params"]: + if all( + value == "*" or value.lower() == provided["params"].get(key, "").lower() + for key, value in spec["params"].items() + ): + s |= 1 + else: + return None + return {"i": index, "o": spec["i"], "q": spec["q"], "s": s} + + +def preferred_media_type(accept: str, provided: tuple[str, ...]) -> str | None: + """negotiator 1.0.0's ``mediaType``: the type in ``provided`` that ``accept`` ranks first, the + earlier in ``provided`` among equals, or ``None`` where it takes none of them.""" + ranges = [ + r + for i, e in enumerate(_split_quoted(accept, ",")) + if (r := _media_range(e.strip(_JS_SPACE), i)) + ] + priorities = [] + for index, type_ in enumerate(provided): + target = _media_range(type_, None) + priority = {"o": -1, "q": 0.0, "s": 0} + for spec in ranges: + found = _specificity(target, spec, index) + if ( + found is not None + and _js_or( + priority["s"] - found["s"], + priority["q"] - found["q"], + priority["o"] - found["o"], + ) + < 0 + ): + priority = found + priorities.append(priority) + accepted = [p for p in priorities if p["q"] > 0] + accepted.sort( + key=functools.cmp_to_key( + lambda a, b: _js_or( + b["q"] - a["q"], b["s"] - a["s"], a["o"] - b["o"], a["i"] - b["i"], 0 + ) + ) + ) + return ( + provided[next(i for i, p in enumerate(priorities) if p is accepted[0])] + if accepted + else None + ) + + +def response_type(accept: str | None) -> str | None: + """``chooseContentTypeForSingleResultResponse``: the media type Apollo Server answers in under + ``accept``, :data:`JSON` for none, or ``None`` where it takes none of its three.""" + if not accept: + return JSON + return preferred_media_type(accept, (JSON, GRAPHQL_RESPONSE, CALLBACK)) + + +def prefers_html(method: str, accept: str | None) -> bool: + """``prefersHTML``: whether a GET's ``accept`` puts ``text/html`` ahead of the media types + Apollo Server answers in, which it lists first so that a tie goes to them.""" + return ( + method == "GET" + and bool(accept) + and preferred_media_type(accept, (JSON, GRAPHQL_RESPONSE, *_MULTIPART, _HTML)) == _HTML + ) + + +#: Apollo Server's 406, thrown once the request pipeline has answered, where ``Accept`` takes none +#: of :func:`response_type`'s types. Its ``http`` extension carries no ``headers``. +NOT_ACCEPTABLE = Refusal( + "An 'accept' header was provided for this request which does not accept " + f"{JSON} or {GRAPHQL_RESPONSE}", + BAD_REQUEST, + status=406, + thrown=True, + http_headers=False, +) + +# An HTTP token, as `whatwg-mimetype` reads one. +_TOKEN = re.compile(r"[-!#$%&'*+.^_`|~A-Za-z0-9]+") +_HTTP_SPACE = " \t\n\r" + + +def mime_essence(value: str) -> str | None: + """``whatwg-mimetype`` 4.0.0's ``MIMEType.parse(value).essence``: the type and subtype in lower + case, or ``None`` where it parses none. The parameters cannot make it ``None``.""" + value = value.strip(_HTTP_SPACE) + type_, _, rest = value.partition("/") + if not _TOKEN.fullmatch(type_): + return None + subtype = rest.partition(";")[0].rstrip(_HTTP_SPACE) + if not _TOKEN.fullmatch(subtype): + return None + return f"{type_}/{subtype}".translate(_ASCII_LOWER) + + +_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz") + +_NOT_PREFLIGHTED = ("application/x-www-form-urlencoded", "multipart/form-data", "text/plain") +_PREFLIGHT_HEADERS = ("x-apollo-operation-name", "apollo-require-preflight") +CSRF = ( + "This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). Please " + "either specify a 'content-type' header (with a type that is not one of " + f"{', '.join(_NOT_PREFLIGHTED)}) or provide a non-empty value for one of the following " + f"headers: {', '.join(_PREFLIGHT_HEADERS)}\n" +) + + +def headers_seen(raw: list[tuple[bytes, bytes]]) -> dict[str, str]: + """The request headers as Apollo Server reads them behind Node, from the ASGI ``raw`` list: + each name in lower case and each value without the spaces and tabs at its ends, the values of + a name sent more than once joined with ``", "`` but ``content-type``'s, of which Node keeps the + first, and a header with no value left out, as each vendor answered on 2026-10-25 (UTC). Linear + reads `Content-Type` on its own (``backlot.routers.linear``).""" + seen: dict[str, str] = {} + for name, value in raw: + key, text = name.decode("latin-1").lower(), value.decode("latin-1").strip(" \t") + if key in seen: + if key != "content-type": + seen[key] = f"{seen[key]}, {text}" + else: + seen[key] = text + return {key: value for key, value in seen.items() if value} + + +def csrf_refusal(headers: Mapping[str, str]) -> Refusal | None: + """``preventCsrf``: the 400 for a request that may not have been preflighted, one with no + content type or one ``whatwg-mimetype`` reads as a type a browser sends without a preflight, + and none of :data:`_PREFLIGHT_HEADERS` with a value; ``None`` for a request it lets through, + a content type ``whatwg-mimetype`` cannot parse among them.""" + content_type = headers.get("content-type") + if content_type is not None and mime_essence(content_type) not in _NOT_PREFLIGHTED: + return None + if any(headers.get(name) for name in _PREFLIGHT_HEADERS): + return None + return Refusal(CSRF, BAD_REQUEST, thrown=True) + + +def method_refusal() -> Refusal: + """The 405 Apollo Server answers a method other than GET and POST with.""" + return Refusal( + "Apollo Server supports only GET/POST requests.", + BAD_REQUEST, + status=405, + headers=(("allow", "GET, POST"),), + thrown=True, + ) + + +def get_content_type_refusal(headers: Mapping[str, str]) -> Refusal | None: + """Apollo Server 5.5's 415 for a GET whose content type ``whatwg-mimetype`` does not read as + ``application/json``, or ``None``. Apollo Server 5.4 and before make no such check.""" + content_type = headers.get("content-type") + if content_type is None or mime_essence(content_type) == "application/json": + return None + return Refusal( + "GET requests may not have a content-type header other than application/json.", + BAD_REQUEST, + status=415, + thrown=True, + ) + + +def search_payload(query_string: bytes) -> dict | Refusal: + """A GET's request as Apollo Server reads it off the query string, with ``URLSearchParams``: + ``query``, ``operationName``, ``variables`` and ``extensions``, each at most once, the last two + read with ``JSON.parse`` as objects; or the refusal of the first that is not, in that order.""" + params: dict[str, list[str]] = {} + for name, value in parse_qsl( + query_string.decode("latin-1"), keep_blank_values=True, errors="replace" + ): + params.setdefault(name, []).append(value) + payload: dict[str, Any] = {} + for name in ("query", "operationName", "variables", "extensions"): + values = params.get(name, []) + if len(values) > 1: + return Refusal( + f"The '{name}' search parameter may only be specified once.", + BAD_REQUEST, + thrown=True, + ) + if not values: + continue + value: Any = values[0] + if name in ("variables", "extensions"): + try: + value = request_body.parse(value) + except request_body.NotJson: + return Refusal( + f"The {name} search parameter contains invalid JSON.", BAD_REQUEST, thrown=True + ) + if not isinstance(value, dict): + return Refusal( + f"The {name} search parameter should contain a JSON-encoded object.", + BAD_REQUEST, + thrown=True, + ) + payload[name] = value + return payload + + +_LANDING_PAGE = (Path(__file__).parent / "apollo_landing_page.html").read_text(encoding="utf-8") + + +def landing_page() -> str: + """Apollo Server 5.5.1's page for a browser, the embedded Sandbox: the page api.fireflies.ai + answered each of twelve GETs preferring ``text/html`` with on 2026-10-25 (UTC), the same 3522 + bytes each time but the nonce, 64 hexadecimal digits of its own each time.""" + return _LANDING_PAGE.replace("NONCE", secrets.token_hex(32)) diff --git a/backlot/graphql/apollo_landing_page.html b/backlot/graphql/apollo_landing_page.html new file mode 100644 index 000000000..f7193cb98 --- /dev/null +++ b/backlot/graphql/apollo_landing_page.html @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + Apollo Server + + + +
+ + +
+

Welcome to Apollo Server

+

Apollo Sandbox cannot be loaded; it appears that you might be offline.

+
+ +
+ + + +
+ + + \ No newline at end of file diff --git a/backlot/graphql/body.py b/backlot/graphql/body.py index 44188f9ac..e7cf4f021 100644 --- a/backlot/graphql/body.py +++ b/backlot/graphql/body.py @@ -64,6 +64,12 @@ def decode(raw: bytes) -> str: _QUOTED_PAIR = re.compile(r"\\([\x0b\x20-\xff])") +#: What JavaScript's ``String.prototype.trim`` removes and its regular expressions' ``\s`` matches: +#: WhiteSpace and LineTerminator. Python's ``str.strip`` also removes ``\x1c`` to ``\x1f`` and +#: ``\x85``, which these do not, and keeps ``\ufeff``, which these remove. +JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff" + + class Unread(Exception): """A body Linear's ``body-parser`` refuses before it has the text: the status and the message Linear's error handler answers with, and for the 413 an uncompressed body draws, its length. @@ -161,6 +167,93 @@ def _charset(content_type: str | None) -> str: return charset.lower() or "utf-8" +def express_reads_json(content_type: str) -> bool: + """Whether Linear's ``body-parser`` reads a body under ``content_type`` as JSON, which + ``type-is`` 2.0.1 decides as ``content-type`` 1.0.5 parses the header: the type before the + first ``;``, trimmed, is ``application/json`` in any case, and each parameter after it is a + token, ``=`` and a token or a quoted string, to the end. A body under any other is not read, + and reaches Apollo Server as none. api.linear.app answered as these two packages do 66 content + types on 2026-10-25 (UTC), where ``content-type`` 2.1.0, which ``type-is`` 2.1.0 asks, reads + ``application/json;``, ``application/json; foo`` and six more as JSON that Linear did not.""" + type_ = content_type.partition(";")[0] + if type_.strip(JS_SPACE).lower() != "application/json": + return False + index = len(type_) + while (parameter := PARAMETER.match(content_type, index)) is not None: + index = parameter.end() + return index == len(content_type) + + +# Fastify's `typeNameReg` and `subtypeNameReg`, the latter with no white space to end it once +# `trimEnd` has removed it. +_FASTIFY_TOKEN = re.compile(r"[\w!#$%&'*+.^`|~-]+", re.ASCII) + + +def fastify_media_type(content_type: str) -> str | None: + """The type and subtype in lower case Fastify's ``ContentType`` reads off a ``Content-Type`` + header, or ``None`` for one it calls invalid: read off ``lib/content-type.js`` in Fastify + 5.10.0. The parameters are not read for it: a type is before the first ``;`` or, with none, + is the whole value, and splits at its first ``/`` into two tokens, white space allowed before + the first and after the second. + + Fastify 5.7.4 to 5.10.0 answered alike each of 661 content types sent to them on 2026-10-25 + (UTC), and as api.fireflies.ai did each of its requests replayed to them but Cloudflare's four + and a persisted query's, where 5.5.0 refuses ``application/json\\t;``, 5.7.0 serves + ``application/json ,`` and 5.11.3 reads the body of a ``QUERY``, which Fireflies answered with + its 404 without reading ``{``.""" + type_, _, subtype = content_type.partition(";")[0].lower().partition("/") + type_, subtype = type_.lstrip(JS_SPACE), subtype.rstrip(JS_SPACE) + if not (_FASTIFY_TOKEN.fullmatch(type_) and _FASTIFY_TOKEN.fullmatch(subtype)): + return None + return f"{type_}/{subtype}" + + +_PERCENT_RUN = re.compile(r"(?:%[0-9A-Fa-f]{2})+") + + +def _fast_decode(text: str) -> str | None: + """``fast-decode-uri-component``: ``text`` with each run of ``%XX`` read as UTF-8, or ``None`` + where a ``%`` is not followed by two hexadecimal digits or a run is not UTF-8.""" + out, last = [], 0 + for run in _PERCENT_RUN.finditer(text): + if "%" in text[last : run.start()]: + return None + try: + decoded = bytes.fromhex(run.group().replace("%", "")).decode("utf-8") + except UnicodeDecodeError: + return None + out.extend((text[last : run.start()], decoded)) + last = run.end() + if "%" in text[last:]: + return None + return "".join(out) + text[last:] + + +def form_fields(text: str) -> dict[str, str | list[str]]: + """A form body as ``@fastify/formbody`` 8 reads it with ``fast-querystring`` 1.1.2: the pairs + between each ``&``, a name alone given ``""``, each ``+`` a space and each name and value + percent-decoded, kept as sent where it cannot be (:func:`_fast_decode`), and a name sent more + than once given the list of its values. api.fireflies.ai served ``query=%7B%20__typename%20%7D`` + with the preflight header on 2026-10-25 (UTC).""" + fields: dict[str, str | list[str]] = {} + for pair in text.split("&"): + name, equals, value = pair.partition("=") + if not equals and not name: + continue + name = name.replace("+", " ") + name = _fast_decode(name) or name + value = value.replace("+", " ") + value = _fast_decode(value) or value + held = fields.get(name) + if held is None: + fields[name] = value + elif isinstance(held, list): + held.append(value) + else: + fields[name] = [held, value] + return fields + + def _byte_order(raw: bytes, width: int) -> str: """``le`` or ``be`` for a body whose charset names no byte order, as iconv-lite's ``detectEncoding`` picks: a byte-order mark first, else which order reads more of the first 100 diff --git a/backlot/graphql/engine.py b/backlot/graphql/engine.py index 359d0a7d0..0bcceb26b 100644 --- a/backlot/graphql/engine.py +++ b/backlot/graphql/engine.py @@ -109,12 +109,18 @@ def _ids_as_graphql_js_reads_them(value: Any, type_: Any) -> Any: class Refusal: """One request error as Apollo Server answers it: ``message`` as Apollo Server writes it, in graphql-js's words where graphql-js made the refusal, the ``locations`` graphql-js gives it, - Apollo Server's ``code``, and the status of the answer.""" + Apollo Server's ``code``, the status of the answer and the ``headers`` it sets on it. + ``thrown`` marks a refusal Apollo Server throws, which ``Accept`` cannot turn into its 406, and + ``http_headers`` whether the ``http`` extension it carries names its ``headers``, as all but + the 406's do.""" message: str code: str locations: tuple = () status: int = 400 + headers: tuple[tuple[str, str], ...] = () + thrown: bool = False + http_headers: bool = True @dataclass(frozen=True) @@ -142,6 +148,11 @@ def status(self) -> int: """The status of the answer: a refusal's, or 200 for an operation that ran.""" return self.refusals[0].status if self.refusals else 200 + @property + def headers(self) -> tuple[tuple[str, str], ...]: + """The headers Apollo Server sets on the answer: a refusal's, or none.""" + return self.refusals[0].headers if self.refusals else () + @dataclass(frozen=True) class Operation: @@ -200,8 +211,10 @@ def refused(*refusals: Refusal) -> Result: return Result(payload={"errors": errors}, refusals=refusals) -def _refusal(message: str, code: str = BAD_REQUEST, status: int = 400) -> Result: - return refused(Refusal(message, code, status=status)) +def _refusal( + message: str, code: str = BAD_REQUEST, status: int = 400, thrown: bool = False +) -> Result: + return refused(Refusal(message, code, status=status, thrown=thrown)) def _js_truthy(value: Any) -> bool: @@ -234,22 +247,22 @@ def body_refusal(payload: Any) -> Result | None: type. Both vendors kept that order with two wrong at once on 2026-10-25 (UTC). A ``null`` member is one not sent.""" if isinstance(payload, list): - return _refusal("Operation batching disabled.") + return _refusal("Operation batching disabled.", thrown=True) if not isinstance(payload, dict) or not payload: - return _refusal(NO_KEYS) + return _refusal(NO_KEYS, thrown=True) query = payload.get("query") if _js_truthy(query) and not isinstance(query, str): document = isinstance(query, dict) and query.get("kind") == "Document" - return _refusal(_A_DOCUMENT if document else _NOT_A_STRING) + return _refusal(_A_DOCUMENT if document else _NOT_A_STRING, thrown=True) for name in ("variables", "extensions"): if isinstance(payload.get(name), str): - return _refusal(RECURSIVELY_ENCODED.format(name)) + return _refusal(RECURSIVELY_ENCODED.format(name), thrown=True) for name in ("extensions", "variables"): if payload.get(name) is not None and not isinstance(payload[name], dict): - return _refusal(f"`{name}` in a POST body must be an object if provided.") + return _refusal(f"`{name}` in a POST body must be an object if provided.", thrown=True) name = payload.get("operationName") if name is not None and not isinstance(name, str): - return _refusal("`operationName` in a POST body must be a string if provided.") + return _refusal("`operationName` in a POST body must be a string if provided.", thrown=True) return None @@ -378,10 +391,12 @@ def resolve( variables: Any = None, operation_name: str | None = None, on_document: Callable[[DocumentNode], Result | list[GraphQLError]] | None = None, + get: bool = False, ) -> Result | Operation: """``document`` read as graphql-js reads it under Apollo Server, up to the choice of the operation to run: parse, then validation, then the choice of operation, each refused with - Apollo Server's code; :meth:`run` takes the operation it chose. + Apollo Server's code; :meth:`run` takes the operation it chose. Sent by ``get``, an + operation that is not a query is Apollo Server's 405. ``on_document`` is a validation rule of the vendor's that reports as graphql-js creates it: the :class:`Result` to answer with where it throws, or the errors it reports, which @@ -413,6 +428,16 @@ def resolve( else f'Unknown operation named "{operation_name}".' ) return _refusal(message, OPERATION_RESOLUTION_FAILURE) + kind = operation.operation.value + if get and kind != "query": + return refused( + Refusal( + f"GET requests only support query operations, not {kind} operations", + BAD_REQUEST, + status=405, + headers=(("allow", "POST"),), + ) + ) return Operation(doc, operation, variables, operation_name) def resolve_payload( @@ -420,14 +445,16 @@ def resolve_payload( payload: Any, *, on_document: Callable[[DocumentNode], Result | list[GraphQLError]] | None = None, + get: bool = False, ) -> Result | Operation: """A GraphQL-over-HTTP POST body (``{query, variables, operationName}``) its router has read with :mod:`backlot.graphql.body`, up to the choice of operation: :func:`body_refusal`, then :func:`persisted_query_refusal`, then Apollo Server's refusal of a body with no query and no ``persistedQuery`` extension, then :meth:`resolve`. A query whose hash a ``persistedQuery`` extension matches is parsed however empty it is, as both vendors parsed - ``""`` on 2026-10-28 (UTC).""" - answer = body_refusal(payload) + ``""`` on 2026-10-28 (UTC). With ``get``, ``payload`` is a GET's query string as + :func:`backlot.graphql.apollo_http.search_payload` reads it, which no body check runs on.""" + answer = None if get else body_refusal(payload) if answer is None: answer = persisted_query_refusal(payload) if answer is not None: @@ -440,6 +467,7 @@ def resolve_payload( variables=payload.get("variables"), operation_name=payload.get("operationName"), on_document=on_document, + get=get, ) def run(self, operation: Operation, *, context: Any = None) -> Result: @@ -524,9 +552,10 @@ def execute_payload( *, context: Any = None, on_document: Callable[[DocumentNode], Result | list[GraphQLError]] | None = None, + get: bool = False, ) -> Result: """:meth:`resolve_payload`, then :meth:`run` the operation it chose.""" - resolved = self.resolve_payload(payload, on_document=on_document) + resolved = self.resolve_payload(payload, on_document=on_document, get=get) return resolved if isinstance(resolved, Result) else self.run(resolved, context=context) diff --git a/backlot/main.py b/backlot/main.py index eaa4534a7..6234e51d5 100644 --- a/backlot/main.py +++ b/backlot/main.py @@ -1272,6 +1272,63 @@ async def warn_slack_post_charset(request: Request, call_next): ) +#: Cloudflare's answer to each method it refuses in front of Linear and Fireflies, as +#: api.linear.app and api.fireflies.ai answered each on `/graphql`, `/graphql/`, `/nosuch` and `/` +#: on 2026-10-25 (UTC): a page with CRLF line ends, or a line of text, and the headers it puts on +#: it. Both closed the connection after the `TRACE` answer and answered a second request on it after +#: the other two (`/graphql`, 2026-10-26). +_CLOUDFLARE_METHOD = { + "TRACE": ( + 405, + b"\r\n405 Not Allowed\r\n\r\n" + b"

405 Not Allowed

\r\n
cloudflare
\r\n" + b"\r\n\r\n", + {"content-type": "text/html", "connection": "close"}, + ), + "CONNECT": ( + 400, + b"error code: 1045\n", + { + "content-type": "text/plain; charset=UTF-8", + "cache-control": "private, max-age=0, no-store, no-cache, must-revalidate, " + "post-check=0, pre-check=0", + "expires": "Thu, 01 Jan 1970 00:00:01 GMT", + "referrer-policy": "same-origin", + "x-frame-options": "SAMEORIGIN", + }, + ), + "PURGE": ( + 400, + request_target.CLOUDFLARE_PAGE, + { + "content-type": "text/html; charset=UTF-8", + "x-frame-options": "SAMEORIGIN", + "cache-control": "private, max-age=0, no-store, no-cache, must-revalidate, " + "post-check=0, pre-check=0", + "expires": "Thu, 01 Jan 1970 00:00:01 GMT", + "referrer-policy": "same-origin", + }, + ), +} + + +@_middleware( + lambda scope: ( + scope["method"] in _CLOUDFLARE_METHOD + and _at_or_under(_url_path(scope), ("/linear", "/fireflies")) + ) +) +async def refuse_cloudflares_methods_on_linear_and_fireflies(request: Request, call_next): + """Answer a `/linear` or `/fireflies` request by one of :data:`_CLOUDFLARE_METHOD`'s methods + with Cloudflare's answer, ahead of the route and the credential. Registered before + :func:`refuse_a_repeated_header_on_notion_linear_fireflies_and_hubspot`, which answers first: a + `TRACE`, `CONNECT` or `PURGE` carrying `Authorization` twice drew its 400 from both vendors on + 2026-10-25 (UTC), and one carrying `If-None-Match` or `If-Modified-Since` twice on + 2026-10-28.""" + status, body, headers = _CLOUDFLARE_METHOD[request.method] + return Response(body, status_code=status, headers=headers) + + @_middleware( lambda scope: (scope.get("raw_path") or b"").startswith((b"/slack/api//", b"/slack/api/\\")) ) diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index dd4ea8eaa..57922f273 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -1,9 +1,10 @@ -"""Fireflies.ai's GraphQL API, served at ``POST /fireflies/graphql``. +"""Fireflies.ai's GraphQL API, served at ``/fireflies/graphql``. Fireflies is **GraphQL only** — there is no REST surface to emulate — so this router is one endpoint. The schema and the resolvers live in ``backlot/graphql/`` (``fireflies.graphql`` + -``fireflies_resolvers.py``); everything here is HTTP: a body and credentials in, a GraphQL -envelope out, or its HTTP layer's for a body it cannot read (:func:`_body_refusal`). +``fireflies_resolvers.py``); everything here is HTTP: a request and credentials in, a GraphQL +envelope out, or the answer of a layer in front of Apollo Server's run, in the order +:func:`graphql` lists them. Auth is the bearer path (``Authorization: Bearer ``), which is what the vendor's own quickstart shows for all four of its raw-HTTP examples, read by @@ -23,7 +24,7 @@ from fastapi import APIRouter, Request from fastapi.concurrency import run_in_threadpool -from fastapi.responses import JSONResponse, Response +from fastapi.responses import Response from graphql import ( DocumentNode, FieldNode, @@ -35,8 +36,8 @@ from backlot import auth from backlot.config import get_settings +from backlot.graphql import apollo_http, engine from backlot.graphql import body as request_body -from backlot.graphql import engine from backlot.graphql.fireflies_resolvers import build_engine router = APIRouter(prefix="/fireflies", tags=["fireflies"]) @@ -45,31 +46,34 @@ # schema fails at startup instead of on the first request. ENGINE = build_engine() +# The media type api.fireflies.ai answered each envelope of `_body_refusal` and `_not_found` in. +_JSON = "application/json; charset=utf-8" _EMPTY = "Body cannot be empty when content-type is set to 'application/json'" +_UNSUPPORTED = "Unsupported Media Type" _NOT_JSON = "Body is not valid JSON but content-type is set to 'application/json'" _TOO_LARGE = "Request body is too large" +_SIZE_MISMATCH = "Request body size did not match Content-Length" #: The most bytes Fireflies reads: api.fireflies.ai served a body of 1048576 bytes and answered one #: of 1048577 with :data:`_TOO_LARGE`'s 413 on 2026-10-19, with a credential, with none and with #: ``Authorization: nope``. _LIMIT = 1024 * 1024 -def _body_refusal(message: str, status: int = 400) -> JSONResponse: +def _body_refusal(message: str, status: int = 400, code: str = "http") -> Response: """Fireflies' answer to a body it cannot read: an envelope of its HTTP layer, not a GraphQL one, with the message twice. :data:`_EMPTY` is the 400 for a body of no bytes and :data:`_NOT_JSON` the 400 for every other one :mod:`backlot.graphql.body` cannot read or finds :func:`~backlot.graphql.body.prototype_poisoned`, as api.fireflies.ai answered on 2026-10-18; - :data:`_TOO_LARGE` is the 413 for one over :data:`_LIMIT`.""" - return JSONResponse( - { - "statusCode": status, - "status": False, - "code": "http", - "message": message, - "errorMessage": message, - }, - status_code=status, - ) + :data:`_TOO_LARGE` is the 413 for one over :data:`_LIMIT` and :data:`_UNSUPPORTED` the 415 + for one of a type it does not read. :func:`_not_found` is the same envelope.""" + envelope = { + "statusCode": status, + "status": False, + "code": code, + "message": message, + "errorMessage": message, + } + return Response(request_body.node_json(envelope), status_code=status, media_type=_JSON) # The two messages Fireflies refuses a credential with: a value with no `Bearer` prefix, and the @@ -85,23 +89,28 @@ def _body_refusal(message: str, status: int = 400) -> JSONResponse: ) -def _auth_failed(message: str) -> JSONResponse: +def _auth_failed(seen: dict[str, str], message: str) -> Response: """Fireflies' answer to a credential it does not take: a 500 carrying an ``auth_failed`` entry, as api.fireflies.ai answered on 2026-10-18 with no ``Authorization``, with ``nope``, ``Basic …``, ``token ``, the bare key, ``Bearer nope`` and ``Bearer extra``. The - document is not read first: ``{"query":"{"}``, ``[]`` and ``5`` drew the same 500.""" - return JSONResponse( - { - "errors": [ - { - "friendly": True, - "code": "auth_failed", - "message": message, - "extensions": {"code": "auth_failed"}, - } - ] - }, + document is not read first: ``{"query":"{"}``, ``[]`` and ``5`` drew the same 500. Apollo + Server makes it from the error its context function throws, so it takes a thrown refusal's + media type (:mod:`backlot.graphql.apollo_http`).""" + return Response( + request_body.node_json( + { + "errors": [ + { + "friendly": True, + "code": "auth_failed", + "message": message, + "extensions": {"code": "auth_failed"}, + } + ] + } + ), status_code=500, + media_type=apollo_http.response_type(seen.get("accept")) or apollo_http.JSON, ) @@ -112,25 +121,58 @@ def _auth_failed(message: str) -> JSONResponse: "object_not_found": True, } +# The methods Fastify reads a body for and has no route for at this path: it reads the body, then +# answers its not-found handler's 404 (:func:`_not_found`). +_READ_THEN_NOT_FOUND = ("DELETE", "PATCH", "PUT") -@router.post("/graphql", include_in_schema=False) + +@router.api_route("/graphql", methods=list(apollo_http.METHODS), include_in_schema=False) async def graphql(request: Request): - """The single Fireflies endpoint. Not in the OpenAPI document on purpose: this is a GraphQL - service, and describing one POST route that accepts an arbitrary query would tell an - OpenAPI→MCP bridge nothing useful (hence no ``SOURCE_PREFIXES`` entry either — see - ``backlot/openapi.py``). Introspection is the schema description for this endpoint.""" - # Off the event loop, as a body of `_LIMIT` bytes can keep `_read` busy a third of a second. - payload = await run_in_threadpool(_read, await request.body()) - if isinstance(payload, JSONResponse): - return payload - # The telemetry middleware names the call by this document (backlot.telemetry.naming). - request.state.graphql_payload = payload + """The single Fireflies endpoint, in front of which Fireflies runs Fastify and + ``@fastify/cors``. Not in the OpenAPI document on purpose: this is a GraphQL service, and + describing one route that accepts an arbitrary query would tell an OpenAPI→MCP bridge nothing + useful (hence no ``SOURCE_PREFIXES`` entry either — see ``backlot/openapi.py``). Introspection + is the schema description for this endpoint. + + The layers answer in this order, each measured against api.fireflies.ai on 2026-10-25 (UTC) + with the one after it wrong too: ``@fastify/cors`` (:func:`_preflight`), Fastify + (:func:`_fastify_read`, :func:`_not_found`), then Apollo Server + (:mod:`backlot.graphql.apollo_http`): its page for a browser and its CSRF check ahead of the + credential (:func:`_auth_failed`), and the rest after it (:func:`_answer`).""" + method = request.method + if method == "OPTIONS": + return _preflight(request) + if method not in ("GET", "HEAD", "POST"): + if method in _READ_THEN_NOT_FOUND: + read = await _fastify_read(request, routed=False) + if isinstance(read, Response): + return read + return _not_found(request) + payload: Any = None + if method == "POST": + payload = await _fastify_read(request, routed=True) + if isinstance(payload, Response): + return payload + # The telemetry middleware names the call by this document (backlot.telemetry.naming). + request.state.graphql_payload = payload + seen = apollo_http.headers_seen(request.headers.raw) + if apollo_http.prefers_html(method, seen.get("accept")): + return Response(apollo_http.landing_page(), headers={"content-type": "text/html"}) + refusal = apollo_http.csrf_refusal(seen) + if refusal is not None: + return _answer(seen, engine.refused(refusal)) token = auth.fireflies_bearer_token(request) - if token is None: - return _auth_failed(_NO_PREFIX) caller = auth.acl(request).resolve(token) if caller is None: - return _auth_failed(_BAD_KEY) + return _auth_failed(seen, _NO_PREFIX if token is None else _BAD_KEY) + if method == "HEAD": + return _answer(seen, engine.refused(apollo_http.method_refusal())) + if method == "GET": + refusal = apollo_http.get_content_type_refusal(seen) + payload = refusal or apollo_http.search_payload(request.scope["query_string"]) + if isinstance(payload, engine.Refusal): + return _answer(seen, engine.refused(payload)) + request.state.graphql_payload = payload state = request.app.state context = { "conn": auth.conn(request), @@ -143,15 +185,115 @@ async def graphql(request: Request): # scale measured) and almost none of them have a Fireflies account. See resolve_users. "roster": frozenset(state.acl.email_to_token()) if getattr(state, "acl", None) else None, } - result = ENGINE.execute_payload(payload, context=context, on_document=_depth_limit) + result = ENGINE.execute_payload( + payload, context=context, on_document=_depth_limit, get=method == "GET" + ) request.state.graphql_raised = result.raised + return _answer(seen, result) + + +def _answer(seen: dict[str, str], result: engine.Result) -> Response: + """Apollo Server's answer to ``result`` in Fireflies' envelope (:func:`_envelope`), in the + media type :func:`~backlot.graphql.apollo_http.response_type` picks, or Apollo Server's 406.""" + media_type = apollo_http.response_type(seen.get("accept")) + if media_type is None and not (result.refusals and result.refusals[0].thrown): + result = engine.refused(apollo_http.NOT_ACCEPTABLE) return Response( request_body.node_json(_envelope(result)), status_code=result.status, - media_type="application/json", + headers=dict(result.headers), + media_type=media_type or apollo_http.JSON, ) +#: The headers ``@fastify/cors`` writes on each preflight, as api.fireflies.ai answered every one +#: sent with `Origin` and `Access-Control-Request-Method` on 2026-10-25 (UTC). +_PREFLIGHT = ( + ("access-control-allow-origin", "*"), + ("access-control-allow-methods", "GET,HEAD,POST"), + ("vary", "Access-Control-Request-Headers"), +) + + +def _preflight(request: Request) -> Response: + """``@fastify/cors``'s answer to an `OPTIONS`, ahead of the body and the credential: its 204, + with :data:`_PREFLIGHT` and `Access-Control-Request-Headers` echoed back as Node reads it, to + one carrying both `Origin` and `Access-Control-Request-Method` with more than spaces and tabs in + them, and its 400 to any other. api.fireflies.ai answered so with no credential and + `Bearer nope`, with `Access-Control-Request-Method` naming `POST` or `PUT`, with `Origin: null`, + and with a JSON body or `{`; it echoed ` a , b ` as `a , b`, `a` and `b` in two copies as + `a, b`, and a value with a space, a tab, U+0085 or U+00A0 inside it, U+00A0 at either end or + U+0085 after it, as sent. It answered an empty or blank `Origin` and an empty + `Access-Control-Request-Method` with the 400 on 2026-10-26 (UTC).""" + seen = apollo_http.headers_seen(request.headers.raw) + if "origin" not in seen or "access-control-request-method" not in seen: + return Response( + b"Invalid Preflight Request", status_code=400, headers={"content-type": "text/plain"} + ) + response = Response(status_code=204) + asked = seen.get("access-control-request-headers") + echoed = () if asked is None else (("access-control-allow-headers", asked),) + response.raw_headers += [(k.encode(), v.encode("latin-1")) for k, v in (*_PREFLIGHT, *echoed)] + return response + + +def _not_found(request: Request) -> Response: + """Fireflies' 404 for a method its route does not take, the method and the path quoted in it as + a client puts them after ``https://api.fireflies.ai``, the query string as sent. + api.fireflies.ai answered so each of the 28 methods of :data:`apollo_http.METHODS` but GET, + HEAD, POST, OPTIONS and Cloudflare's three on 2026-10-25 (UTC), `PUT`, `DELETE` and `PATCH` once + Fastify had read their body (:func:`_fastify_read`), and quoted `/graphql?x=%7B%20y&z&a=b+c` as + sent.""" + # The path as sent, but for the segment that names Fireflies here. + path = "/" + request.scope["raw_path"].decode("latin-1").split("/", 2)[2] + query = request.scope["query_string"].decode("latin-1") + message = f"Cannot {request.method} {path}{'?' if query else ''}{query}" + return _body_refusal(message, 404, "not_found") + + +_FASTIFY_PARSED = ("application/json", "text/plain", "application/x-www-form-urlencoded") + + +async def _fastify_read(request: Request, routed: bool) -> Any: + """The body Fastify 5.10.0 hands a request's handler, or :func:`_body_refusal`'s refusal of it, + read off ``lib/handle-request.js`` and ``lib/content-type-parser.js``. With no + ``Content-Type``, a request with no ``Content-Length`` or one of 0, and no + ``Transfer-Encoding``, has none, and any other is the 415; a type it cannot parse + (:func:`~backlot.graphql.body.fastify_media_type`) is the 415. It reads JSON as :func:`_read` + does, plain text and a form (:func:`~backlot.graphql.body.form_fields`). On a request its route + does not take, ``routed`` false, any other type and no type are left unread, where a routed + request gets the 415. + + api.fireflies.ai answered so each body sent on 2026-10-25 (UTC) to `POST`, `PUT`, `DELETE` + and `PATCH`: no type with a body was the 415 by `POST` and the 404 by `PUT`, `a/b/c` and an + empty type the 415 by both, `application/graphql` the 415 by `POST`, past the limit too, and + the 404 by `PUT`, and a form or plain text past the limit the 413 by both.""" + content_type = request.headers.get("content-type") + if content_type is None: + length = request.headers.get("content-length") + if "transfer-encoding" not in request.headers and length in (None, "0"): + return None + return _body_refusal(_UNSUPPORTED, 415) if routed else None + media_type = request_body.fastify_media_type(content_type) + if media_type is None: + return _body_refusal(_UNSUPPORTED, 415) + if media_type not in _FASTIFY_PARSED: + return _body_refusal(_UNSUPPORTED, 415) if routed else None + raw = await request.body() + if media_type == "application/json": + # Off the event loop, as a body of `_LIMIT` bytes can keep `_read` busy a third of a second. + return await run_in_threadpool(_read, raw) + if len(raw) > _LIMIT: + return _body_refusal(_TOO_LARGE, 413) + text = raw.decode("utf-8", "replace") + if media_type != "text/plain": + return request_body.form_fields(text) + # Fastify reads plain text as UTF-8 and counts the text's own UTF-8 bytes against the body's: + # `text/plain` holding the byte 0xE9, with a charset or none, by POST and by PUT, was the 400 on + # 2026-10-26 (UTC), where a form holding it was served and 0xE9 past the limit was the 413. + return text if len(text.encode("utf-8")) == len(raw) else _body_refusal(_SIZE_MISMATCH) + + def _entry(error: dict, code: str, friendly: bool, extensions: dict) -> dict: """``error`` in Fireflies' members, in this order: ``friendly`` first, its own members, then ``code`` and ``extensions``.""" @@ -275,8 +417,8 @@ def spread(name: str, depth: int) -> list: def _read(raw: bytes) -> Any: - """The payload Fireflies reads from the body ``raw``, or :func:`_body_refusal`'s refusal of - it.""" + """The payload Fireflies reads from the JSON body ``raw``, or :func:`_body_refusal`'s refusal + of it.""" if len(raw) > _LIMIT: return _body_refusal(_TOO_LARGE, 413) if not raw: diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index af9c0743f..415ba76c1 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -1,9 +1,10 @@ -"""Linear's GraphQL API, served at ``POST /linear/graphql``. +"""Linear's GraphQL API, served at ``/linear/graphql``. Linear is **GraphQL only** — there is no REST surface to emulate — so this router is one endpoint. The schema and the resolvers live in ``backlot/graphql/`` (``linear.graphql`` + -``linear_resolvers.py``); everything here is HTTP: a body and credentials in, a GraphQL envelope -out, or Express's for the bodies :func:`_express` answers. +``linear_resolvers.py``); everything here is HTTP: a request and credentials in, a GraphQL +envelope out, or the answer of a layer in front of Apollo Server's run, in the order :func:`graphql` +lists them. Auth is the one place Linear differs from every other source in this repo. A personal API key travels as the **bare** header value (``Authorization: lin_api_…``, no scheme), read by @@ -23,11 +24,11 @@ operations with no ``operationName`` drew what the key drew on 2026-10-25 (UTC), and a document missing a variable the 401, but for one that selects only ``__schema`` and ``__type`` (:func:`_introspection_only`), which drew the variable's refusal as with the key. An API key under -``Bearer`` is refused ahead of the body: with ``Bearer ``, ``{``, an empty body and a body -holding ``NaN`` drew :func:`_api_key_as_bearer`'s 400 on 2026-10-18, and ``charset=latin1``, -``Content-Encoding: bogus`` and a body past the size limit did on 2026-10-19, when a key in an API -key's shape that Linear never issued drew it under ``Bearer`` with ``{``, ``NaN``, ``5``, -``charset=latin1`` and an empty body. +``Bearer`` is refused ahead of the body on any method but ``OPTIONS``: with ``Bearer ``, +``{``, an empty body and a body holding ``NaN`` drew :func:`_api_key_as_bearer`'s 400 on +2026-10-18, and ``charset=latin1``, ``Content-Encoding: bogus`` and a body past the size limit +did on 2026-10-19, when a key in an API key's shape that Linear never issued drew it under +``Bearer`` with ``{``, ``NaN``, ``5``, ``charset=latin1`` and an empty body. """ from __future__ import annotations @@ -36,17 +37,20 @@ from fastapi import APIRouter, Request from fastapi.concurrency import run_in_threadpool -from fastapi.responses import JSONResponse, Response +from fastapi.responses import Response from graphql import FieldNode, FragmentDefinitionNode, FragmentSpreadNode from backlot import auth from backlot.config import get_settings +from backlot.graphql import apollo_http, engine, v8_json from backlot.graphql import body as request_body -from backlot.graphql import engine, v8_json from backlot.graphql.linear_resolvers import build_engine router = APIRouter(prefix="/linear", tags=["linear"]) +# The media type of Express's `res.json`, in which `_api_key_as_bearer` and `_express` answer. +_JSON = "application/json; charset=utf-8" + # Linear's code for a body with no query, where Apollo Server's is `BAD_REQUEST`. _INVALID_INPUT = "INVALID_INPUT" @@ -55,26 +59,29 @@ ENGINE = build_engine() -def _api_key_as_bearer() -> JSONResponse: +def _api_key_as_bearer() -> Response: """Linear's 400 for an API key under ``Bearer``, which :func:`backlot.auth.linear_api_key_under_bearer` finds, as api.linear.app sent it on 2026-10-18.""" - return JSONResponse( - { - "errors": [ - { - "message": "It looks like you're trying to use an API key as a Bearer token. " - "Remove the Bearer prefix from the Authorization header.", - "extensions": { - "type": "invalid input", - "code": "INPUT_ERROR", - "statusCode": 400, - "userError": True, - }, - } - ] - }, + return Response( + request_body.node_json( + { + "errors": [ + { + "message": "It looks like you're trying to use an API key as a Bearer " + "token. Remove the Bearer prefix from the Authorization header.", + "extensions": { + "type": "invalid input", + "code": "INPUT_ERROR", + "statusCode": 400, + "userError": True, + }, + } + ] + } + ), status_code=400, + media_type=_JSON, ) @@ -95,41 +102,81 @@ def _express(message: str, status: int = 400, length: int | None = None) -> Resp "length": length, } envelope["code"] = status - return Response( - request_body.node_json(envelope), status_code=status, media_type="application/json" - ) + return Response(request_body.node_json(envelope), status_code=status, media_type=_JSON) + +#: The methods Linear's load balancer answers with an empty 501 before Linear sees them, as +#: api.linear.app answered each on 2026-10-25 (UTC). Of the rest of :data:`apollo_http.METHODS`, +#: Cloudflare answers those ``backlot.main._CLOUDFLARE_METHOD`` holds and the layers :func:`graphql` +#: lists the others. +_LOAD_BALANCER_501 = frozenset( + {"BIND", "LINK", "M-SEARCH", "MKCALENDAR", "REBIND", "SOURCE", "UNBIND", "UNLINK"} +) -@router.post("/graphql", include_in_schema=False) + +@router.api_route("/graphql", methods=list(apollo_http.METHODS), include_in_schema=False) async def graphql(request: Request): - """The single Linear endpoint. Not in the OpenAPI document on purpose: this is a GraphQL - service, and describing one POST route that accepts an arbitrary query would tell an - OpenAPI→MCP bridge nothing useful (hence no ``SOURCE_PREFIXES`` entry either — see - ``backlot/openapi.py``). Introspection is the schema description for this endpoint.""" - if auth.linear_api_key_under_bearer(request): + """The single Linear endpoint, in front of which Linear runs Express and ``cors``. Not in the + OpenAPI document on purpose: this is a GraphQL service, and describing one route that accepts an + arbitrary query would tell an OpenAPI→MCP bridge nothing useful (hence no ``SOURCE_PREFIXES`` + entry either — see ``backlot/openapi.py``). Introspection is the schema description for this + endpoint. + + The layers answer in this order, each measured against api.linear.app on 2026-10-25 (UTC) with + the one after it wrong too: the load balancer (:data:`_LOAD_BALANCER_501`), ``cors`` + (:func:`_preflight`), :func:`_api_key_as_bearer`, ``body-parser`` (:func:`_has_body`, + :func:`~backlot.graphql.body.express_reads_json`), Apollo Server + (:mod:`backlot.graphql.apollo_http`) up to the choice of operation, then the credential and the + run (:func:`_answer`).""" + method = request.method + if method in _LOAD_BALANCER_501: + return Response(status_code=501) + if method == "OPTIONS" and "origin" in apollo_http.headers_seen(request.headers.raw): + return _preflight(request) + if method != "OPTIONS" and auth.linear_api_key_under_bearer(request): return _api_key_as_bearer() - # In a worker thread: `_read` can take seconds over a body near the 10 MiB limit, which would - # hold up every other request on the event loop. - payload = await run_in_threadpool( - _read, - await request.body(), - request.headers.get("content-type"), - request_body.joined_header(request.headers.getlist("content-encoding")), - ) - if isinstance(payload, Response): - return payload - # The telemetry middleware names the call by this document (backlot.telemetry.naming). - request.state.graphql_payload = payload - resolved = ENGINE.resolve_payload(_variables_read(payload)) + payload: Any = None + content_type = request_body.joined_header(request.headers.getlist("content-type")) + if _has_body(request) and request_body.express_reads_json(content_type): + # In a worker thread: `_read` can take seconds over a body near the 10 MiB limit, which + # would hold up every other request on the event loop. + payload = await run_in_threadpool( + _read, + await request.body(), + content_type, + request_body.joined_header(request.headers.getlist("content-encoding")), + ) + if isinstance(payload, Response): + return payload + if method == "POST": + # The telemetry middleware names the call by this document (backlot.telemetry.naming). + request.state.graphql_payload = payload + seen = apollo_http.headers_seen(request.headers.raw) + seen.pop("content-type", None) + if content_type: + seen["content-type"] = content_type + refusal = apollo_http.csrf_refusal(seen) + if refusal is None and method not in ("GET", "POST"): + refusal = apollo_http.method_refusal() + if refusal is None and method == "GET": + payload = apollo_http.search_payload(request.scope["query_string"]) + if isinstance(payload, engine.Refusal): + refusal = payload + else: + request.state.graphql_payload = payload + if refusal is not None: + return _answer(seen, engine.refused(refusal), None) + if method == "POST": + resolved = ENGINE.resolve_payload(_variables_read(payload)) + else: + resolved = ENGINE.resolve_payload(payload, get=True) if isinstance(resolved, engine.Result): - return _answer(request, resolved, payload) + return _answer(seen, resolved, payload) caller = auth.resolve_api_key(request) if caller is None: if _introspection_only(resolved): - return _answer(request, ENGINE.run(resolved), payload) - return Response( - request_body.node_json(_UNAUTHENTICATED), status_code=401, media_type="application/json" - ) + return _answer(seen, ENGINE.run(resolved), payload, request) + return _answer(seen, None, payload) state = request.app.state context = { "conn": auth.conn(request), @@ -144,7 +191,63 @@ async def graphql(request: Request): # entities with no table of their own through `linear_entities` # (store.linear_entity_by_id, called by the resolvers' own `_by_id`). } - return _answer(request, ENGINE.run(resolved, context=context), payload) + return _answer(seen, ENGINE.run(resolved, context=context), payload, request) + + +def _has_body(request: Request) -> bool: + """Whether ``body-parser`` finds a body to read, as ``type-is``'s ``hasBody`` decides behind + Linear's front: under any method but `GET` and `HEAD`, which that front gives a length of 0 + where it has none. api.linear.app refused a `Content-Type` naming `charset=latin1` with no body + and no length with its 415 under `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` and `PROPFIND` on + 2026-10-25 (UTC), and read no body under `GET` and `HEAD`, a length of 0 among them.""" + return request.method not in ("GET", "HEAD") + + +#: The headers ``cors`` writes on each preflight, as api.linear.app answered every one sent with +#: `Origin` on 2026-10-25 (UTC), and the edge's ``Cache-Control`` and ``Cache-Tag`` beside them. +_PREFLIGHT = ( + ("cache-control", "s-maxage=43200"), + ("vary", "Origin, Access-Control-Request-Headers"), + ("cache-tag", "preflight"), + ("access-control-allow-methods", "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS"), + ( + "access-control-expose-headers", + "Cache-Control,Content-Language,Content-Length,Content-Type,Expires,Last-Modified,Pragma," + "x-request-id,cf-ray,cf-cache-status,Retry-After", + ), + ("access-control-max-age", "86400"), +) + + +def _preflight(request: Request) -> Response: + """Linear's 204 to an `OPTIONS` carrying `Origin`, ahead of the credential and the body: + `Origin` echoed back, :data:`_PREFLIGHT`, and each name `Access-Control-Request-Headers` lists, + in each copy of it, without the spaces and tabs around it. api.linear.app answered so with no + credential, `nope` and an API key under `Bearer`, with `Access-Control-Request-Method` naming + `POST`, `PUT` or none, with `Origin: null`, and with `Content-Type, X-Foo`, ` a , b `, `a,,b`, + `a, ,b`, a tab after `a` and `a` and `b` in two copies asked for, which came back + `Content-Type,X-Foo`, `a,b`, `a,,b`, `a,,b`, `a` and `a,b`. An `OPTIONS` without `Origin` is + Apollo Server's.""" + headers = [("access-control-allow-origin", request.headers["origin"].strip(" \t"))] + asked = apollo_http.headers_seen(request.headers.raw).get("access-control-request-headers") + if asked is not None: + names = (name.strip(" \t") for name in asked.split(",")) + headers.append(("access-control-allow-headers", ",".join(names))) + response = Response(status_code=204) + response.raw_headers += [(k.encode(), v.encode("latin-1")) for k, v in (*headers, *_PREFLIGHT)] + return response + + +def _as_express_sends(media_type: str) -> str: + """``media_type`` as Express's ``res.send`` rewrites it through the ``content-type`` package: + parameter names in lower case, in the order of their names. api.linear.app answered + ``Accept: application/json; callbackSpec=1.0`` in + ``application/json; callbackspec=1.0; charset=utf-8`` on 2026-10-25 (UTC).""" + type_, *parameters = (part.strip() for part in media_type.split(";")) + pairs = sorted( + (name.lower(), value) for name, _, value in (p.partition("=") for p in parameters) + ) + return "; ".join([type_, *(f"{name}={value}" for name, value in pairs)]) # Linear's 401, as api.linear.app answered `{ viewer { id } }` with no `Authorization`, with @@ -195,12 +298,32 @@ def only(selections) -> bool: return only(operation.operation.selection_set.selections) -def _answer(request: Request, result: engine.Result, payload: Any) -> Response: - request.state.graphql_raised = result.raised +def _answer( + seen: dict[str, str], + result: engine.Result | None, + payload: Any, + request: Request | None = None, +) -> Response: + """Apollo Server's answer to ``result`` in Linear's envelope (:func:`_envelope`), or Linear's + 401 where ``result`` is ``None``, in the media type + :func:`~backlot.graphql.apollo_http.response_type` picks as Express writes it + (:func:`_as_express_sends`), or Apollo Server's 406. ``request``, passed where ``result`` is a + run, is given ``result.raised`` as ``graphql_raised``.""" + if request is not None: + request.state.graphql_raised = result.raised + media_type = apollo_http.response_type(seen.get("accept")) + thrown = result is not None and bool(result.refusals) and result.refusals[0].thrown + if media_type is None and not thrown: + result = engine.refused(apollo_http.NOT_ACCEPTABLE) + if result is None: + body, status, headers = _UNAUTHENTICATED, 401, () + else: + body, status, headers = _envelope(result, payload), result.status, result.headers return Response( - request_body.node_json(_envelope(result, payload)), - status_code=result.status, - media_type="application/json", + request_body.node_json(body), + status_code=status, + headers=dict(headers), + media_type=_as_express_sends(media_type or apollo_http.JSON), ) @@ -223,13 +346,14 @@ def _variables_read(payload: Any) -> Any: _TYPE = {engine.INTERNAL_SERVER_ERROR: "internal error", _INVALID_INPUT: "invalid input"} -def _apollo(message: str, code: str, status: int, locations: Any = ()) -> dict: +def _apollo( + message: str, code: str, status: int, locations: Any = (), http_headers: bool = True +) -> dict: entry: dict = {"message": message} if locations: entry["locations"] = list(locations) - extensions: dict = ( - {} if code == engine.BAD_USER_INPUT else {"http": {"status": status, "headers": {}}} - ) + http: dict = {"status": status, "headers": {}} if http_headers else {"status": status} + extensions: dict = {} if code == engine.BAD_USER_INPUT else {"http": http} extensions["code"] = code extensions["type"] = _TYPE.get(code, "graphql error") extensions["userError"] = code != engine.INTERNAL_SERVER_ERROR @@ -301,7 +425,13 @@ def _envelope(result: engine.Result, payload: Any) -> dict: errors.append(_internal({})) else: errors.append( - _apollo(refusal.message, refusal.code, refusal.status, refusal.locations) + _apollo( + refusal.message, + refusal.code, + refusal.status, + refusal.locations, + refusal.http_headers, + ) ) return {"errors": errors} if "errors" not in result.payload: diff --git a/backlot/telemetry/errors.py b/backlot/telemetry/errors.py index 1ce60d4f8..5727049ac 100644 --- a/backlot/telemetry/errors.py +++ b/backlot/telemetry/errors.py @@ -21,7 +21,7 @@ | ``unsupported`` | Backlot does not do this yet: the baseline records the operation as ``missing`` or ``refused``, or the GraphQL engine refused a filter | | ``internal`` | of a Backlot bug: its own failure, or a 5xx other than a vendor's answer (below) | | ``rate_limit`` | the vendor's rate limit was reached: a 429, or a 403 with ``x-ratelimit-remaining: 0`` as GitHub sends | -| ``auth`` | the credential was missing, wrong, or not allowed: a 401 or 403, or Slack's auth errors, which it sends with a 200 | +| ``auth`` | the credential was missing, wrong, or not allowed: a 401 or 403, or one in ``_AUTH`` | | ``client`` | the request was wrong: any other failure | Backlot's own failure is an exception raised instead of an answer, or a field error the GraphQL @@ -182,8 +182,12 @@ def error_code(source: str, status: int, body: bytes) -> str: return "" -# Slack sends its auth errors with a 200. -_SLACK_AUTH = frozenset({"account_inactive", "invalid_auth", "not_authed"}) +# The auth errors a vendor answers at a status other than 401 and 403: Slack's at 200, and +# Fireflies' at 500 (`backlot.routers.fireflies._auth_failed`). +_AUTH = frozenset( + {("slack", "account_inactive"), ("slack", "invalid_auth"), ("slack", "not_authed")} + | {("fireflies", "auth_failed")} +) # Each 5xx a vendor answers a wrong request with, by source and error code, and Backlot answers too: # Fireflies' 500 for a document graphql-depth-limit throws on # (`backlot.routers.fireflies._depth_limit`). @@ -201,7 +205,7 @@ def error_class( return "unsupported" if status == 429 or (status == 403 and spent): return "rate_limit" - if status in (401, 403) or (source == "slack" and code in _SLACK_AUTH): + if status in (401, 403) or (source, code) in _AUTH: return "auth" if status >= 500 and status not in (501, 505) and (source, code) not in _ANSWERED_5XX: return "internal" diff --git a/docs/fidelity.md b/docs/fidelity.md index 5d5334e38..a737bc8a9 100644 --- a/docs/fidelity.md +++ b/docs/fidelity.md @@ -493,7 +493,7 @@ suite. Declared, not served: six live routes are `include_in_schema=False` and so invisible to that check. Four are FastAPI's own (`/docs`, `/docs/oauth2-redirect`, `/openapi.json`, `/redoc`). The other two -are the GraphQL POSTs at `/fireflies/graphql` and `/linear/graphql`, which their own comparison +are the GraphQL endpoints at `/fireflies/graphql` and `/linear/graphql`, which their own comparison covers — introspection, not a path map, so there is no mount to say so. Walking `app.routes` instead was ruled out for `gen_docs.py`, and the same reasoning holds here. diff --git a/docs/supported-sources.md b/docs/supported-sources.md index da23f8790..0167f3103 100644 --- a/docs/supported-sources.md +++ b/docs/supported-sources.md @@ -16,13 +16,13 @@ Generated from `backlot/schemas/*.schema.json` and the app's own `/openapi.json` | `source_type` | Service | URL prefix | Endpoints | Record schema | What one record is | |---|---|---|---|---|---| | `confluence` | Confluence | `/atlassian/wiki/rest/api` | 10 | [`confluence.schema.json`](../backlot/schemas/confluence.schema.json) | A Confluence page or blogpost. | -| `fireflies` | Fireflies | `/fireflies/graphql` | GraphQL (one `POST`) | [`fireflies.schema.json`](../backlot/schemas/fireflies.schema.json) | A Fireflies.ai meeting transcript. | +| `fireflies` | Fireflies | `/fireflies/graphql` | GraphQL (one endpoint, `POST` or `GET`) | [`fireflies.schema.json`](../backlot/schemas/fireflies.schema.json) | A Fireflies.ai meeting transcript. | | `github` | GitHub | `/github` | 34 | [`github.schema.json`](../backlot/schemas/github.schema.json) | A GitHub issue, pull request, file, or the repository itself. | | `gmail` | Gmail | `/gmail/v1` | 8 | [`gmail.schema.json`](../backlot/schemas/gmail.schema.json) | A Gmail message. | | `google_drive` | Google Drive, Docs, Sheets, Slides | `/drive/v3` `/docs/v1` `/sheets/v4` `/slides/v1` | 21 | [`google_drive.schema.json`](../backlot/schemas/google_drive.schema.json) | A Google Drive file. | | `hubspot` | HubSpot | `/hubspot` | 5 | [`hubspot.schema.json`](../backlot/schemas/hubspot.schema.json) | A HubSpot CRM record (contact, company, deal, ticket, note, …). | | `jira` | Jira | `/atlassian/rest/api` | 14 | [`jira.schema.json`](../backlot/schemas/jira.schema.json) | A Jira issue. | -| `linear` | Linear | `/linear/graphql` | GraphQL (one `POST`) | [`linear.schema.json`](../backlot/schemas/linear.schema.json) | A Linear issue. | +| `linear` | Linear | `/linear/graphql` | GraphQL (one endpoint, `POST` or `GET`) | [`linear.schema.json`](../backlot/schemas/linear.schema.json) | A Linear issue. | | `msteams` | Microsoft Teams | `/msgraph/v1.0` | 13 | [`msteams.schema.json`](../backlot/schemas/msteams.schema.json) | A Microsoft Teams channel message. | | `notion` | Notion | `/notion/v1` | 12 | [`notion.schema.json`](../backlot/schemas/notion.schema.json) | A Notion page or database. | | `s3` | Amazon S3 | `/s3` | 5 | [`s3.schema.json`](../backlot/schemas/s3.schema.json) | An S3 object. | @@ -87,7 +87,10 @@ what `search` answers a `GET` or `HEAD`, but for a 401 and a refusal of its `Acc ### Fireflies — `/fireflies/graphql` -**GraphQL only**, one `POST`. Root `Query` fields: +**GraphQL only**, one endpoint: a `POST` with a JSON or form body, or a `GET` with the query in its +query string and a JSON `Content-Type` or none. Apollo Server serves a form, or a `GET` with no +`Content-Type`, only beside an `apollo-require-preflight` or `x-apollo-operation-name` header, its +check that a browser preflighted the request. Root `Query` fields: | Query field | Notes | |---|---| @@ -577,7 +580,10 @@ operation's 415 carry the account id alone. ### Linear — `/linear/graphql` -**GraphQL only**, one `POST`. Root `Query` fields: +**GraphQL only**, one endpoint: a `POST` with a JSON body, or a `GET` with the query in its query +string. Apollo Server serves a `GET` with no `Content-Type`, or a form or `text/plain` one, only +beside an `apollo-require-preflight` or `x-apollo-operation-name` header, its check that a +browser preflighted the request. Root `Query` fields: | Query field | Notes | |---|---| diff --git a/pyproject.toml b/pyproject.toml index 75865190d..15d78ab13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -252,6 +252,7 @@ backlot = [ "schemas/*.json", "schemas/*.md", "graphql/*.graphql", + "graphql/*.html", "data/*.json", "data/*.jsonl", "data/*.wadl", diff --git a/scripts/gen_docs.py b/scripts/gen_docs.py index 3c2d307a4..1cb6b136b 100644 --- a/scripts/gen_docs.py +++ b/scripts/gen_docs.py @@ -215,7 +215,7 @@ def render_sources() -> str: for source_type in sorted(SOURCES): name, prefixes = SOURCES[source_type] if source_type in GRAPHQL_ONLY: - endpoints = "GraphQL (one `POST`)" + endpoints = "GraphQL (one endpoint, `POST` or `GET`)" else: endpoints = str( sum(1 for path in spec_paths if any(path.startswith(p) for p in prefixes)) diff --git a/tests/_helpers.py b/tests/_helpers.py index 4408e696f..72dabb245 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -319,11 +319,13 @@ def __new__(cls, answer: bytes, status: int = 400): return self -def sent_as(client, method: str, path: str, headers, content=None): +def sent_as(client, method: str, path: str, headers, content=None, bare: bool = False): """``method`` on ``path`` with each header's latin-1 bytes as sent, ``headers`` a mapping or a sequence of pairs, which can name a header twice. The TestClient re-encodes a header's bytes above 0x7F as UTF-8 (`\\x85` reaches the app as `\\xc2\\x85`), where httpx's own - `ASGITransport` hands the app what it was given, over the app the fixture started.""" + `ASGITransport` hands the app what it was given, over the app the fixture started. ``bare`` + leaves out the headers httpx sends unasked, `Accept: */*` among them, and with no ``content`` + the `Content-Length: 0` it puts on a POST.""" import asyncio import httpx @@ -334,11 +336,31 @@ def sent_as(client, method: str, path: str, headers, content=None): async def send(): transport = httpx.ASGITransport(app=client.app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: - return await c.request(method, path, headers=raw, content=content) + if bare: + c.headers.clear() + request = c.build_request(method, path, headers=raw, content=content) + if bare and content is None: + request.headers.pop("content-length", None) + return await c.send(request) return asyncio.run(send()) +class Chunked(bytes): + """A body :func:`sent_as` sends in chunks, with no `Content-Length`.""" + + async def chunks(self): + yield bytes(self) + + +def http_row(method, target, sent, body, credential, status, media_type, answer, id, answered=None): + """A row of an HTTP layer's table: the request, the credential, and the answer, ``answered`` + naming headers on it.""" + return pytest.param( + method, target, sent, body, credential, status, media_type, answer, answered or {}, id=id + ) + + def selected_fields(document: str, *path: str) -> set[str]: """The field names ``document`` selects at ``path`` — for asserting on a generated selection set (``backlot.graphql.mcp_tools``) by shape rather than by substring.""" diff --git a/tests/test_acl.py b/tests/test_acl.py index 5712146c3..841d6b292 100644 --- a/tests/test_acl.py +++ b/tests/test_acl.py @@ -1,10 +1,28 @@ """ACL resolution + visibility, asserted against the SAMPLE corpus's generated ACL.""" +import json + +import pytest import yaml from backlot import store from tests._helpers import client_for, gql +_ADMIN = "admin-service-token" + + +def _sent(client, path, query, authorization, via, **variables): + """``query`` sent to ``path`` as ``via`` names: ``POST`` as JSON (:func:`gql`), ``GET`` in the + query string and, on Fireflies, ``form`` as a form, the last two beside the preflight header + (`backlot.graphql.apollo_http.csrf_refusal`).""" + if via == "POST": + return gql(client, path, query, authorization, **variables) + fields = {"query": query, **({"variables": json.dumps(variables)} if variables else {})} + headers = {"Authorization": authorization, "apollo-require-preflight": "1"} + if via == "GET": + return client.get(path, params=fields, headers=headers) + return client.post(path, data=fields, headers=headers) + def _visible(db, acl, token, source): """The SERVED keys this token can see in one source — the same tuples `keys` hands back, so an @@ -165,24 +183,25 @@ def test_linear_team_counts_are_acl_scoped(db, acl, tokens): # offline: an enumerable oracle, not merely a confirmable one. -def _gql(client, query, token): - return gql(client, "/linear/graphql", query, token).json() +def _gql(client, query, token, via="POST"): + return _sent(client, "/linear/graphql", query, token, via).json() -def test_linear_by_id_roots_do_not_leak_entities_off_hidden_issues(sample_settings, tokens): +@pytest.mark.parametrize("via", ["POST", "GET"]) +def test_linear_by_id_roots_do_not_leak_entities_off_hidden_issues(sample_settings, tokens, via): """`blackops` is granted to hana only, so its "Triage" state, in no other team, must fail for - ava exactly as an absent id would — and must still work for hana, proving the id is real and - the difference is the ACL.""" + ava exactly as an absent id would — and must still work for hana and the admin, proving the id + is real and the difference is the ACL, whichever way the document is sent.""" from backlot import synth with client_for(sample_settings) as client: state_id = synth.linear_state_id("Triage", "blackops") q = '{ workflowState(id: "%s") { name } }' % state_id - hidden = _gql(client, q, tokens["ava@acme.com"]) - granted = _gql(client, q, tokens["hana@acme.com"]) + hidden = _gql(client, q, tokens["ava@acme.com"], via) assert "data" not in hidden or hidden["data"] is None assert "Entity not found" in hidden["errors"][0]["message"] - assert granted["data"]["workflowState"]["name"] == "Triage" + for granted in (tokens["hana@acme.com"], _ADMIN): + assert _gql(client, q, granted, via)["data"]["workflowState"]["name"] == "Triage" # ...and indistinguishable from an id that genuinely does not exist. absent = _gql( @@ -190,6 +209,7 @@ def test_linear_by_id_roots_do_not_leak_entities_off_hidden_issues(sample_settin '{ workflowState(id: "%s") { name } }' % synth.linear_state_id("No Such State", "engineering"), tokens["ava@acme.com"], + via, ) assert absent == hidden @@ -358,8 +378,8 @@ def test_linear_hidden_assignee_is_not_nameable_by_id(sample_settings, tokens): # both the unfiltered list and every filter have to agree about hiding it. -def _ff_gql(client, query, token, **variables): - return gql(client, "/fireflies/graphql", query, f"Bearer {token}", **variables).json() +def _ff_gql(client, query, token, via="POST", **variables): + return _sent(client, "/fireflies/graphql", query, f"Bearer {token}", via, **variables).json() def test_fireflies_store_reads_are_acl_scoped(db, acl, tokens, keys): @@ -374,13 +394,15 @@ def test_fireflies_store_reads_are_acl_scoped(db, acl, tokens, keys): ) -def test_fireflies_transcripts_list_hides_denied_meetings(sample_settings, tokens): +@pytest.mark.parametrize("via", ["POST", "GET", "form"]) +def test_fireflies_transcripts_list_hides_denied_meetings(sample_settings, tokens, via): with client_for(sample_settings) as client: q = "{ transcripts(limit: 50) { title } }" - ava = _ff_gql(client, q, tokens["ava@acme.com"])["data"]["transcripts"] - hana = _ff_gql(client, q, tokens["hana@acme.com"])["data"]["transcripts"] + ava = _ff_gql(client, q, tokens["ava@acme.com"], via)["data"]["transcripts"] assert "Board pre-read walkthrough" not in [t["title"] for t in ava] - assert "Board pre-read walkthrough" in [t["title"] for t in hana] + for granted in (tokens["hana@acme.com"], _ADMIN): + seen = _ff_gql(client, q, granted, via)["data"]["transcripts"] + assert "Board pre-read walkthrough" in [t["title"] for t in seen] def test_fireflies_transcript_by_id_denies_rather_than_reveals(sample_settings, tokens): diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index d2de4a063..d7c1fc914 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -333,7 +333,9 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( number, quoted = row.values body = '{"query":"%s","variables":{"x":%s}}' % (document, number) r = client.post( - f"/{vendor}/graphql", content=body.encode(), headers={"Authorization": credential} + f"/{vendor}/graphql", + content=body.encode(), + headers={"Authorization": credential, "Content-Type": "application/json"}, ) message = (r.json().get("errors") or [{}])[0].get("message", "") answered[row.id] = ( @@ -344,61 +346,87 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( assert answered == {row.id: (400, True, True) for row in _NUMBERS} -# A request to Linear or Fireflies with `Authorization` sent more than once, `{key}` standing for -# the vendor's own spelling of the admin token, each answered with the 400 page -# `backlot.main.refuse_a_repeated_header_on_notion_linear_fireflies_and_hubspot` records. The last -# three rows are the control: one header reaches the vendor, and a path that only starts with the -# vendor's name is not the vendor's. +# A request to Linear or Fireflies that Cloudflare answers itself, `{key}` standing for the vendor's +# own spelling of the admin token, and the status, media type, length, `Connection` and body of the +# answer, as `backlot.main.refuse_cloudflares_methods_on_linear_and_fireflies` and the +# repeated-header middleware it names record them. The last three rows are the control: one header +# reaches the vendor, and a path that only starts with the vendor's name is not the vendor's. _TYPENAME = b'{"query":"{ __typename }"}' _CLOUDFLARE = 400 -_REPEATED = [ - pytest.param("POST", "/graphql", _TYPENAME, ("{key}", "{key}"), _CLOUDFLARE, id="two keys"), - pytest.param("POST", "/graphql", _TYPENAME, ("{key}", "nope"), _CLOUDFLARE, id="a key, nope"), - pytest.param("POST", "/graphql", _TYPENAME, ("nope", "{key}"), _CLOUDFLARE, id="nope, a key"), - pytest.param("POST", "/graphql", _TYPENAME, ("{key}", ""), _CLOUDFLARE, id="a key, empty"), - pytest.param("POST", "/graphql", _TYPENAME, ("", ""), _CLOUDFLARE, id="two empty"), - pytest.param("POST", "/graphql", _TYPENAME, ("{key}",) * 3, _CLOUDFLARE, id="three keys"), - pytest.param("POST", "/graphql", b"", ("{key}", "nope"), _CLOUDFLARE, id="no bytes"), - pytest.param("POST", "/graphql", b"{", ("{key}", "nope"), _CLOUDFLARE, id="{"), - pytest.param("POST", "/graphql", None, ("{key}", "nope"), _CLOUDFLARE, id="past the limit"), - pytest.param("GET", "/graphql", b"", ("{key}", "nope"), _CLOUDFLARE, id="GET"), - pytest.param("HEAD", "/graphql", b"", ("{key}", "nope"), _CLOUDFLARE, id="HEAD"), - pytest.param("POST", "/nosuch", _TYPENAME, ("{key}", "nope"), _CLOUDFLARE, id="another path"), - pytest.param("POST", "", _TYPENAME, ("{key}", "nope"), _CLOUDFLARE, id="the root"), - pytest.param("POST", "/graphql", _TYPENAME, ("{key}",), 200, id="one key"), - pytest.param("POST", "x/graphql", _TYPENAME, ("{key}", "nope"), 404, id="a longer name"), +_PAGE = ( + b"\r\n%s\r\n\r\n

%s

\r\n" + b"
cloudflare
\r\n\r\n\r\n" +) +_TWICE = (_CLOUDFLARE, "text/html", "155", "close", _PAGE % ((b"400 Bad Request",) * 2)) +_NOT_ALLOWED = (405, "text/html", "155", "close", _PAGE % ((b"405 Not Allowed",) * 2)) +_ERROR_1045 = (400, "text/plain; charset=UTF-8", "17", None, b"error code: 1045\n") +_PURGED = (400, "text/html; charset=UTF-8", "155", None, _PAGE % ((b"400 Bad Request",) * 2)) + + +def _auth(*values: str) -> tuple: + return tuple(("Authorization", value) for value in values) + + +_ETAG_TWICE = (("If-None-Match", '"x"'),) * 2 +_CLOUDFLARE_FIRST = [ + pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}", "{key}"), _TWICE, id="two keys"), + pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}", "nope"), _TWICE, id="a key, nope"), + pytest.param("POST", "/graphql", _TYPENAME, _auth("nope", "{key}"), _TWICE, id="nope, a key"), + pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}", ""), _TWICE, id="a key, empty"), + pytest.param("POST", "/graphql", _TYPENAME, _auth("", ""), _TWICE, id="two empty"), + pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}") * 3, _TWICE, id="three keys"), + pytest.param("POST", "/graphql", b"", _auth("{key}", "nope"), _TWICE, id="no bytes"), + pytest.param("POST", "/graphql", b"{", _auth("{key}", "nope"), _TWICE, id="{"), + pytest.param("POST", "/graphql", None, _auth("{key}", "nope"), _TWICE, id="past the limit"), + pytest.param("GET", "/graphql", b"", _auth("{key}", "nope"), _TWICE, id="GET"), + pytest.param("HEAD", "/graphql", b"", _auth("{key}", "nope"), _TWICE, id="HEAD"), + pytest.param("POST", "/nosuch", _TYPENAME, _auth("{key}", "nope"), _TWICE, id="another path"), + pytest.param("POST", "", _TYPENAME, _auth("{key}", "nope"), _TWICE, id="the root"), + *( + pytest.param(m, path, b"", _auth("{key}"), answer, id=f"{m} {path or '/'}") + for m, answer in (("TRACE", _NOT_ALLOWED), ("CONNECT", _ERROR_1045), ("PURGE", _PURGED)) + for path in ("/graphql/", "/nosuch", "") + ), + *( + pytest.param(m, "/graphql", b"", _auth("{key}", "{key}"), _TWICE, id=f"{m}, two keys") + for m in ("CONNECT", "PURGE") + ), + *( + pytest.param( + m, "/graphql", b"", (*_auth("{key}"), *_ETAG_TWICE), _TWICE, id=f"{m}, ETag twice" + ) + for m in ("TRACE", "CONNECT", "PURGE") + ), + pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}"), 200, id="one key"), + pytest.param("POST", "x/graphql", _TYPENAME, _auth("{key}", "nope"), 404, id="a longer name"), + pytest.param("TRACE", "x/graphql", b"", _auth("{key}"), 404, id="TRACE, a longer name"), ] @pytest.mark.parametrize("vendor", ["linear", "fireflies"]) -def test_cloudflare_refuses_a_repeated_authorization(client, tokens_yaml, vendor): +def test_cloudflare_answers_ahead_of_linear_and_fireflies(client, tokens_yaml, vendor): admin = tokens_yaml["admin_token"] key = admin if vendor == "linear" else f"Bearer {admin}" - page = ( - b"\r\n400 Bad Request\r\n\r\n" - b"

400 Bad Request

\r\n
cloudflare
\r\n" - b"\r\n\r\n" - ) answered, expected = {}, {} - for row in _REPEATED: - method, path, body, values, status = row.values + for row in _CLOUDFLARE_FIRST: + method, path, body, sent, answer = row.values if body is None: body = padded_query({"linear": 10 * 2**20, "fireflies": 2**20}[vendor] + 1) headers = [("Content-Type", "application/json")] - headers += [("Authorization", value.format(key=key)) for value in values] + headers += [(name, value.format(key=key)) for name, value in sent] r = client.request(method, f"/{vendor}{path}", content=body, headers=headers) - if status != _CLOUDFLARE: + if isinstance(answer, int): answered[row.id] = ( r.status_code, - page in r.content, + b"cloudflare" in r.content, "connection" in r.headers, r.json() if r.status_code == 200 else None, ) expected[row.id] = ( - status, + answer, False, False, - {"data": {"__typename": "Query"}} if status == 200 else None, + {"data": {"__typename": "Query"}} if answer == 200 else None, ) continue answered[row.id] = ( @@ -406,9 +434,9 @@ def test_cloudflare_refuses_a_repeated_authorization(client, tokens_yaml, vendor r.headers.get("content-type"), r.headers.get("content-length"), r.headers.get("connection"), - None if method == "HEAD" else r.content, + r.content, ) - expected[row.id] = (status, "text/html", "155", "close", None if method == "HEAD" else page) + expected[row.id] = (*answer[:4], b"" if method == "HEAD" else answer[4]) assert answered == expected diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index 2d5133fc4..65b3f2d4f 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -9,6 +9,7 @@ import gzip import hashlib import json +import re import sqlite3 import time import tracemalloc @@ -20,10 +21,12 @@ from backlot.graphql import mcp_tools from tests._helpers import ( SWEPT_BYTES, + Chunked, WithTheKey, client_for, corpus_client, gql, + http_row, padded_query, sent_as, served_id, @@ -195,6 +198,10 @@ def _no_field_a(column: int) -> bytes: b'{"statusCode":413,"status":false,"code":"http","message":"Request body is too large",' b'"errorMessage":"Request body is too large"}' ) +_SIZE_MISMATCH = ( + b'{"statusCode":400,"status":false,"code":"http","message":"Request body size did not match' + b' Content-Length","errorMessage":"Request body size did not match Content-Length"}' +) _TYPENAME = b'{"query":"{ __typename }"}' @@ -1659,3 +1666,878 @@ def _roster_emails(settings): data = yaml.safe_load(settings.tokens_path.read_text(encoding="utf-8")) or {} return [u["email"] for u in data.get("users", [])] + + +# --- Fastify, @fastify/cors and Apollo Server in front of Fireflies' GraphQL ---------------------- +# Each row: a request, its credential (`_FIREFLIES_CREDENTIALS`), and api.fireflies.ai's answer to +# it, as `graphql` records the layers it was measured through: status, media type and body less +# the newline Apollo Server ends one with, `_TYPENAME_DATA` where the query was served, nothing for +# a `HEAD`. +_CSRF = _apollo( + b"This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). Please" + b" either specify a 'content-type' header (with a type that is not one of" + b" application/x-www-form-urlencoded, multipart/form-data, text/plain) or provide a non-empty" + b" value for one of the following headers: x-apollo-operation-name, apollo-require-preflight" + b"\\n" +) +_ONLY_GET_POST = _apollo(b"Apollo Server supports only GET/POST requests.") +_NOT_ACCEPTABLE = _apollo( + b"An 'accept' header was provided for this request which does not accept application/json;" + b" charset=utf-8 or application/graphql-response+json; charset=utf-8" +) +_GET_CONTENT_TYPE = _apollo( + b"GET requests may not have a content-type header other than application/json." +) +_UNSUPPORTED = ( + b'{"statusCode":415,"status":false,"code":"http","message":"Unsupported Media Type",' + b'"errorMessage":"Unsupported Media Type"}' +) +_TYPENAME_DATA = b'{"data":{"__typename":"Query"}}' +_JSON_UTF8 = "application/json; charset=utf-8" +_GRAPHQL_RESPONSE = "application/graphql-response+json; charset=utf-8" +_CLOUDFLARE_PAGE = ( + b"\r\n%s\r\n\r\n

%s

\r\n" + b"
cloudflare
\r\n\r\n\r\n" +) +_FIREFLIES_PREFLIGHT = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,HEAD,POST", + "vary": "Access-Control-Request-Headers", + # A preflight asking for no header names gets none back. + "access-control-allow-headers": None, +} +_J = (("Content-Type", "application/json"),) +_P = (("apollo-require-preflight", "1"),) +_PREFLIGHT_ASKED = (("Origin", "https://example.com"), ("Access-Control-Request-Method", "POST")) +_Q = "?query=%7B%20__typename%20%7D" + + +_PAST_THE_LIMIT = b"x" * (2**20 + 10) +_LANDING_PAGE = object() # Apollo Server's page for a browser, `landing_page`'s + + +def _not_found(method: str, target: str) -> bytes: + message = b"Cannot %s /graphql%s" % (method.encode(), target.encode()) + return ( + b'{"statusCode":404,"status":false,"code":"not_found","message":"%s","errorMessage":"%s"}' + % (message, message) + ) + + +_FIREFLIES_HTTP = [ + # Cloudflare, ahead of everything. + http_row( + "TRACE", + "", + (), + None, + "key", + 405, + "text/html", + _CLOUDFLARE_PAGE % ((b"405 Not Allowed",) * 2), + id="TRACE", + answered={"connection": "close"}, + ), + http_row( + "CONNECT", + "", + (), + None, + "key", + 400, + "text/plain; charset=UTF-8", + b"error code: 1045\n", + id="CONNECT", + answered={"connection": None}, + ), + http_row( + "PURGE", + "", + (), + None, + "key", + 400, + "text/html; charset=UTF-8", + _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), + id="PURGE", + answered={"connection": None}, + ), + http_row( + "TRACE", + "", + (), + None, + "twice", + 400, + "text/html", + _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), + id="TRACE, Authorization twice", + answered={"connection": "close"}, + ), + # `@fastify/cors`, whatever the credential and the body. + *( + http_row( + "OPTIONS", + "", + _PREFLIGHT_ASKED, + None, + c, + 204, + None, + b"", + id=f"preflight, {c}", + answered=_FIREFLIES_PREFLIGHT, + ) + for c in ("key", "none", "Bearer nope") + ), + http_row( + "OPTIONS", + "", + (("Origin", "null"), ("Access-Control-Request-Method", "POST")), + None, + "key", + 204, + None, + b"", + id="Origin null", + answered=_FIREFLIES_PREFLIGHT, + ), + http_row( + "OPTIONS", + "", + (("Origin", "https://example.com"), ("Access-Control-Request-Method", "PUT")), + None, + "key", + 204, + None, + b"", + id="asked PUT", + answered=_FIREFLIES_PREFLIGHT, + ), + *( + http_row( + "OPTIONS", + "", + (*_PREFLIGHT_ASKED, ("Access-Control-Request-Headers", asked)), + None, + "key", + 204, + None, + b"", + id=f"asked {asked!r}", + answered={**_FIREFLIES_PREFLIGHT, "access-control-allow-headers": allowed}, + ) + for asked, allowed in ( + ("Content-Type, X-Foo", "Content-Type, X-Foo"), + (" a , b ", "a , b"), + ("a,,b", "a,,b"), + ) + ), + *( + http_row( + "OPTIONS", + "", + h, + b, + c, + 400, + "text/plain", + b"Invalid Preflight Request", + id=f"OPTIONS {i}, {c}", + ) + for h, b, c, i in ( + ((), None, "key", ""), + ((), None, "none", ""), + ((), None, "Bearer nope", ""), + ((("Origin", "https://example.com"),), None, "key", "Origin alone"), + ((("Access-Control-Request-Method", "POST"),), None, "key", "no Origin"), + ((("Origin", ""), ("Access-Control-Request-Method", "POST")), None, "key", "Origin ''"), + ( + (("Origin", " "), ("Access-Control-Request-Method", "POST")), + None, + "key", + "Origin ' '", + ), + ( + (("Origin", "https://example.com"), ("Access-Control-Request-Method", "")), + None, + "key", + "Access-Control-Request-Method ''", + ), + ((*_J, *_P), b"{", "key", "{"), + ) + ), + # `_not_found`, after `_fastify_read`. + http_row( + "ACL", + "?_=271823726", + (*_J, *_P), + None, + "key", + 404, + _JSON_UTF8, + b'{"statusCode":404,"status":false,"code":"not_found","message":"Cannot ACL /graphql?_=27182' + b'3726","errorMessage":"Cannot ACL /graphql?_=271823726"}', + id="ACL", + ), + *( + http_row(m, "?_=1", (*_J, *_P), None, "key", 404, _JSON_UTF8, _not_found(m, "?_=1"), id=m) + for m in ( + "BIND", + "CHECKOUT", + "COPY", + "LINK", + "LOCK", + "M-SEARCH", + "MERGE", + "MKACTIVITY", + "MKCALENDAR", + "MKCOL", + "MOVE", + "NOTIFY", + "PROPFIND", + "PROPPATCH", + "QUERY", + "REBIND", + "REPORT", + "SEARCH", + "SOURCE", + "SUBSCRIBE", + "UNBIND", + "UNLINK", + "UNLOCK", + "UNSUBSCRIBE", + ) # fmt: skip + ), + http_row( + "PROPFIND", + "?x=%7B%20y&z&a=b+c&_=1", + _J, + None, + "key", + 404, + _JSON_UTF8, + _not_found("PROPFIND", "?x=%7B%20y&z&a=b+c&_=1"), + id="PROPFIND, a query string", + ), + *( + http_row(m, "", h, b, c, s, _JSON_UTF8, a, id=f"{m} {i}") + for m, h, b, c, s, a, i in ( + ("PUT", (*_J, *_P), _TYPENAME, "key", 404, _not_found("PUT", ""), "a query"), + ("DELETE", _J, _TYPENAME, "none", 404, _not_found("DELETE", ""), "a query"), + ("PUT", (), _TYPENAME, "key", 404, _not_found("PUT", ""), "no type"), + ( + "PATCH", + (("Content-Type", "application/x-www-form-urlencoded"),), + b"query=%7B%7D", + "key", + 404, + _not_found("PATCH", ""), + "a form", + ), + ( + "DELETE", + (("Content-Type", "text/plain"),), + b"", + "key", + 404, + _not_found("DELETE", ""), + "text/plain", + ), + ("PUT", (*_J, *_P), b"{", "key", 400, _NOT_JSON, "{"), + ("PATCH", (*_J, *_P), None, "key", 400, _EMPTY, "empty"), + ("PUT", (("Content-Type", "a/b/c"),), _TYPENAME, "key", 415, _UNSUPPORTED, "a/b/c"), + ("PUT", (("Content-Type", ""),), _TYPENAME, "key", 415, _UNSUPPORTED, "an empty type"), + ("PUT", (("Content-Type", "text/plain"),), b"\xe9", "key", 400, _SIZE_MISMATCH, "0xE9"), + ( + "PUT", + (("Content-Type", "text/plain"),), + _PAST_THE_LIMIT, + "key", + 413, + _TOO_LARGE, + "past the limit", + ), + ) + ), + # A POST's content type: a 415 for one Fastify does not read; CSRF for one a browser sends + # unpreflighted; the CSRF check ahead of the credential. + *( + http_row("POST", "", (("Content-Type", t), *h), b, c, s, _JSON_UTF8, a, id=f"{t!r}{i}") + for t, h, b, c, s, a, i in ( + ("text/plain", (), _TYPENAME, "key", 400, _CSRF, ""), + ("text/plain", (), _TYPENAME, "none", 400, _CSRF, ", none"), + ("text/plain", _P, _TYPENAME, "key", 400, _NO_KEYS, ", preflight"), + ("text/plain", _P, b"\xe9", "key", 400, _SIZE_MISMATCH, ", preflight, 0xE9"), + ("text/plain", (), b"\xe9", "key", 400, _SIZE_MISMATCH, ", 0xE9"), + ("text/plain; charset=latin1", _P, b"\xe9", "key", 400, _SIZE_MISMATCH, ", 0xE9"), + ("text/plain", _P, "\u00e9".encode(), "key", 400, _NO_KEYS, ", preflight, U+00E9"), + ( + "application/x-www-form-urlencoded", + _P, + b"query=%7B__typename%7D&x=\xe9", + "key", + 200, + b'{"data":{"__typename":"Query"}}', + ", preflight, 0xE9", + ), + ("TEXT/PLAIN;X=Y", (), _TYPENAME, "key", 400, _CSRF, ""), + ("application/x-www-form-urlencoded", (), _TYPENAME, "key", 400, _CSRF, ""), + ("", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), + ("foo", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), + ("text /plain", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), + ("multipart/form-data; boundary=x", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), + ("application/graphql", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), + ( + "application/graphql", + (), + _PAST_THE_LIMIT, + "key", + 415, + _UNSUPPORTED, + ", past the limit", + ), + ("application/json ,", (), _TYPENAME, "key", 415, _UNSUPPORTED, ""), + ("text/plain", (), _PAST_THE_LIMIT, "key", 413, _TOO_LARGE, ", past the limit"), + ( + "text/plain", + _P, + b"\xe9" * 2**20 + b"\xe9", + "key", + 413, + _TOO_LARGE, + ", past the limit, 0xE9", + ), + ( + "application/x-www-form-urlencoded", + (), + _PAST_THE_LIMIT, + "key", + 413, + _TOO_LARGE, + ", past the limit", + ), + ("application/json;", (), _TYPENAME, "key", 200, _TYPENAME_DATA, ""), + ("application/json\t;", (), _TYPENAME, "key", 200, _TYPENAME_DATA, ""), + ("Application/JSON", (), _TYPENAME, "key", 200, _TYPENAME_DATA, ""), + ( + "application/x-www-form-urlencoded", + _P, + b"query=%7B%20__typename%20%7D", + "key", + 200, + _TYPENAME_DATA, + ", a form", + ), + ( + "application/x-www-form-urlencoded", + _P, + b"query=%7B%20__typename%20%7D&query=x", + "key", + 400, + _apollo(b"GraphQL queries must be strings."), + ", query twice", + ), + ( + "application/x-www-form-urlencoded", + _P, + b"query=%7B%20__typename%20%7D&variables=%7B%7D", + "key", + 400, + _apollo( + b"`variables` in a POST body should be provided as an object, not a recursively JSON-encoded string." + ), + ", variables", + ), + ("application/x-www-form-urlencoded", _P, b"", "key", 400, _NO_KEYS, ", empty"), + ("text/plain", _P, b"", "key", 400, _NO_KEYS, ", empty"), + ) + ), + http_row("POST", "", (), _TYPENAME, "key", 415, _JSON_UTF8, _UNSUPPORTED, id="no type"), + http_row( + "POST", "", _P, _TYPENAME, "key", 415, _JSON_UTF8, _UNSUPPORTED, id="no type, preflight" + ), + http_row("POST", "", (), b"", "key", 400, _JSON_UTF8, _CSRF, id="no type, empty"), + http_row("POST", "", _P, b"", "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, empty, preflight"), + # Apollo Server's page for a browser, ahead of the CSRF check and the credential. + *( + http_row("GET", t, (*h, ("Accept", a)), None, c, s, ty, e, id=f"GET, Accept {a!r}{i}") + for t, h, a, c, s, ty, e, i in ( + ("", (), "text/html", "none", 200, "text/html", _LANDING_PAGE, ", none"), + (_Q, _P, "text/html,application/json", "key", 200, "text/html", _LANDING_PAGE, ""), + (_Q, _P, "text/*", "key", 200, "text/html", _LANDING_PAGE, ""), + (_Q, _P, "text/html;q=0.5;charset=utf-8", "key", 200, "text/html", _LANDING_PAGE, ""), + ( + _Q, + _P, + "application/json;q=0.5, text/html;q=0.6", + "key", + 200, + "text/html", + _LANDING_PAGE, + "", + ), + (_Q, _P, "text/html;level=1", "key", 406, _JSON_UTF8, _NOT_ACCEPTABLE, ""), + (_Q, _P, "text/html;q", "key", 406, _JSON_UTF8, _NOT_ACCEPTABLE, ""), + ( + _Q, + _P, + "text/html;q=0.9, application/json", + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + "", + ), + ) + ), + # A GET, its content type and its query string read after the credential. + http_row("GET", _Q, _P, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET"), + http_row("GET", _Q, (), None, "key", 400, _JSON_UTF8, _CSRF, id="GET, unpreflighted"), + http_row("GET", _Q, _J, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET, JSON"), + *( + http_row( + "GET", _Q, (("Content-Type", t), *h), None, c, s, _JSON_UTF8, a, id=f"GET, {t!r}{i}" + ) + for t, h, c, s, a, i in ( + ("text/plain", _P, "key", 415, _GET_CONTENT_TYPE, ""), + ("foo", (), "key", 415, _GET_CONTENT_TYPE, ""), + ("foo", _P, "none", 500, _BAD_KEY, ", none"), + ("application/graphql-response+json", _P, "key", 415, _GET_CONTENT_TYPE, ""), + ("", _P, "key", 200, _TYPENAME_DATA, ""), + ("application/json; charset=latin1", _P, "key", 200, _TYPENAME_DATA, ""), + ) + ), + http_row( + "GET", + "?query=%7B&query=%7B", + (("Content-Type", "text/plain"), *_P), + None, + "key", + 415, + _JSON_UTF8, + _GET_CONTENT_TYPE, + id="GET, text/plain, query twice", + ), + *( + http_row("GET", t, _P, None, "key", 400, _JSON_UTF8, _apollo(m), id=i) + for t, m, i in ( + ( + _Q + "&query=", + b"The 'query' search parameter may only be specified once.", + "query twice", + ), + ( + _Q + "&variables=nope", + b"The variables search parameter contains invalid JSON.", + "variables nope", + ), + ( + _Q + "&variables=null", + b"The variables search parameter should contain a JSON-encoded object.", + "variables null", + ), + ( + "?query=%7B&query=%7B&variables=nope", + b"The 'query' search parameter may only be specified once.", + "query twice, variables nope", + ), + ( + "?operationName=A&operationName=B&variables=nope", + b"The 'operationName' search parameter may only be specified once.", + "operationName twice, variables nope", + ), + ( + "?variables=nope&extensions=5", + b"The variables search parameter contains invalid JSON.", + "variables nope, extensions 5", + ), + ) + ), + *( + http_row( + "GET", + f"?query={op}%20%7B%20__typename%20%7D", + _P, + None, + c, + s, + _JSON_UTF8, + a, + id=f"GET {op}, {c}", + **h, + ) + for op, c, s, a, h in ( + ( + "mutation", + "key", + 405, + _apollo( + b"GET requests only support query operations, not mutation operations", + status=405, + ), + {"answered": {"allow": "POST"}}, + ), + ( + "subscription", + "key", + 405, + _apollo( + b"GET requests only support query operations, not subscription operations", + status=405, + ), + {"answered": {"allow": "POST"}}, + ), + ("mutation", "none", 500, _BAD_KEY, {}), + ) + ), + http_row( + "HEAD", + _Q, + _P, + None, + "key", + 405, + _JSON_UTF8, + b"", + id="HEAD", + answered={"allow": "GET, POST"}, + ), + http_row( + "HEAD", + _Q, + (*_P, ("Accept", "text/html")), + None, + "key", + 405, + _JSON_UTF8, + b"", + id="HEAD, Accept text/html", + ), + http_row("HEAD", _Q, _P, None, "none", 500, _JSON_UTF8, b"", id="HEAD, none"), + http_row( + "HEAD", + _Q, + (("Content-Type", "text/plain"),), + None, + "key", + 400, + _JSON_UTF8, + b"", + id="HEAD, text/plain", + ), + # `apollo_http.response_type` and its 406, the credential's 500 among the thrown refusals. + *( + http_row("POST", "", (*_J, ("Accept", a)), b, c, s, t, e, id=f"Accept {a!r}{i}") + for a, b, c, s, t, e, i in ( + ("text/html", _TYPENAME, "key", 406, _JSON_UTF8, _NOT_ACCEPTABLE, ""), + ("application/json;q=0", _TYPENAME, "key", 406, _JSON_UTF8, _NOT_ACCEPTABLE, ""), + ("application/json;q", _TYPENAME, "key", 406, _JSON_UTF8, _NOT_ACCEPTABLE, ""), + ( + "application/graphql-response+json", + _TYPENAME, + "key", + 200, + _GRAPHQL_RESPONSE, + _TYPENAME_DATA, + "", + ), + ( + "application/json; callbackSpec=1.0", + _TYPENAME, + "key", + 200, + "application/json; callbackSpec=1.0; charset=utf-8", + _TYPENAME_DATA, + "", + ), + ("text/html", b"{}", "key", 400, _JSON_UTF8, _NO_KEYS, ", {}"), + ( + "application/graphql-response+json", + b"{}", + "key", + 400, + _GRAPHQL_RESPONSE, + _NO_KEYS, + ", {}", + ), + ( + "text/html", + b'{"query":"{"}', + "key", + 406, + _JSON_UTF8, + _NOT_ACCEPTABLE, + ", a parse error", + ), + ("text/html", _TYPENAME, "none", 500, _JSON_UTF8, _BAD_KEY, ", none"), + ( + "application/graphql-response+json", + _TYPENAME, + "none", + 500, + _GRAPHQL_RESPONSE, + _BAD_KEY, + ", none", + ), + ("text/html", b"{", "key", 400, _JSON_UTF8, _NOT_JSON, ", {"), + ) + ), + http_row( + "POST", + "", + (("Content-Type", "text/plain"), ("Accept", "application/graphql-response+json")), + _TYPENAME, + "key", + 400, + _GRAPHQL_RESPONSE, + _CSRF, + id="text/plain, Accept graphql-response", + ), + http_row( + "PUT", + "", + (*_J, ("Accept", "application/graphql-response+json")), + _TYPENAME, + "key", + 404, + _JSON_UTF8, + _not_found("PUT", ""), + id="PUT, Accept graphql-response", + ), + # Fastify reads the body of a DELETE before its 404, and leaves a type it has no parser for + # unread there. + http_row("DELETE", "", (*_J, *_P), b"{", "key", 400, _JSON_UTF8, _NOT_JSON, id="DELETE {"), + http_row( + "PUT", + "", + (("Content-Type", "application/graphql"), *_P), + _TYPENAME, + "key", + 404, + _JSON_UTF8, + _not_found("PUT", ""), + id="PUT application/graphql", + ), + http_row( + "OPTIONS", + "", + ( + *_PREFLIGHT_ASKED, + ("Access-Control-Request-Headers", "a"), + ("Access-Control-Request-Headers", "b"), + ), + None, + "key", + 204, + None, + b"", + id="asked twice", + answered={**_FIREFLIES_PREFLIGHT, "access-control-allow-headers": "a, b"}, + ), + *( + http_row( + "OPTIONS", + "", + (*_PREFLIGHT_ASKED, ("Access-Control-Request-Headers", asked)), + None, + "key", + 204, + None, + b"", + id=f"asked {asked!r}", + answered={**_FIREFLIES_PREFLIGHT, "access-control-allow-headers": allowed}, + ) + for asked, allowed in ( + ("a\xa0b", "a\xa0b"), + ("a b", "a b"), + ("a\x85b", "a\x85b"), + ("a\tb", "a\tb"), + ("a\xa0", "a\xa0"), + ("\xa0a", "\xa0a"), + ("a\x85", "a\x85"), + ("a\t", "a"), + ("a, ,b", "a, ,b"), + ("A-B, c_d", "A-B, c_d"), + ) + ), + # Copies of a header, read as `apollo_http.headers_seen` reads them. + *( + http_row("POST", "", h, _TYPENAME, "key", s, _JSON_UTF8, a, id=i) + for h, s, a, i in ( + ( + (*_J, ("Accept", "application/json"), ("Accept", "text/html")), + 200, + _TYPENAME_DATA, + "Accept json, html", + ), + ( + (*_J, ("Accept", "text/html"), ("Accept", "application/json")), + 200, + _TYPENAME_DATA, + "Accept html, json", + ), + ( + (*_J, ("Content-Type", "text/plain")), + 200, + _TYPENAME_DATA, + "Content-Type json, text/plain", + ), + ((("Content-Type", "text/plain"), *_J), 400, _CSRF, "Content-Type text/plain, json"), + ((*_J, ("Content-Type", "")), 200, _TYPENAME_DATA, "Content-Type json, empty"), + ((("Content-Type", ""), *_J), 415, _UNSUPPORTED, "Content-Type empty, json"), + ((*_J, *_J), 200, _TYPENAME_DATA, "Content-Type json twice"), + ( + (("Content-Type", "text/plain"), ("x-apollo-operation-name", " ")), + 400, + _CSRF, + "operation name a space", + ), + ) + ), + *( + http_row("GET", _Q, h, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id=i) + for h, i in ( + ((("apollo-require-preflight", ""), *_P), "preflight empty, 1"), + ((*_P, ("apollo-require-preflight", "")), "preflight 1, empty"), + ) + ), + # Each method under a JSON type, with no body and no length. + *( + http_row( + m, + t, + (("Content-Type", "application/json; charset=latin1"),), + None, + "key", + s, + ty, + a, + id=f"{m}, latin1, no body", + ) + for m, t, s, ty, a in ( + ("POST", "", 400, _JSON_UTF8, _EMPTY), + ("PUT", "", 400, _JSON_UTF8, _EMPTY), + ("PATCH", "", 400, _JSON_UTF8, _EMPTY), + ("DELETE", "", 400, _JSON_UTF8, _EMPTY), + ("OPTIONS", "", 400, "text/plain", b"Invalid Preflight Request"), + ("PROPFIND", "", 404, _JSON_UTF8, _not_found("PROPFIND", "")), + ( + "GET", + "", + 400, + _JSON_UTF8, + _apollo( + b"GraphQL operations must contain a non-empty `query` or a `persistedQuery` extension." + ), + ), + ("HEAD", "", 405, _JSON_UTF8, b""), + ) + ), + http_row("POST", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="no type, no body"), + http_row( + "POST", "", _P, None, "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, no body, preflight" + ), + http_row( + "POST", + "", + _P, + Chunked(_TYPENAME), + "key", + 415, + _JSON_UTF8, + _UNSUPPORTED, + id="no type, chunked, preflight", + ), + http_row( + "POST", + "", + _J, + Chunked(_TYPENAME), + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="JSON, chunked", + ), + http_row( + "GET", + "?query=%7B&query=%7B", + (), + None, + "key", + 400, + _JSON_UTF8, + _CSRF, + id="GET, unpreflighted, query twice", + ), + http_row( + "GET", + _Q + "&variables=%7B%7D&operationName=", + _P, + None, + "key", + 400, + _JSON_UTF8, + _apollo(b'Unknown operation named \\"\\".', b"OPERATION_RESOLUTION_FAILURE"), + id="GET, variables {}, operationName empty", + ), + *( + http_row( + m, + t, + (*h, ("Origin", "https://example.com")), + b, + "key", + s, + _JSON_UTF8, + a, + id=f"{m}, Origin", + ) + for m, t, h, b, s, a in ( + ("POST", "", _J, _TYPENAME, 200, _TYPENAME_DATA), + ("GET", _Q, _P, None, 200, _TYPENAME_DATA), + ("HEAD", _Q, _P, None, 405, b""), + ("PUT", "", _J, _TYPENAME, 404, _not_found("PUT", "")), + ) + ), +] +_FIREFLIES_CREDENTIALS = { + "none": None, + "Bearer nope": "Bearer nope", + "key": "Bearer {t}", + "twice": "Bearer {t}", +} + + +def _fireflies_body(r, answer): + """``r``'s body, or `_LANDING_PAGE` where ``answer`` is and ``r`` is Apollo Server's page: one + nonce of 64 hex digits, 3,522 bytes and its title.""" + if answer is not _LANDING_PAGE: + return r.content + page = r.text + nonces = set(re.findall(r"nonce-([0-9a-f]+)", page)) + whole = len(page.encode()) == 3522 and "Apollo Server" in page + return _LANDING_PAGE if len(nonces) == 1 and len(nonces.pop()) == 64 and whole else r.content + + +def test_fireflies_answers_each_method_type_and_accept_as_fireflies_does(client, tokens_yaml): + """Each row of `_FIREFLIES_HTTP` is answered as api.fireflies.ai answered it.""" + answered, expected = {}, {} + for row in _FIREFLIES_HTTP: + method, target, headers, body, credential, status, media_type, answer, named = row.values + sent = list(headers) + if _FIREFLIES_CREDENTIALS[credential] is not None: + value = _FIREFLIES_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) + sent += [("Authorization", value)] * (2 if credential == "twice" else 1) + content = body.chunks() if isinstance(body, Chunked) else body + r = sent_as(client, method, f"/fireflies/graphql{target}", sent, content=content, bare=True) + answered[row.id] = ( + r.status_code, + r.headers.get("content-type"), + _fireflies_body(r, answer), + {name: r.headers.get(name) for name in named}, + ) + expected[row.id] = (status, media_type, answer, named) + assert len(expected) == len(_FIREFLIES_HTTP) + assert answered == expected diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 6e357721a..50fb3098c 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -27,8 +27,8 @@ from starlette.testclient import TestClient from backlot import auth, pagination, store +from backlot.graphql import apollo_http, engine, mcp_tools, v8_json from backlot.graphql import body as request_body -from backlot.graphql import engine, mcp_tools, v8_json from tests._helpers import selected_fields SDL = """ @@ -2040,3 +2040,194 @@ def test_a_derived_document_validates_against_the_schema(tools): def test_a_tool_carries_its_fields_description(tools): assert "Loose notes, newest first." in tools["notes"].description + + +# --- the Node libraries `apollo_http` and `body` are read off ------------------------------------- +# Each value and what the library behind the port each test names answered for it under Node +# 24.18.0; the ports' docstrings name each library's release. +_ACCEPTED = [ + ("*/*", apollo_http.JSON, False), + ("application/*", apollo_http.JSON, False), + ("text/html", None, True), + ("", apollo_http.JSON, False), + ("application/json;q=0", None, False), + ("application/json;q", None, False), + ("application/json;q=abc", None, False), + ("application/json;q=1.5", apollo_http.JSON, False), + ("application/json; charset=utf-8", apollo_http.JSON, False), + ("application/json; charset=latin1", None, False), + ("APPLICATION/JSON", apollo_http.JSON, False), + ("application/json;Q=0", None, False), + ("application/json;q=0.5;charset=latin1", apollo_http.JSON, False), + ('application/json;x="a,b"', None, False), + ('"a,b", application/json', apollo_http.JSON, False), + ("text/html;q=0.5;charset=utf-8", None, True), + ("text/html;level=1", None, False), + (",", None, False), + ("foo", None, False), + ("application/json\xa0", apollo_http.JSON, False), + ("application/json\x85", None, False), + ("application/json;q=Infinity", apollo_http.JSON, False), + ("application/json;q=-0", None, False), + ("application/json; callbackSpec=1.0", apollo_http.CALLBACK, False), + ( + "application/graphql-response+json;q=0.5, application/json;q=0.5", + apollo_http.GRAPHQL_RESPONSE, + False, + ), + ("application/json, application/graphql-response+json", apollo_http.JSON, False), + ("application/json;q=0, */*", apollo_http.GRAPHQL_RESPONSE, False), + ("text/html, application/json", apollo_http.JSON, True), + ("text/*", None, True), + ("*/*;q=0.001, text/html;q=0.0005", apollo_http.JSON, False), + ("application/json;q= 0.5", apollo_http.JSON, False), + ("application/json;q=0.5x", apollo_http.JSON, False), + ("application/json ;q=0", None, False), + ("application/json;q =0", None, False), + ( + "application/json;charset=utf-8;q=0.1, application/json;q=0.9, application/graphql-response+json;q=0.5", + apollo_http.GRAPHQL_RESPONSE, + False, + ), + ("application/json;q=abc, application/json;q=0.5", apollo_http.JSON, False), + ('text/html;x=",application/json,"', None, False), + ('application/json;x="a;q=0"', None, False), + ('application/json;charset="utf-8"', apollo_http.JSON, False), + ('application/json;charset=autf-8"', None, False), + ('application/json;charset="utf-8a', None, False), + ("application/json;charset=*", apollo_http.JSON, False), + ("application/json;foo", apollo_http.JSON, False), + ("multipart/mixed; deferSpec=20220824, text/html;q=0.9", None, False), + ("multipart/mixed; incrementalSpec=v0.2, text/html;q=0.9", None, False), + ("multipart/mixed, text/html;q=0.9", None, False), + ("application/json;q=.5, application/graphql-response+json;q=0.4", apollo_http.JSON, False), + ( + "application/json;q=1e-1, application/graphql-response+json;q=0.4", + apollo_http.GRAPHQL_RESPONSE, + False, + ), + ("application/json;q=+0.1, application/graphql-response+json;q=0.05", apollo_http.JSON, False), +] + + +@pytest.mark.parametrize("accept,media_type,html", _ACCEPTED) +def test_apollo_server_reads_accept_as_negotiator_does(accept, media_type, html): + """Each row of `_ACCEPTED`: the media type negotiator picks for a single result, and whether it + puts `text/html` first for a GET.""" + assert apollo_http.response_type(accept) == media_type + assert apollo_http.prefers_html("GET", accept) is html + assert apollo_http.prefers_html("POST", accept) is False + + +_ESSENCES = [ + (" text/plain ", "text/plain"), + ("TEXT/PLAIN;x", "text/plain"), + ("text/plain ;", "text/plain"), + ("text/plain\t", "text/plain"), + ("text/plain\xa0", None), + ("text /plain", None), + ("text/ plain", None), + ("a/b/c", None), + ("/b", None), + ("a/", None), + ("", None), + ("text/plain, application/json", None), + ('text/plain;"', "text/plain"), + ("text/pl@in", None), + ("\ttext/plain;charset=utf-8", "text/plain"), + ("multipart/form-data; boundary=x", "multipart/form-data"), + ("application/x-www-form-urlencoded", "application/x-www-form-urlencoded"), + ("*/*", "*/*"), + ("text/plain\x0c", None), + ("te!xt/plain", "te!xt/plain"), +] + + +@pytest.mark.parametrize("content_type,essence", _ESSENCES) +def test_apollo_server_reads_a_content_type_as_whatwg_mimetype_does(content_type, essence): + assert apollo_http.mime_essence(content_type) == essence + + +_EXPRESS_JSON = [ + ("application/json", True), + ("Application/JSON", True), + (" application/json ", True), + ("application/json;", False), + ("application/json; foo", False), + ('application/json;x="a;b"', True), + ('application/json;x="a', False), + ("application/json; charset=utf-8; x=y", True), + ("application/json\xa0", True), + ("application/json\x85", False), + ("application/json\t;charset=utf-8", True), + ("application/json ;", False), + ("application/json+x", False), + ("", False), + ("application/json;charset=utf-8,", False), + ('application/json; x="\\""', True), + ("1application/json", False), +] + + +@pytest.mark.parametrize("content_type,read", _EXPRESS_JSON) +def test_body_parser_reads_json_where_type_is_does(content_type, read): + assert request_body.express_reads_json(content_type) is read + + +_FASTIFY_TYPES = [ + ("application/json", "application/json"), + ("application/json;", "application/json"), + ("application/json ,", None), + ("application/json\t;", "application/json"), + ("application/json x", None), + ("\xa0application/json", "application/json"), + ("application/json\xa0", "application/json"), + ("application/json\x85", None), + ("", None), + ("undefined", None), + ("Undefined", None), + ("a/b/c", None), + ("text /plain", None), + ("text/ plain", None), + ("TEXT/PLAIN;X=Y", "text/plain"), + ("application/json; x=y, text/plain", "application/json"), + ("/json", None), + ("application/", None), + ("app_lication/json", "app_lication/json"), + ("application/json@", None), + ("text/plain\x0b", "text/plain"), + ("t\xe9xt/plain", None), + ("application; charset=utf-8", None), + ("text/pl\xe1in;x=y", None), +] + + +@pytest.mark.parametrize("content_type,media_type", _FASTIFY_TYPES) +def test_fastify_reads_a_content_type_as_its_content_type_class_does(content_type, media_type): + assert request_body.fastify_media_type(content_type) == media_type + + +_FORMS = [ + ("query=%7B%20__typename%20%7D", {"query": "{ __typename }"}), + ("a&b=&=c&&d", {"a": "", "b": "", "": "c", "d": ""}), + ("a=1&a=2&a=3", {"a": ["1", "2", "3"]}), + ("a+b=c+d", {"a b": "c d"}), + ("a=%zz", {"a": "%zz"}), + ("a=%C3%A9", {"a": "\u00e9"}), + ("a=%C3", {"a": "%C3"}), + ("a=%E2%82", {"a": "%E2%82"}), + ("%zz=1", {"%zz": "1"}), + ("a==b", {"a": "=b"}), + ("a=%41%2", {"a": "%41%2"}), + ("x=%F0%9F%98%80", {"x": "\U0001f600"}), + ("__proto__=1", {"__proto__": "1"}), + ("a=1&b", {"a": "1", "b": ""}), + ("x=a%zz%41", {"x": "a%zz%41"}), + ("%41=1", {"A": "1"}), + ("x=%c3%a9", {"x": "\xe9"}), +] + + +@pytest.mark.parametrize("text,fields", _FORMS) +def test_a_form_is_read_as_fast_querystring_reads_it(text, fields): + assert request_body.form_fields(text) == fields diff --git a/tests/test_linear.py b/tests/test_linear.py index fd3ce5fa8..e6173c49a 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -26,12 +26,14 @@ from backlot.graphql.body import node_json from tests._helpers import ( SWEPT_BYTES, + Chunked, WithTheKey, build_corpus, client_for, complete, corpus_client, db_count, + http_row, padded_query, selected_field_count, selected_fields, @@ -4816,7 +4818,11 @@ def test_linear_team_membership_is_the_teams_group(tmp_path, ava_groups): for x in (synth.linear_id("nope"), "a\ud800b"): body = {"query": "query($x: String!) { teamMembership(id: $x) { id } }"} body["variables"] = {"x": x} - miss = client.post("/linear/graphql", content=json.dumps(body), headers=h) + miss = client.post( + "/linear/graphql", + content=json.dumps(body), + headers={**h, "Content-Type": "application/json"}, + ) assert miss.status_code == 200 assert miss.json()["errors"][0]["message"] == "Entity not found: TeamMembership" @@ -5927,3 +5933,810 @@ def got(result): backward = page("last: 3, before: " + lit(backward["pageInfo"]["endCursor"])) assert seen == forward[::-1] assert len(seen) == len(set(seen)) + + +# --- the load balancer, Express, cors and Apollo Server in front of Linear's GraphQL -------------- +# The request, the credential (`_LINEAR_CREDENTIALS`) and the answer api.linear.app gave it, as +# `graphql` records the layers it was measured through: its status, its media type and its body but +# for the newline Apollo Server ends one with, `_TYPENAME_DATA` where it served the query, and none +# for a `HEAD`. +_CSRF = ( + b'{"errors":[{"message":"This operation has been blocked as a potential Cross-Site Request' + b" Forgery (CSRF). Please either specify a 'content-type' header (with a type that is not one" + b" of application/x-www-form-urlencoded, multipart/form-data, text/plain) or provide a" + b" non-empty value for one of the following headers: x-apollo-operation-name," + b' apollo-require-preflight\\n","extensions":{"http":{"status":400,"headers":{}},"code":"BAD_R' + b'EQUEST","type":"graphql error","userError":true}}]}' +) +_ONLY_GET_POST = ( + b'{"errors":[{"message":"Apollo Server supports only GET/POST requests.","extensions":{"http":' + b'{"status":405,"headers":{}},"code":"BAD_REQUEST","type":"graphql error","userError":true}}]}' +) +_GET_MUTATION = ( + b'{"errors":[{"message":"GET requests only support query operations, not mutation operations"' + b',"extensions":{"http":{"status":405,"headers":{}},"code":"BAD_REQUEST","type":"graphql erro' + b'r","userError":true}}]}' +) +_NOT_ACCEPTABLE = ( + b'{"errors":[{"message":"An \'accept\' header was provided for this request which does not' + b" accept application/json; charset=utf-8 or application/graphql-response+json; charset=utf-8" + b'","extensions":{"http":{"status":406},"code":"BAD_REQUEST","type":"graphql error","userErr' + b'or":true}}]}' +) +_NO_QUERY = ( + b'{"errors":[{"message":"GraphQL operations must contain a non-empty `query` or a `persistedQ' + b'uery` extension.","extensions":{"http":{"status":400,"headers":{}},"code":"INVALID_INPUT","' + b'type":"invalid input","userError":true}}]}' +) +_TYPENAME_DATA = b'{"data":{"__typename":"Query"}}' +_JSON_UTF8 = "application/json; charset=utf-8" +_GRAPHQL_RESPONSE = "application/graphql-response+json; charset=utf-8" +_CLOUDFLARE_PAGE = ( + b"\r\n%s\r\n\r\n

%s

\r\n" + b"
cloudflare
\r\n\r\n\r\n" +) +_LINEAR_PREFLIGHT = { + "access-control-allow-origin": "https://example.com", + "cache-control": "s-maxage=43200", + "vary": "Origin, Access-Control-Request-Headers", + "cache-tag": "preflight", + "access-control-allow-methods": "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", + "access-control-expose-headers": "Cache-Control,Content-Language,Content-Length,Content-Type," + "Expires,Last-Modified,Pragma,x-request-id,cf-ray,cf-cache-status,Retry-After", + "access-control-max-age": "86400", + # Sent no `Access-Control-Request-Headers`, a preflight is answered none. + "access-control-allow-headers": None, +} +_J = (("Content-Type", "application/json"),) +_P = (("apollo-require-preflight", "1"),) +_ORIGIN = (("Origin", "https://example.com"),) +_PREFLIGHT_ASKED = (*_ORIGIN, ("Access-Control-Request-Method", "POST")) +_Q = "?query=%7B%20__typename%20%7D" + + +def _search_refusal(message: bytes) -> bytes: + return ( + b'{"errors":[{"message":"%s","extensions":{"http":{"status":400,"headers":{}},"code":"BAD_REQ' + b'UEST","type":"graphql error","userError":true}}]}' % message + ) + + +_LINEAR_HTTP = [ + # Cloudflare, then Linear's load balancer, ahead of everything; then Apollo Server's 405 for + # each other method. + http_row( + "TRACE", + "", + (), + None, + "key", + 405, + "text/html", + _CLOUDFLARE_PAGE % ((b"405 Not Allowed",) * 2), + id="TRACE", + answered={"connection": "close"}, + ), + http_row( + "CONNECT", + "", + (), + None, + "key", + 400, + "text/plain; charset=UTF-8", + b"error code: 1045\n", + id="CONNECT", + answered={"connection": None}, + ), + http_row( + "PURGE", + "", + (), + None, + "key", + 400, + "text/html; charset=UTF-8", + _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), + id="PURGE", + answered={"connection": None}, + ), + http_row( + "TRACE", + "", + (), + None, + "twice", + 400, + "text/html", + _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), + id="TRACE, Authorization twice", + answered={"connection": "close"}, + ), + *( + http_row(m, "", (*_J, *_P), None, "key", 501, None, b"", id=m) + for m in ("BIND", "LINK", "M-SEARCH", "MKCALENDAR", "REBIND", "SOURCE", "UNBIND", "UNLINK") + ), + *( + http_row(m, "", (*_J, *_P), None, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id=m) + for m in ( + "ACL", + "CHECKOUT", + "COPY", + "DELETE", + "LOCK", + "MERGE", + "MKACTIVITY", + "MKCOL", + "MOVE", + "NOTIFY", + "OPTIONS", + "PATCH", + "PROPFIND", + "PROPPATCH", + "QUERY", + "REPORT", + "SEARCH", + "SUBSCRIBE", + "UNLOCK", + "UNSUBSCRIBE", + ) # fmt: skip + ), + # `cors`, whatever the credential; Apollo Server's CSRF check and 405 without `Origin`. + *( + http_row( + "OPTIONS", + "", + _PREFLIGHT_ASKED, + None, + c, + 204, + None, + b"", + id=f"preflight, {c}", + answered=_LINEAR_PREFLIGHT, + ) + for c in ("key", "none", "Bearer key") + ), + http_row( + "OPTIONS", + "", + _ORIGIN, + None, + "nope", + 204, + None, + b"", + id="Origin alone", + answered=_LINEAR_PREFLIGHT, + ), + http_row( + "OPTIONS", + "", + (("Origin", "null"), ("Access-Control-Request-Method", "POST")), + None, + "key", + 204, + None, + b"", + id="Origin null", + answered={**_LINEAR_PREFLIGHT, "access-control-allow-origin": "null"}, + ), + http_row( + "OPTIONS", + "", + (*_ORIGIN, ("Access-Control-Request-Method", "PUT")), + None, + "key", + 204, + None, + b"", + id="asked PUT", + answered=_LINEAR_PREFLIGHT, + ), + *( + http_row( + "OPTIONS", + "", + (*_PREFLIGHT_ASKED, ("Access-Control-Request-Headers", asked)), + None, + "key", + 204, + None, + b"", + id=f"asked {asked!r}", + answered={**_LINEAR_PREFLIGHT, "access-control-allow-headers": allowed}, + ) + for asked, allowed in ( + ("Content-Type, X-Foo", "Content-Type,X-Foo"), + (" a , b ", "a,b"), + ("a,,b", "a,,b"), + ) + ), + http_row("OPTIONS", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="OPTIONS, unpreflighted"), + *( + http_row( + "OPTIONS", + "", + (("Origin", origin), ("Access-Control-Request-Method", "POST")), + None, + c, + 400, + _JSON_UTF8, + _CSRF, + id=f"Origin {origin!r}, {c}", + ) + for origin in ("", " ") + for c in ("key", "none") + ), + http_row( + "OPTIONS", + "", + (("Origin", ""), *_J), + None, + "key", + 405, + _JSON_UTF8, + _ONLY_GET_POST, + id="Origin '', JSON", + ), + http_row( + "OPTIONS", "", (), None, "Bearer key", 400, _JSON_UTF8, _CSRF, id="OPTIONS, Bearer key" + ), + http_row( + "OPTIONS", + "", + (("Access-Control-Request-Method", "POST"),), + None, + "key", + 400, + _JSON_UTF8, + _CSRF, + id="OPTIONS, no Origin", + ), + http_row("OPTIONS", "", _J, None, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id="OPTIONS, JSON"), + # `_api_key_as_bearer` ahead of `body-parser`. + *( + http_row(m, t, h, b, "Bearer key", 400, _JSON_UTF8, a, id=f"{m}, Bearer key") + for m, t, h, b, a in ( + ("GET", _Q, (), None, _API_KEY_AS_BEARER), + ("PUT", "", _J, b"{", _API_KEY_AS_BEARER), + ("HEAD", _Q, _P, None, b""), + ("PATCH", "", _J, None, _API_KEY_AS_BEARER), + ) + ), + # `body-parser` on a JSON body (`_has_body`), then Apollo Server's CSRF check, then its 405. + http_row("PUT", "", (*_J, *_P), b"{", "key", 400, _JSON_UTF8, _EXPECTED_PROPERTY, id="PUT {"), + http_row( + "OPTIONS", "", (*_J, *_P), b"{", "key", 400, _JSON_UTF8, _EXPECTED_PROPERTY, id="OPTIONS {" + ), + http_row( + "PUT", "", (*_J, *_P), _TYPENAME, "key", 405, _JSON_UTF8, _ONLY_GET_POST, id="PUT, a query" + ), + http_row( + "DELETE", "", _J, _TYPENAME, "none", 405, _JSON_UTF8, _ONLY_GET_POST, id="DELETE, a query" + ), + http_row( + "PUT", + "", + (("Content-Type", "text/plain"),), + _TYPENAME, + "key", + 400, + _JSON_UTF8, + _CSRF, + id="PUT, text/plain", + ), + http_row("DELETE", "", (), None, "nope", 400, _JSON_UTF8, _CSRF, id="DELETE, unpreflighted"), + # A POST's content type: read as JSON, refused as a request a browser sends unpreflighted, or + # passed with nothing read. + *( + http_row( + "POST", + "", + (("Content-Type", t), *h), + _TYPENAME, + "key", + s, + _JSON_UTF8, + a, + id=f"{t!r}{i}", + ) + for t, h, s, a, i in ( + ("text/plain", (), 400, _CSRF, ""), + ("text/plain", _P, 400, _NO_KEYS, ", preflight"), + ("TEXT/PLAIN;X=Y", (), 400, _CSRF, ""), + ("application/x-www-form-urlencoded", (), 400, _CSRF, ""), + ("multipart/form-data; boundary=x", (), 400, _CSRF, ""), + ("", (), 400, _CSRF, ""), + ("foo", (), 400, _NO_KEYS, ""), + ("text/plain, application/json", (), 400, _NO_KEYS, ""), + ("application/graphql", (), 400, _NO_KEYS, ""), + ("application/json;", (), 400, _NO_KEYS, ""), + ("application/json; x=y, text/plain", (), 400, _NO_KEYS, ""), + ("application/json\t;", (), 400, _NO_KEYS, ""), + ("Application/JSON", (), 200, _TYPENAME_DATA, ""), + ("application/json ;charset=utf-8", (), 200, _TYPENAME_DATA, ""), + ('application/json;x="a;b"', (), 200, _TYPENAME_DATA, ""), + ) + ), + http_row("POST", "", (), _TYPENAME, "key", 400, _JSON_UTF8, _CSRF, id="no type"), + http_row("POST", "", _P, _TYPENAME, "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, preflight"), + http_row("POST", "", _J, b"", "key", 400, _JSON_UTF8, _NO_KEYS, id="JSON, empty"), + # A GET: its query string, Apollo Server's 405 for one that is not a query, ahead of the + # credential. + http_row("GET", _Q, _P, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET"), + http_row("GET", _Q, (), None, "key", 400, _JSON_UTF8, _CSRF, id="GET, unpreflighted"), + http_row("GET", _Q, _J, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id="GET, JSON"), + http_row( + "GET", + _Q, + (("Content-Type", "text/plain"), *_P), + None, + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="GET, text/plain", + ), + http_row( + "GET", + _Q, + (("Content-Type", "foo"),), + None, + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="GET, foo", + ), + http_row("GET", _Q, _P, None, "none", 401, _JSON_UTF8, _UNAUTHENTICATED, id="GET, none"), + http_row( + "GET", + "?query=%7B+__typename+%7D", + _P, + None, + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="GET, +", + ), + http_row("GET", "", _P, None, "key", 400, _JSON_UTF8, _NO_QUERY, id="GET, no query"), + http_row( + "GET", "?QUERY" + _Q[6:], _P, None, "key", 400, _JSON_UTF8, _NO_QUERY, id="GET, QUERY" + ), + *( + http_row("GET", t, _P, None, "key", 400, _JSON_UTF8, _search_refusal(m), id=i) + for t, m, i in ( + ( + _Q + "&query=", + b"The 'query' search parameter may only be specified once.", + "query twice", + ), + ( + _Q + "&operationName=A&operationName=B", + b"The 'operationName' search parameter may only be specified once.", + "operationName twice", + ), + ( + _Q + "&variables=nope", + b"The variables search parameter contains invalid JSON.", + "variables nope", + ), + ( + _Q + "&variables=", + b"The variables search parameter contains invalid JSON.", + "variables empty", + ), + ( + _Q + "&variables=%5B1%5D", + b"The variables search parameter should contain a JSON-encoded object.", + "variables [1]", + ), + ( + _Q + "&variables=null", + b"The variables search parameter should contain a JSON-encoded object.", + "variables null", + ), + ( + _Q + "&extensions=5", + b"The extensions search parameter should contain a JSON-encoded object.", + "extensions 5", + ), + ( + "?query=%7B&query=%7B&variables=nope", + b"The 'query' search parameter may only be specified once.", + "query twice, variables nope", + ), + ( + "?operationName=A&operationName=B&query=%7B&query=%7B", + b"The 'query' search parameter may only be specified once.", + "operationName twice, query twice", + ), + ( + "?operationName=A&operationName=B&variables=nope", + b"The 'operationName' search parameter may only be specified once.", + "operationName twice, variables nope", + ), + ( + "?variables=nope&extensions=5", + b"The variables search parameter contains invalid JSON.", + "variables nope, extensions 5", + ), + ) + ), + *( + http_row( + "GET", + "?query=mutation%20%7B%20__typename%20%7D", + _P, + None, + c, + 405, + _JSON_UTF8, + _GET_MUTATION, + id=f"GET mutation, {c}", + answered={"allow": "POST"}, + ) + for c in ("key", "none") + ), + http_row( + "GET", + "?query=query%20A%20%7B%20__typename%20%7D%20mutation%20B%20%7B%20__typename%20%7D" + "&operationName=A", + _P, + None, + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="GET, a query beside a mutation", + ), + http_row( + "HEAD", + _Q, + _P, + None, + "key", + 405, + _JSON_UTF8, + b"", + id="HEAD", + answered={"allow": "GET, POST"}, + ), + http_row( + "HEAD", + _Q, + (("Content-Type", "text/plain"),), + None, + "key", + 400, + _JSON_UTF8, + b"", + id="HEAD, text/plain", + ), + # `apollo_http.response_type` and its 406, the 401 among the answers it replaces. + *( + http_row("POST", "", (*_J, ("Accept", a)), _TYPENAME, "key", s, t, b, id=f"Accept {a!r}") + for a, s, t, b in ( + ("text/html", 406, _JSON_UTF8, _NOT_ACCEPTABLE), + ("application/json;q=0", 406, _JSON_UTF8, _NOT_ACCEPTABLE), + ("application/json;q", 406, _JSON_UTF8, _NOT_ACCEPTABLE), + ("application/json; charset=latin1", 406, _JSON_UTF8, _NOT_ACCEPTABLE), + ("", 200, _JSON_UTF8, _TYPENAME_DATA), + ("*/*", 200, _JSON_UTF8, _TYPENAME_DATA), + ("application/graphql-response+json", 200, _GRAPHQL_RESPONSE, _TYPENAME_DATA), + ("application/json;q=0, */*", 200, _GRAPHQL_RESPONSE, _TYPENAME_DATA), + ("text/html;q=0.9, */*;q=0.1", 200, _JSON_UTF8, _TYPENAME_DATA), + ( + "application/json; callbackSpec=1.0", + 200, + "application/json; callbackspec=1.0; charset=utf-8", + _TYPENAME_DATA, + ), + ) + ), + *( + http_row("POST", "", (*_J, ("Accept", a)), b, c, s, t, e, id=f"Accept {a!r}, {i}") + for a, b, c, s, t, e, i in ( + ("text/html", b"{}", "key", 400, _JSON_UTF8, _NO_KEYS, "{}"), + ( + "application/graphql-response+json", + b"{}", + "key", + 400, + _GRAPHQL_RESPONSE, + _NO_KEYS, + "{}", + ), + ("text/html", b"[]", "key", 400, _JSON_UTF8, _BATCHING, "[]"), + ( + "text/html", + b'{"query":"{"}', + "key", + 406, + _JSON_UTF8, + _NOT_ACCEPTABLE, + "a parse error", + ), + ("text/html", _TYPENAME, "none", 406, _JSON_UTF8, _NOT_ACCEPTABLE, "none"), + ( + "application/graphql-response+json", + _TYPENAME, + "none", + 401, + _GRAPHQL_RESPONSE, + _UNAUTHENTICATED, + "none", + ), + ("text/html", b"{", "key", 400, _JSON_UTF8, _EXPECTED_PROPERTY, "{"), + ) + ), + http_row( + "GET", + _Q, + (*_P, ("Accept", "text/html")), + None, + "key", + 406, + _JSON_UTF8, + _NOT_ACCEPTABLE, + id="GET, Accept text/html", + ), + http_row( + "GET", + "?query=mutation%20%7B%20__typename%20%7D", + (*_P, ("Accept", "text/html;level=1")), + None, + "key", + 406, + _JSON_UTF8, + _NOT_ACCEPTABLE, + id="GET mutation, Accept text/html;level=1", + ), + http_row( + "POST", + "", + (("Content-Type", "text/plain"), ("Accept", "application/graphql-response+json")), + _TYPENAME, + "key", + 400, + _GRAPHQL_RESPONSE, + _CSRF, + id="text/plain, Accept graphql-response", + ), + http_row( + "PUT", + "", + (*_J, ("Accept", "application/graphql-response+json")), + _TYPENAME, + "key", + 405, + _GRAPHQL_RESPONSE, + _ONLY_GET_POST, + id="PUT, Accept graphql-response", + ), + http_row( + "GET", + _Q, + (*_P, ("Accept", "text/html, application/graphql-response+json")), + None, + "key", + 200, + _GRAPHQL_RESPONSE, + _TYPENAME_DATA, + id="GET, Accept text/html then graphql-response", + ), + # `cors` lets any other method carrying `Origin` through; an API key under `Bearer` on DELETE. + *( + http_row(m, t, (*h, *_ORIGIN), b, "key", s, _JSON_UTF8, a, id=f"{m}, Origin") + for m, t, h, b, s, a in ( + ("POST", "", _J, _TYPENAME, 200, _TYPENAME_DATA), + ("GET", _Q, _P, None, 200, _TYPENAME_DATA), + ("HEAD", _Q, _P, None, 405, b""), + ("PUT", "", _J, _TYPENAME, 405, _ONLY_GET_POST), + ("PATCH", "", _J, _TYPENAME, 405, _ONLY_GET_POST), + ("DELETE", "", _J, _TYPENAME, 405, _ONLY_GET_POST), + ) + ), + http_row( + "DELETE", + "", + (), + None, + "Bearer key", + 400, + _JSON_UTF8, + _API_KEY_AS_BEARER, + id="DELETE, Bearer key", + ), + http_row( + "OPTIONS", + "", + ( + *_PREFLIGHT_ASKED, + ("Access-Control-Request-Headers", "a"), + ("Access-Control-Request-Headers", "b"), + ), + None, + "key", + 204, + None, + b"", + id="asked twice", + answered={**_LINEAR_PREFLIGHT, "access-control-allow-headers": "a,b"}, + ), + *( + http_row( + "OPTIONS", + "", + (*_PREFLIGHT_ASKED, ("Access-Control-Request-Headers", asked)), + None, + "key", + 204, + None, + b"", + id=f"asked {asked!r}", + answered={**_LINEAR_PREFLIGHT, "access-control-allow-headers": allowed}, + ) + for asked, allowed in ( + ("a, ,b", "a,,b"), + ("A-B, c_d", "A-B,c_d"), + ("a\t", "a"), + ("a b", "a b"), + ) + ), + http_row( + "OPTIONS", + "", + (("Origin", " https://example.com\xa0"), ("Access-Control-Request-Method", "POST")), + None, + "key", + 204, + None, + b"", + id="Origin with spaces", + answered={**_LINEAR_PREFLIGHT, "access-control-allow-origin": "https://example.com\xa0"}, + ), + # Copies of a header: `Content-Type` as `backlot.graphql.body.joined_header` joins them, the + # rest as `apollo_http.headers_seen` reads them. + *( + http_row("POST", "", h, _TYPENAME, "key", s, _JSON_UTF8, a, id=i) + for h, s, a, i in ( + ( + (*_J, ("Accept", "application/json"), ("Accept", "text/html")), + 200, + _TYPENAME_DATA, + "Accept json, html", + ), + ( + (*_J, ("Accept", "text/html"), ("Accept", "application/json")), + 200, + _TYPENAME_DATA, + "Accept html, json", + ), + ((*_J, ("Content-Type", "text/plain")), 400, _NO_KEYS, "Content-Type json, text/plain"), + ((("Content-Type", "text/plain"), *_J), 400, _NO_KEYS, "Content-Type text/plain, json"), + ((*_J, ("Content-Type", "")), 400, _NO_KEYS, "Content-Type json, empty"), + ((("Content-Type", ""), *_J), 200, _TYPENAME_DATA, "Content-Type empty, json"), + ((*_J, *_J), 400, _NO_KEYS, "Content-Type json twice"), + ( + (("Content-Type", "text/plain"), ("x-apollo-operation-name", " ")), + 400, + _CSRF, + "operation name a space", + ), + ) + ), + *( + http_row("GET", _Q, h, None, "key", 200, _JSON_UTF8, _TYPENAME_DATA, id=i) + for h, i in ( + ((("apollo-require-preflight", ""), *_P), "preflight empty, 1"), + ((*_P, ("apollo-require-preflight", "")), "preflight 1, empty"), + ) + ), + # No body and no length under a JSON type, by method (`_has_body`). + *( + http_row( + m, + t, + (("Content-Type", "application/json; charset=latin1"), *h), + None, + "key", + s, + _JSON_UTF8, + a, + id=f"{m}, latin1, no body", + ) + for m, t, h, s, a in ( + ("POST", "", (), 415, _LATIN1), + ("PUT", "", (), 415, _LATIN1), + ("PATCH", "", (), 415, _LATIN1), + ("DELETE", "", (), 415, _LATIN1), + ("OPTIONS", "", (), 415, _LATIN1), + ("PROPFIND", "", (), 415, _LATIN1), + ("GET", "", (), 400, _NO_QUERY), + ("HEAD", "", (), 405, b""), + ) + ), + http_row("POST", "", _J, None, "key", 400, _JSON_UTF8, _NO_KEYS, id="JSON, no body"), + http_row("POST", "", (), None, "key", 400, _JSON_UTF8, _CSRF, id="no type, no body"), + http_row( + "POST", "", _P, None, "key", 400, _JSON_UTF8, _NO_KEYS, id="no type, no body, preflight" + ), + http_row( + "POST", + "", + _P, + Chunked(_TYPENAME), + "key", + 400, + _JSON_UTF8, + _NO_KEYS, + id="no type, chunked, preflight", + ), + http_row( + "POST", + "", + _J, + Chunked(_TYPENAME), + "key", + 200, + _JSON_UTF8, + _TYPENAME_DATA, + id="JSON, chunked", + ), + http_row( + "GET", + "?query=%7B&query=%7B", + (), + None, + "key", + 400, + _JSON_UTF8, + _CSRF, + id="GET, unpreflighted, query twice", + ), + http_row( + "GET", + _Q + "&variables=%7B%7D&operationName=", + _P, + None, + "key", + 400, + _JSON_UTF8, + b'{"errors":[{"message":"operation does not exist","extensions":{"type":"invalid input","code":"INPUT_ERROR",' + b'"statusCode":400,"userError":true,"userPresentableMessage":"The operation does not exist on the query.",' + b'"http":{"status":400}}}]}', + id="GET, variables {}, operationName empty", + ), +] +_LINEAR_CREDENTIALS = { + "none": None, + "nope": "nope", + "Bearer key": "Bearer {t}", + "key": "{t}", + "twice": "{t}", +} + + +def test_linear_answers_each_method_type_and_accept_as_linear_does(client, tokens_yaml): + """Each row of `_LINEAR_HTTP` is answered as api.linear.app answered it.""" + answered, expected = {}, {} + for row in _LINEAR_HTTP: + method, target, headers, body, credential, status, media_type, answer, named = row.values + sent = list(headers) + if _LINEAR_CREDENTIALS[credential] is not None: + value = _LINEAR_CREDENTIALS[credential].format(t=tokens_yaml["admin_token"]) + sent += [("Authorization", value)] * (2 if credential == "twice" else 1) + content = body.chunks() if isinstance(body, Chunked) else body + r = sent_as(client, method, f"/linear/graphql{target}", sent, content=content, bare=True) + answered[row.id] = ( + r.status_code, + r.headers.get("content-type"), + r.content, + {name: r.headers.get(name) for name in named}, + ) + expected[row.id] = (status, media_type, answer, named) + assert len(expected) == len(_LINEAR_HTTP) + assert answered == expected diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index dbdda9abb..1b1b2dff0 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -34,6 +34,8 @@ ADMIN = {"Authorization": "Bearer admin-service-token"} LINEAR = {"Authorization": "admin-service-token"} JSON = {"Content-Type": "application/json"} +_PREFLIGHT = {"apollo-require-preflight": "1"} +_TYPENAME_SEARCH = "?query=%7B%20__typename%20%7D" UNREACHABLE = "http://127.0.0.1:9/v1/metrics" # The values an export may carry. The collector replaces a value outside its own shape checks with # `invalid`, and every pattern here is narrower than those. @@ -269,6 +271,8 @@ def test_each_client_rule_names_the_headers_it_was_measured_from(): ("fireflies", "unknown", 500, "INTERNAL_SERVER_ERROR", False, "client"), ("linear", "unknown", 500, "INTERNAL_SERVER_ERROR", False, "internal"), ("fireflies", "unknown", 500, "", False, "internal"), + ("fireflies", "unknown", 500, "auth_failed", False, "auth"), + ("linear", "unknown", 500, "auth_failed", False, "internal"), ], ) def test_a_failure_is_classed_by_its_answer(source, support, status, code, spent, expected): @@ -603,6 +607,65 @@ def test_an_error_code_is_the_envelopes_own_identifier(source, status, body, exp {"headers": LINEAR | JSON, "content": b"{"}, ("linear", "unparsed", "unknown", 400, None, "client"), ), + # The HTTP layer in front of each GraphQL server answers each method and `Accept`, a GET's + # query among them, and Fireflies' credential's 500 is a refusal of the credential. + ( + "GET", + "/fireflies/graphql" + _TYPENAME_SEARCH, + {"headers": _PREFLIGHT}, + ("fireflies", "query.__typename", "extra", 200, None, None), + ), + ( + "GET", + "/fireflies/graphql" + _TYPENAME_SEARCH, + {"headers": _PREFLIGHT | {"Authorization": "Bearer nope"}}, + ("fireflies", "unparsed", "unknown", 500, "auth_failed", "auth"), + ), + ( + "PROPFIND", + "/fireflies/graphql", + {"headers": JSON}, + ("fireflies", "unparsed", "unknown", 404, "not_found", "client"), + ), + ( + "OPTIONS", + "/fireflies/graphql", + {}, + ("fireflies", "unparsed", "unknown", 400, None, "client"), + ), + ( + "GET", + "/linear/graphql" + _TYPENAME_SEARCH, + {"headers": LINEAR | _PREFLIGHT}, + ("linear", "query.__typename", "extra", 200, None, None), + ), + ( + "PUT", + "/linear/graphql", + {"headers": LINEAR | JSON, "json": {"query": "{ __typename }"}}, + ("linear", "unparsed", "unknown", 405, "BAD_REQUEST", "client"), + ), + ( + "POST", + "/linear/graphql", + { + "headers": LINEAR | JSON | {"Accept": "text/html"}, + "json": {"query": "{ __typename }"}, + }, + ("linear", "query.__typename", "extra", 406, "BAD_REQUEST", "client"), + ), + ( + "BIND", + "/linear/graphql", + {"headers": LINEAR}, + ("linear", "unparsed", "unknown", 501, None, "client"), + ), + ( + "TRACE", + "/linear/graphql", + {"headers": LINEAR}, + ("linear", "unparsed", "unknown", 405, None, "client"), + ), ( "GET", "/calendar/v3/calendars/primary/events", From 57041cc5aa5df037e8d394c6b1ecda2a1d51062a Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:47:21 +0900 Subject: [PATCH 51/52] linear, fireflies: a cut escape, a `..` past the root and each dot segment answered as each front answers them Cloudflare answers a path the end cuts short in a `%` (`/a%2`, `/a%`) with its other 400 (charset, no-store headers, the connection kept, Linear's in chunks), behind its TRACE, CONNECT and PURGE answers and a raw byte's, and refuses a `..` that climbs past the root with a segment after it with its page, ahead of them. Linear's front resolves the dot segments as a URL's path is resolved before it folds slashes, and nginx in front of Fastify resolves them after, answering a `..` past the root with its 400. Review of #1058 by khj809. --- backlot/main.py | 83 +++++++++++++++++++++------------ backlot/request_target.py | 77 +++++++++++++++++++++++++++---- backlot/routers/fireflies.py | 58 ++++++++++++++++------- backlot/routers/linear.py | 55 +++++++++++----------- docs/supported-sources.md | 17 ++++--- tests/_helpers.py | 31 +++++++++++++ tests/test_cross_vendor.py | 88 ++++++++++++++++++++++++++--------- tests/test_fireflies.py | 72 +++++++++++++++++++++++++++++ tests/test_linear.py | 89 +++++++++++++++++++++++++++--------- tests/test_request_target.py | 58 +++++++++-------------- tests/test_telemetry.py | 18 ++++++++ 11 files changed, 475 insertions(+), 171 deletions(-) diff --git a/backlot/main.py b/backlot/main.py index 8d5938aa3..e65d3b76e 100644 --- a/backlot/main.py +++ b/backlot/main.py @@ -20,7 +20,7 @@ from fastapi import FastAPI, HTTPException, Request, Response from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse, PlainTextResponse +from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse from starlette.datastructures import URL, Headers from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.base import BaseHTTPMiddleware, DispatchFunction @@ -1344,6 +1344,16 @@ async def warn_slack_post_charset(request: Request, call_next): ) +#: The headers Cloudflare puts beside the `Content-Type` of its answers to a `CONNECT`, a `PURGE` +#: and a cut escape in front of Linear and Fireflies. +_CLOUDFLARE_PRIVATE = { + "cache-control": "private, max-age=0, no-store, no-cache, must-revalidate, " + "post-check=0, pre-check=0", + "expires": "Thu, 01 Jan 1970 00:00:01 GMT", + "referrer-policy": "same-origin", + "x-frame-options": "SAMEORIGIN", +} + #: Cloudflare's answer to each method it refuses in front of Linear and Fireflies, as #: api.linear.app and api.fireflies.ai answered each on `/graphql`, `/graphql/`, `/nosuch` and `/` #: on 2026-10-25 (UTC): a page with CRLF line ends, or a line of text, and the headers it puts on @@ -1360,30 +1370,44 @@ async def warn_slack_post_charset(request: Request, call_next): "CONNECT": ( 400, b"error code: 1045\n", - { - "content-type": "text/plain; charset=UTF-8", - "cache-control": "private, max-age=0, no-store, no-cache, must-revalidate, " - "post-check=0, pre-check=0", - "expires": "Thu, 01 Jan 1970 00:00:01 GMT", - "referrer-policy": "same-origin", - "x-frame-options": "SAMEORIGIN", - }, + {"content-type": "text/plain; charset=UTF-8", **_CLOUDFLARE_PRIVATE}, ), "PURGE": ( 400, request_target.CLOUDFLARE_PAGE, - { - "content-type": "text/html; charset=UTF-8", - "x-frame-options": "SAMEORIGIN", - "cache-control": "private, max-age=0, no-store, no-cache, must-revalidate, " - "post-check=0, pre-check=0", - "expires": "Thu, 01 Jan 1970 00:00:01 GMT", - "referrer-policy": "same-origin", - }, + {"content-type": "text/html; charset=UTF-8", **_CLOUDFLARE_PRIVATE}, ), } +def _sent(scope: Scope) -> tuple[bytes, bytes | None]: + """The vendor's path as sent and the query string, ``None`` where it is empty: the scope does + not keep a `?` with nothing after it.""" + return sent_path(scope).encode("latin-1"), scope["query_string"] or None + + +@_middleware( + lambda scope: ( + _at_or_under(_url_path(scope), ("/linear", "/fireflies")) + and request_target.cut_escape(*_sent(scope)) + ) +) +async def refuse_a_cut_escape_on_linear_and_fireflies(request: Request, call_next): + """Answer a `/linear` or `/fireflies` request whose path the end cuts short in a `%` + (``request_target.cut_escape``) with Cloudflare's 400, ahead of the route and the credential + and behind :func:`refuse_cloudflares_methods_on_linear_and_fireflies`. api.linear.app and + api.fireflies.ai answered so `/a%2`, `/a%`, `/a%0`, `/a%F`, `/%`, `/nosucha%b`, `/a%2b%2`, + `/graphql%2` and `/graphql/%2` by `POST`, and `/a%2` by `GET`, `HEAD`, `OPTIONS` and `DELETE`, + on 2026-10-29 (UTC), and answered a second request on the connection after it. `TRACE`, + `CONNECT` and `PURGE` at `/a%2` drew their own answer, and so did `Authorization` or + `If-None-Match` sent twice. Linear's answer came in chunks and Fireflies' with a length.""" + page = request_target.CLOUDFLARE_PAGE + headers = {"content-type": "text/html; charset=UTF-8", **_CLOUDFLARE_PRIVATE} + if _url_path(request.scope).startswith("/linear"): + return StreamingResponse(iter([page]), status_code=400, headers=headers) + return Response(page, status_code=400, headers=headers) + + @_middleware( lambda scope: ( scope["method"] in _CLOUDFLARE_METHOD @@ -1460,23 +1484,22 @@ async def refuse_a_repeated_header_on_notion_linear_fireflies_and_hubspot( ) -def _a_broken_escape(scope: Scope) -> bool: - return request_target.broken_escape(scope.get("raw_path") or scope["path"].encode()) - - @_middleware( lambda scope: ( - _at_or_under(_url_path(scope), ("/linear", "/fireflies")) and _a_broken_escape(scope) + _at_or_under(_url_path(scope), ("/linear", "/fireflies")) + and request_target.refused_path(*_sent(scope)) ) ) -async def refuse_a_broken_escape_on_linear_and_fireflies(request: Request, call_next): - """Answer a `/linear` or `/fireflies` request whose path holds `%00`, or a `%` two hex digits do - not follow, with :data:`backlot.request_target.CLOUDFLARE_PAGE`'s 400, ahead of - :func:`refuse_cloudflares_methods_on_linear_and_fireflies`. api.linear.app and api.fireflies.ai - answered so `/a%zz` and `/a%00b` by each method of :data:`_CLOUDFLARE_METHOD`, and `/a%2`, - `/graphql%zz` and a `%` before `b` by `POST`, on 2026-10-25 (UTC), and passed `?a=%zz`, `?a=%`, - `?a=%2` and `?a=%00` after `/graphql` on to the vendor. Both closed the connection after the 400 - (`POST /graphql%zz`, `POST /a%00b`, `GET /a%2`, 2026-10-26).""" +async def refuse_a_path_cloudflare_refuses_on_linear_and_fireflies(request: Request, call_next): + """Answer a `/linear` or `/fireflies` request whose path Cloudflare refuses + (``request_target.refused_path``) with :data:`backlot.request_target.CLOUDFLARE_PAGE`'s 400, + ahead of :func:`refuse_cloudflares_methods_on_linear_and_fireflies`. api.linear.app and + api.fireflies.ai answered so `/a%zz` and `/a%00b` by each method of :data:`_CLOUDFLARE_METHOD`, + and `/graphql%zz` by `POST`, on 2026-10-25 (UTC), and `/../graphql` by `POST`, `GET` and each + method of :data:`_CLOUDFLARE_METHOD`, and `/a%2?x=1`, `/a%2/` and `/a%z` by `POST`, on + 2026-10-29, and passed `?a=%zz`, `?a=%`, `?a=%2` and `?a=%00` after `/graphql` on to the + vendor. Both closed the connection after the 400 (`POST /graphql%zz`, `POST /a%00b`, + 2026-10-26; `POST /../graphql`, `POST /a%2/`, 2026-10-29).""" return Response( request_target.CLOUDFLARE_PAGE, status_code=400, diff --git a/backlot/request_target.py b/backlot/request_target.py index 58b3cd83b..d1cef3e66 100644 --- a/backlot/request_target.py +++ b/backlot/request_target.py @@ -196,14 +196,54 @@ def _google(path_kinds: list[str], query_kinds: list[str]) -> Response | None: #: A `%` with fewer than two hex digits after it. _BROKEN_ESCAPE = re.compile(rb"%(?![0-9A-Fa-f]{2})") +#: A `%` that ends the path with one hex digit after it or none. +_CUT_ESCAPE = re.compile(rb"%[0-9A-Fa-f]?\Z") + + +def cut_escape(path: bytes, query: bytes | None) -> bool: + """Whether ``path``, the vendor's path as sent, with ``query`` after a `?` or ``None`` where + none came, is one Cloudflare in front of Linear and Fireflies reads as cut short by the end of + the target (:data:`_CUT_ESCAPE`). ``backlot.main.refuse_a_cut_escape_on_linear_and_fireflies`` + answers it, and :func:`refused_path` each other broken escape.""" + cut = _CUT_ESCAPE.search(path) + return query is None and cut is not None and not _broken(path[: cut.start()]) + + +def refused_path(path: bytes, query: bytes | None) -> bool: + """Whether Cloudflare in front of Linear and Fireflies refuses ``path`` with ``query`` after it, + ``None`` where no `?` came, with :data:`CLOUDFLARE_PAGE`: the path holds a `..` segment that + climbs past the root (:func:`_climbs_past_the_root`), which both hosts refused so ahead of a cut + escape after it (`/../a%2`, 2026-10-29, UTC), or it holds `%00` or a `%` two hex digits do not + follow, but where :func:`cut_escape` holds. The page comes ahead of Cloudflare's answer to a raw + byte: on 2026-10-26 (UTC) both hosts drew it for `/a%zz` with a raw `é` before or after it or + `\\x01` before it, and for `/a%00` after a raw `é`, and on 2026-10-29 for `/../a` with a raw `é` + or a space after it, where a raw `é` alone drew the empty 400.""" + if _climbs_past_the_root(path): + return True + return _broken(path) and not cut_escape(path, query) + + +def _broken(path: bytes) -> bool: + return b"%00" in path or _BROKEN_ESCAPE.search(path) is not None -def broken_escape(path: bytes) -> bool: - """Whether ``backlot.main.refuse_a_broken_escape_on_linear_and_fireflies`` refuses ``path``. - Cloudflare's page for it comes ahead of its answer to a raw byte: on 2026-10-26 (UTC) both hosts - drew :data:`CLOUDFLARE_PAGE` for `/a%zz` with a raw `é` before or after it or `\\x01` before - it, and for `/a%00` after a raw `é`, where a raw `é` alone drew the empty 400.""" - return b"%00" in path or _BROKEN_ESCAPE.search(path) is not None +def _climbs_past_the_root(path: bytes) -> bool: + """Whether, once each escape in ``path`` is decoded and each run of slashes is one, a `..` + segment with a segment after it has no segment before it to take away. Both hosts refused + `/../graphql`, `/x/../../`, `//../graphql`, `/x//../../graphql`, `/./../graphql`, + `/%2e%2e/graphql`, `/.%2e/graphql` and `/..%2Fgraphql` on 2026-10-29 (UTC), and passed `/..`, + `/x/../..`, `/x/..%2F..` and `/..?x=1` on to the vendor, and `/..\\graphql`, where a backslash + is no slash to Cloudflare.""" + segments = re.sub(rb"/{2,}", b"/", urllib.parse.unquote_to_bytes(path)).split(b"/")[1:] + depth = 0 + for at, segment in enumerate(segments, 1): + if segment == b".." and depth == 0 and at < len(segments): + return True + if segment == b"..": + depth = max(depth - 1, 0) + elif segment != b".": + depth += 1 + return False def _cloudflare(scope: Scope, path_kinds: list[str], query_kinds: list[str]) -> Response | None: @@ -310,6 +350,16 @@ def _graph(scope: Scope, path_kinds: list[str], query_kinds: list[str]) -> Respo return None +def _drop_the_cut(scope: Scope) -> None: + """Take the `%` that ends the scope's path, and the hex digit after it, off the path, as + Cloudflare in front of Linear does where it reads a path with a space in it as its + percent-encoding (:func:`_cloudflare`): Express's 404 behind it quoted `/a b%2`, `/a b%` and + `/a b%0` as `/a%20b` on 2026-10-29 (UTC).""" + cut = _CUT_ESCAPE.search(scope["raw_path"]) + scope["raw_path"] = scope["raw_path"][: cut.start()] + scope["path"] = scope["path"][: cut.start() - cut.end()] + + def answer(scope: Scope) -> Response | None: """The answer to a request whose target :mod:`backlot.protocol` encoded, or ``None`` where the vendor reads the target as another request, which the scope is left holding. @@ -317,7 +367,7 @@ def answer(scope: Scope) -> Response | None: Any other path, `/oauth2/token` and `/_meta` among them, gets the 400 uvicorn's own protocol answers such a target with.""" path = scope["path"] - sent_path, _, sent_query = scope[SENT].partition(b"?") + sent_path, mark, sent_query = scope[SENT].partition(b"?") in_path, in_query = _kinds(sent_path), _kinds(sent_query) if _under(path, "/github"): return _github() @@ -327,9 +377,16 @@ def answer(scope: Scope) -> Response | None: return _google(in_path, in_query) if errors.atlassian.owns(path): return _atlassian(scope, in_path, in_query) - if any(_under(path, p) for p in ("/linear", "/fireflies")) and broken_escape(sent_path): - return _page(CLOUDFLARE_PAGE, "text/html") - if any(_under(path, p) for p in ("/notion", "/hubspot", "/linear", "/fireflies")): + if any(_under(path, p) for p in ("/linear", "/fireflies")): + own = b"/" + sent_path.partition(b"/")[2].partition(b"/")[2] + query = sent_query if mark else None + if refused_path(own, query): + return _page(CLOUDFLARE_PAGE, "text/html") + refused = _cloudflare(scope, in_path, in_query) + if refused is None and cut_escape(own, query): + _drop_the_cut(scope) + return refused + if any(_under(path, p) for p in ("/notion", "/hubspot")): return _cloudflare(scope, in_path, in_query) if errors.s3.owns(path): return _s3(scope, in_path, in_query) diff --git a/backlot/routers/fireflies.py b/backlot/routers/fireflies.py index 37dce0f32..0b1796377 100644 --- a/backlot/routers/fireflies.py +++ b/backlot/routers/fireflies.py @@ -256,32 +256,56 @@ def _not_found(request: Request) -> Response: return _body_refusal(message, 404, "not_found") -_NGINX_404 = ( - b"\r\n404 Not Found\r\n\r\n" - b"

404 Not Found

\r\n
nginx
\r\n\r\n\r\n" +_NGINX_PAGE = ( + b"\r\n%s\r\n\r\n

%s

\r\n" + b"
nginx
\r\n\r\n\r\n" ) +_NGINX_404 = _NGINX_PAGE % ((b"404 Not Found",) * 2) +_NGINX_400 = _NGINX_PAGE % ((b"400 Bad Request",) * 2) _NGINX_LOCATION = re.compile(rb"/graphql(/|$)", re.IGNORECASE) -def nginx_passes(path: str) -> bool: - """Whether nginx in front of Fastify passes on ``path``, as a client sent it - (:func:`~backlot.routers.sent_path`): once each escape is decoded and each run of slashes is - one, it starts with `/graphql`, in any case, followed by a slash, by nothing or by a newline - that ends it. api.fireflies.ai answered with Fastify's 404 at each path :func:`_not_found` - names, and with nginx's (:data:`_NGINX_404`) at `/graphqlx`, `/graphql.json`, `/graphql;x`, - `/graphql%20`, `/graphql%7E`, `/x/graphql` and `/`, on 2026-10-25 (UTC).""" - decoded = re.sub(rb"/{2,}", b"/", urllib.parse.unquote_to_bytes(path)) - return _NGINX_LOCATION.match(decoded) is not None +def nginx_path(path: str) -> bytes | None: + """``path``, as a client sent it (:func:`~backlot.routers.sent_path`), as nginx in front of + Fastify reads it to pick a location: each escape decoded, each run of slashes one, then each + `.` segment dropped and each `..` segment taking the segment before it away; or ``None`` where a + `..` has no segment before it to take, which nginx refuses with :data:`_NGINX_400`. On + 2026-10-29 (UTC) `/x/../graphql`, `/x//../graphql`, `/a/b/../../graphql`, `/x/..%2Fgraphql` and + `/graphql/x/..` drew Fastify's 404 from api.fireflies.ai, `/graphql/..`, `/graphql//..` and + `/x/.//graphql` :data:`_NGINX_404`, and `/..`, `/x/../..`, `/x/..%2F..` and `/%2e%2e` + :data:`_NGINX_400`.""" + segments = re.sub(rb"/{2,}", b"/", urllib.parse.unquote_to_bytes(path)).split(b"/")[1:] + out: list[bytes] = [] + for segment in segments: + if segment == b"..": + if not out: + return None + out.pop() + elif segment != b".": + out.append(segment) + return b"/" + b"/".join(out) + + +def nginx_passes(path: bytes) -> bool: + """Whether nginx passes on ``path``, as :func:`nginx_path` reads it, to Fastify: it starts with + `/graphql`, in any case, followed by a slash, by nothing or by a newline that ends it. + api.fireflies.ai answered with Fastify's 404 at each path :func:`_not_found` names, and with + :data:`_NGINX_404` at `/graphqlx`, `/graphql.json`, `/graphql;x`, `/graphql%20`, `/graphql%7E`, + `/x/graphql` and `/`, on 2026-10-25 (UTC).""" + return _NGINX_LOCATION.match(path) is not None @router.api_route("/", methods=list(apollo_http.METHODS), include_in_schema=False) async def unrouted(request: Request): """Fireflies' answer at a path Fastify has no route for, each of which ``backlot.main`` routes - here: :data:`_NGINX_404` where nginx keeps the request (:func:`nginx_passes`), and Fastify's - where it passes it on, :func:`_preflight` to an `OPTIONS` and its not-found handler's 404 - (:func:`_not_found`) to any other method, after reading the body of one of :data:`_BODIED` - (:func:`_fastify_read`).""" - if not nginx_passes(sent_path(request.scope)): + here: nginx's page where nginx keeps the request (:func:`nginx_path`, :func:`nginx_passes`), + and Fastify's where it passes it on: :func:`_preflight` to an `OPTIONS`, and to any other + method, after reading the body of one of :data:`_BODIED` (:func:`_fastify_read`), its + not-found handler's 404 (:func:`_not_found`).""" + path = nginx_path(sent_path(request.scope)) + if path is None: + return Response(_NGINX_400, status_code=400, headers={"content-type": "text/html"}) + if not nginx_passes(path): return Response(_NGINX_404, status_code=404, headers={"content-type": "text/html"}) if request.method == "OPTIONS": return _preflight(request) diff --git a/backlot/routers/linear.py b/backlot/routers/linear.py index ec08c51bf..f93d39e64 100644 --- a/backlot/routers/linear.py +++ b/backlot/routers/linear.py @@ -226,47 +226,48 @@ def _has_body(request: Request) -> bool: def express_path(path: str) -> str: """``path``, as a client sent it (:func:`backlot.routers.sent_path`), as Express reads it behind Linear's front: a backslash is a slash, an escape of a character RFC 3986 leaves unreserved is - that character, a run of slashes is one, each character of :data:`_BARE` sent bare is escaped in - upper case, and any other escape is kept as sent. Express's 404 quoted `/%6Eosuch` as `/nosuch`, - `//nosuch` and `/nosuch//x` as `/nosuch` and `/nosuch/x`, `/a\\b` as `/a/b`, `/a%2fb` and - `/a%3cb` as sent, each character of :data:`_BARE` escaped in upper case (`|` as `%7C`), and - each other printable ASCII character but `%`, `?` and `#` as sent, on 2026-10-25 (UTC).""" + that character, the dot segments are resolved (:func:`_dots_resolved`), a run of slashes is + one, each character of :data:`_BARE` sent bare is escaped in upper case, and any other escape is + kept as sent. Express's 404 quoted `/%6Eosuch` as `/nosuch`, `//nosuch` and `/nosuch//x` as + `/nosuch` and `/nosuch/x`, `/a\\b` as `/a/b`, `/a%2fb` and `/a%3cb` as sent, each character of + :data:`_BARE` escaped in upper case (`|` as `%7C`), and each other printable ASCII character but + `%`, `?` and `#` as sent, on 2026-10-25 (UTC).""" def one(match: re.Match[str]) -> str: char = chr(int(match[1], 16)) return char if char in _UNRESERVED else match[0] - path = re.sub("/{2,}", "/", _ESCAPE.sub(one, path.replace("\\", "/"))) + path = re.sub("/{2,}", "/", _dots_resolved(_ESCAPE.sub(one, path.replace("\\", "/")))) return "".join(f"%{ord(char):02X}" if char in _BARE else char for char in path) def routes_to_apollo(path: str) -> bool: """Whether Express hands ``path`` (:func:`express_path`) to Apollo Server: `/graphql` in any - case, alone or with a slash after it and anything after that, once its dot segments are - resolved (:func:`_dots_resolved`). api.linear.app served `/GRAPHQL`, `/GraphQL/`, `/graphql/`, - `/graphql/x` and `/Graphql/X/Y` and answered `/graphqlx`, `/graphql.json`, `/graphql;x`, - `/graphql%20` and `/graphql%2F` with Express's 404 on 2026-10-25 (UTC). On 2026-10-26 it served - `/./graphql`, `/x/../graphql`, `/graphql/.`, `/graphql/x/..` and `/graphql/x/%2E%2E`, and - answered `/graphql/..`, `/graphql/../x`, `/graphql/%2e%2E/x` and `/graphql/x/../..` with - Express's 404 and `/../graphql` with Cloudflare's 400.""" - resolved = _dots_resolved(path) - return ( - resolved is not None - and re.fullmatch(r"/graphql(?:/.*)?", resolved, re.ASCII | re.IGNORECASE) is not None - ) - - -def _dots_resolved(path: str) -> str | None: - """``path`` with each `.` segment dropped and each `..` segment taking the one before it away, - or None where a `..` has no segment before it to take.""" + case, alone or with a slash after it and anything after that. api.linear.app served `/GRAPHQL`, + `/GraphQL/`, `/graphql/`, `/graphql/x` and `/Graphql/X/Y` and answered `/graphqlx`, + `/graphql.json`, `/graphql;x`, `/graphql%20` and `/graphql%2F` with Express's 404 on 2026-10-25 + (UTC).""" + return re.fullmatch(r"/graphql(?:/.*)?", path, re.ASCII | re.IGNORECASE) is not None + + +def _dots_resolved(path: str) -> str: + """``path`` with each `.` segment dropped and each `..` segment taking the segment before it + away, an empty one included, as a URL's path is resolved: a `..` with none before it takes + nothing, and a path that ends in either keeps the slash before it. Linear's front resolves them + so ahead of folding a run of slashes: on 2026-10-29 (UTC) api.linear.app served `/..\\graphql` + and `/graphql//..`, answered `/x//../graphql` and `/x/.//graphql` with Express's 404 quoting + `/x/graphql`, and `/graphql/x/../..` and `/r/b/%2e%2e` quoting `/` and `/r/`, and answered a + `GET` to `/r/..` with its greeting. Cloudflare refuses a `..` that climbs past the root with a + segment after it first (``backlot.request_target.refused_path``).""" + segments = path.split("/")[1:] out: list[str] = [] - for segment in path.split("/")[1:]: + for segment in segments: if segment == "..": - if not out: - return None - out.pop() + out = out[:-1] elif segment != ".": out.append(segment) + if segments[-1] in (".", ".."): + out.append("") return "/" + "/".join(out) diff --git a/docs/supported-sources.md b/docs/supported-sources.md index 45dc80d21..a980f00cd 100644 --- a/docs/supported-sources.md +++ b/docs/supported-sources.md @@ -92,8 +92,10 @@ query string and a JSON `Content-Type` or none. Apollo Server serves a form, or `Content-Type`, only beside an `apollo-require-preflight` or `x-apollo-operation-name` header, its check that a browser preflighted the request. Fastify serves `/graphql` alone, an escaped letter in it decoded. Any other path nginx passes on to Fastify, `/graphql` in any case, alone, before a -newline that ends it, or with a slash and more after it, once each escape is decoded and each run of -slashes is one, gets Fastify's 404, and any other path nginx's 404 page. Root `Query` fields: +newline that ends it, or with a slash and anything after it, once each escape is decoded, each run +of slashes is one and the dot segments are resolved, gets Fastify's 404, or its preflight to an +`OPTIONS`; any other path gets nginx's 404 page, and one ending in a `..` that climbs past the root +nginx's 400. Root `Query` fields: | Query field | Notes | |---|---| @@ -591,11 +593,12 @@ operation's 415 carry the account id alone. string. Apollo Server serves a `GET` with no `Content-Type`, or a form or `text/plain` one, only beside an `apollo-require-preflight` or `x-apollo-operation-name` header, its check that a browser preflighted the request. Express hands Apollo Server `/graphql` in any case, alone or with -a slash and more after it, once Linear's front has turned a backslash into a slash, folded a run of -slashes into one and decoded an escape of a letter, a digit or one of `-._~`, and once its dot -segments are resolved, so each of those paths is the endpoint too, but for one whose `..` climbs -past the root; `/` answers a `GET` or `HEAD` with a greeting, and any other method there, or any -other path, Express's 404 page. Root `Query` fields: +a slash and anything after it, once Linear's front has turned a backslash into a slash, decoded an +escape of a letter, a digit or one of `-._~`, resolved the dot segments and then folded a run of +slashes into one, so each of those paths is the endpoint too, but for one where a `..` with a +segment after it climbs past the root, which Cloudflare refuses with its 400; `/` answers a `GET` or +`HEAD` with a greeting and an `OPTIONS` with the methods it takes, and any other method there, or +any other path, Express's 404 page. Root `Query` fields: | Query field | Notes | |---|---| diff --git a/tests/_helpers.py b/tests/_helpers.py index be83dbb4d..f76d524db 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -17,8 +17,10 @@ import json import os import re +import socket import sys import threading +import urllib.parse from pathlib import Path import pytest @@ -319,6 +321,35 @@ def __new__(cls, answer: bytes, status: int = 400): return self +def read_answer(reader) -> tuple[int, dict[str, str], bytes] | None: + """One answer off ``reader``, by its `Content-Length` or its chunks, or ``None`` where the + connection closed ahead of it.""" + if not (status_line := reader.readline()): + return None + headers = {} + while (line := reader.readline()) not in (b"\r\n", b""): + name, _, value = line.decode("latin-1").partition(":") + headers[name.strip().lower()] = value.strip() + if headers.get("transfer-encoding") == "chunked": + body = b"" + while size := int(reader.readline().split(b";")[0], 16): + body += reader.read(size) + reader.readline() + reader.readline() + else: + body = reader.read(int(headers.get("content-length", 0))) + return int(status_line.split()[1]), headers, body + + +def raw_exchange(base_url: str, request: bytes) -> tuple[int, dict[str, str], bytes]: + """``request`` written as it is to the server at ``base_url``, and its answer: for a target a + client library would resolve, escape or refuse before sending it.""" + url = urllib.parse.urlsplit(base_url) + with socket.create_connection((url.hostname, url.port), timeout=10) as c: + c.sendall(request) + return read_answer(c.makefile("rb")) + + def sent_as(client, method: str, path: str, headers, content=None, bare: bool = False): """``method`` on ``path`` with each header's latin-1 bytes as sent, ``headers`` a mapping or a sequence of pairs, which can name a header twice. The TestClient re-encodes a header's bytes diff --git a/tests/test_cross_vendor.py b/tests/test_cross_vendor.py index 713f9a674..45fcacd73 100644 --- a/tests/test_cross_vendor.py +++ b/tests/test_cross_vendor.py @@ -347,22 +347,49 @@ def test_a_graphql_vendor_quotes_a_number_as_javascript_prints_it( # A request to Linear or Fireflies that Cloudflare answers itself, `{key}` standing for the vendor's -# own spelling of the admin token, and the status, media type, length, `Connection` and body of the -# answer, as `backlot.main.refuse_cloudflares_methods_on_linear_and_fireflies`, the repeated-header -# middleware it names and `backlot.main.refuse_a_broken_escape_on_linear_and_fireflies` record -# them. The last five rows are the control: one header reaches the vendor, a path that only starts -# with the vendor's name is not the vendor's, with a broken escape in it or without, and a broken -# escape in the query string is not one in the path. +# own spelling of the admin token, and the status, headers and body of the answer, as +# `backlot.main.refuse_cloudflares_methods_on_linear_and_fireflies`, the repeated-header middleware +# it names, `backlot.main.refuse_a_path_cloudflare_refuses_on_linear_and_fireflies` and +# `backlot.main.refuse_a_cut_escape_on_linear_and_fireflies` record them, or by each vendor where +# they part. The rows answered with a status alone are the control: one header reaches the vendor, +# a path that only starts with the vendor's name is not the vendor's, with a broken escape in it or +# without, a broken escape in the query string is not one in the path, and a `..` that ends the +# path is passed on to the vendor. _TYPENAME = b'{"query":"{ __typename }"}' _CLOUDFLARE = 400 _PAGE = ( b"\r\n%s\r\n\r\n

%s

\r\n" b"
cloudflare
\r\n\r\n\r\n" ) -_BAD_REQUEST = (_CLOUDFLARE, "text/html", "155", "close", _PAGE % ((b"400 Bad Request",) * 2)) -_NOT_ALLOWED = (405, "text/html", "155", "close", _PAGE % ((b"405 Not Allowed",) * 2)) -_ERROR_1045 = (400, "text/plain; charset=UTF-8", "17", None, b"error code: 1045\n") -_PURGED = (400, "text/html; charset=UTF-8", "155", None, _PAGE % ((b"400 Bad Request",) * 2)) +_PRIVATE = { + "cache-control": "private, max-age=0, no-store, no-cache, must-revalidate, post-check=0, " + "pre-check=0", + "expires": "Thu, 01 Jan 1970 00:00:01 GMT", + "referrer-policy": "same-origin", + "x-frame-options": "SAMEORIGIN", +} +_NOT_PRIVATE = dict.fromkeys(_PRIVATE) + + +def _answer(status, media_type, length, connection, private, body): + headers = {"content-type": media_type, "content-length": length, "connection": connection} + return status, headers | (_PRIVATE if private else _NOT_PRIVATE), body + + +_BAD_REQUEST = _answer( + _CLOUDFLARE, "text/html", "155", "close", False, _PAGE % ((b"400 Bad Request",) * 2) +) +_NOT_ALLOWED = _answer(405, "text/html", "155", "close", False, _PAGE % ((b"405 Not Allowed",) * 2)) +_ERROR_1045 = _answer(400, "text/plain; charset=UTF-8", "17", None, True, b"error code: 1045\n") +_PURGED = _answer( + 400, "text/html; charset=UTF-8", "155", None, True, _PAGE % ((b"400 Bad Request",) * 2) +) +_CUT = { + vendor: _answer( + 400, "text/html; charset=UTF-8", length, None, True, _PAGE % ((b"400 Bad Request",) * 2) + ) + for vendor, length in (("linear", None), ("fireflies", "155")) +} def _auth(*values: str) -> tuple: @@ -399,7 +426,7 @@ def _auth(*values: str) -> tuple: *( pytest.param(m, path, b"", _auth("{key}"), answer, id=f"{m} {path or '/'}") for m, answer in (("TRACE", _NOT_ALLOWED), ("CONNECT", _ERROR_1045), ("PURGE", _PURGED)) - for path in ("/graphql/", "/nosuch", "") + for path in ("/graphql/", "/nosuch", "", "/a%2") ), *( pytest.param(m, "/graphql", b"", _auth("{key}", "{key}"), _BAD_REQUEST, id=f"{m}, two keys") @@ -411,19 +438,39 @@ def _auth(*values: str) -> tuple: ) for m in ("TRACE", "CONNECT", "PURGE") ), - pytest.param("POST", "/a%2", _TYPENAME, _auth("{key}"), _BAD_REQUEST, id="%2 at the end"), + pytest.param("POST", "/a%2", _TYPENAME, _auth("{key}"), _CUT, id="%2 at the end"), + pytest.param("POST", "/nosucha%b", b"{}", (), _CUT, id="% before b"), + pytest.param("POST", "/graphql%", _TYPENAME, (), _CUT, id="% at the end"), + pytest.param("HEAD", "/a%2", b"", (), _CUT, id="HEAD, %2 at the end"), + pytest.param( + "POST", "/a%2", _TYPENAME, _auth("{key}", "{key}"), _BAD_REQUEST, id="%2, two keys" + ), + pytest.param("POST", "/a%2?x=1", _TYPENAME, (), _BAD_REQUEST, id="%2 before a query"), + pytest.param("POST", "/a%2/", _TYPENAME, (), _BAD_REQUEST, id="%2 before a slash"), + pytest.param("POST", "/a%zz/b%2", _TYPENAME, (), _BAD_REQUEST, id="%zz before a %2 at the end"), + pytest.param( + "POST", "/%2e%2e/a%2", _TYPENAME, (), _BAD_REQUEST, id="%2 at the end past the root" + ), pytest.param("POST", "/graphql%zz", _TYPENAME, (), _BAD_REQUEST, id="%zz"), - pytest.param("POST", "/nosucha%b", b"{}", (), _BAD_REQUEST, id="% before b"), *( pytest.param(m, p, b"", (), _BAD_REQUEST, id=f"{m} {p}") for m in ("TRACE", "CONNECT", "PURGE") - for p in ("/a%zz", "/a%00b") + for p in ("/a%zz", "/a%00b", "/%2e%2e/graphql") + ), + *( + pytest.param("POST", p, _TYPENAME, _auth("{key}"), _BAD_REQUEST, id=f"POST {p}") + for p in ("/%2e%2e/graphql", "/..%2Fgraphql", "/x//%2e%2e/%2e%2e/graphql", "/%2e%2e/") ), pytest.param("POST", "/graphql", _TYPENAME, _auth("{key}"), 200, id="one key"), pytest.param("POST", "x/graphql", _TYPENAME, _auth("{key}", "nope"), 404, id="a longer name"), pytest.param("TRACE", "x/graphql", b"", _auth("{key}"), 404, id="TRACE, a longer name"), pytest.param("POST", "x/a%zz", _TYPENAME, _auth("{key}"), 404, id="%zz under a longer name"), + pytest.param("POST", "x/a%2", _TYPENAME, _auth("{key}"), 404, id="%2 under a longer name"), pytest.param("POST", "/graphql?a=%zz", _TYPENAME, _auth("{key}"), 200, id="%zz in the query"), + pytest.param("POST", "/graphql?a=%2", _TYPENAME, _auth("{key}"), 200, id="%2 in the query"), + pytest.param( + "POST", "/%2e%2e", _TYPENAME, _auth("{key}"), {"linear": 404, "fireflies": 400}, id="/.." + ), ] @@ -434,6 +481,8 @@ def test_cloudflare_answers_ahead_of_linear_and_fireflies(client, tokens_yaml, v answered, expected = {}, {} for row in _CLOUDFLARE_FIRST: method, path, body, sent, answer = row.values + if isinstance(answer, dict): + answer = answer[vendor] if body is None: body = padded_query({"linear": 10 * 2**20, "fireflies": 2**20}[vendor] + 1) headers = [("Content-Type", "application/json")] @@ -453,14 +502,9 @@ def test_cloudflare_answers_ahead_of_linear_and_fireflies(client, tokens_yaml, v {"data": {"__typename": "Query"}} if answer == 200 else None, ) continue - answered[row.id] = ( - r.status_code, - r.headers.get("content-type"), - r.headers.get("content-length"), - r.headers.get("connection"), - r.content, - ) - expected[row.id] = (*answer[:4], b"" if method == "HEAD" else answer[4]) + status, named, page = answer + answered[row.id] = (r.status_code, {name: r.headers.get(name) for name in named}, r.content) + expected[row.id] = (status, named, b"" if method == "HEAD" else page) assert answered == expected diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index 5cd2424d3..05395ceaf 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -28,6 +28,7 @@ gql, http_row, padded_query, + raw_exchange, sent_as, served_id, tiny_corpus, @@ -2711,3 +2712,74 @@ def test_fireflies_answers_each_method_path_type_and_accept_as_fireflies_does(cl expected[row.id] = (status, media_type, answer, named) assert len(expected) == len(_FIREFLIES_HTTP) assert answered == expected + + +# A path httpx would resolve before sending it, written as it is to `backlot serve` with the key and +# `{ __typename }`, and the status and body api.fireflies.ai answers it with: Fastify's 404 where +# nginx resolves the dot segments to `/graphql` and passes the path on as sent, nginx's 404 where +# they resolve to any other path and its 400 where a `..` climbs past the root (`nginx_path`), and +# Cloudflare's page where one does with a segment after it (`request_target.refused_path`). +_NGINX_400 = _NGINX_404.replace(b"404 Not Found", b"400 Bad Request") +_FIREFLIES_SENT_RAW = [ + *( + pytest.param(m, p, 404, _not_found(m, p), id=f"{m} {p}") + for m, p in ( + ("POST", "/x/../graphql"), + ("POST", "/x//../graphql"), + ("POST", "/a/b/../../graphql"), + ("POST", "/x/..%2Fgraphql"), + ("POST", "/graphql/x/.."), + ("POST", "/./graphql"), + ("POST", "/graphql/."), + ("POST", "/%2e/graphql"), + ("POST", "/graphql/../graphql"), + ) + ), + *( + pytest.param("POST", p, 404, _NGINX_404, id=f"POST {p}") + for p in ( + "/graphql/..", + "/graphql//..", + "/x/.//graphql", + "/graphql/x/../..", + "/x/..", + "/..\\graphql", + ) + ), + *( + pytest.param(m, p, 400, _NGINX_400, id=f"{m} {p}") + for m, p in ( + ("POST", "/.."), + ("POST", "/x/../.."), + ("POST", "/x/..%2F.."), + ("POST", "/%2e%2e"), + ("POST", "/./.."), + ("POST", "/graphql/../.."), + ("GET", "/.."), + ("OPTIONS", "/.."), + ) + ), + *( + pytest.param("POST", p, 400, _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), id=f"POST {p}") + for p in ("/../graphql", "/./../graphql") + ), +] + + +@pytest.mark.parametrize("method, path, status, body", _FIREFLIES_SENT_RAW) +def test_fireflies_answers_a_path_sent_as_written_as_fireflies_does( + live_server, method, path, status, body +): + base_url, settings = live_server + request = ( + b"%s /fireflies%s HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer %s\r\n" + b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n%s" + % ( + method.encode(), + path.encode(), + settings.admin_token.encode(), + len(_TYPENAME) if method == "POST" else 0, + _TYPENAME if method == "POST" else b"", + ) + ) + assert raw_exchange(base_url, request)[::2] == (status, body) diff --git a/tests/test_linear.py b/tests/test_linear.py index 509239f8a..d318b9953 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -35,6 +35,7 @@ db_count, http_row, padded_query, + raw_exchange, selected_field_count, selected_fields, sent_as, @@ -6886,26 +6887,72 @@ def test_linear_answers_each_method_path_type_and_accept_as_linear_does(client, assert answered == expected -def test_linears_front_routes_a_path_once_its_dot_segments_are_resolved(): - """`routes_to_apollo` on paths holding dot segments, which httpx would resolve were - `_LINEAR_HTTP` to send them.""" - from backlot.routers import linear - - served = dict.fromkeys( - ("/./graphql", "/x/../graphql", "/graphql/.", "/graphql/x/..", "/graphql/x/%2E%2E"), True - ) - served |= dict.fromkeys( - ("/graphql/..", "/graphql/../x", "/graphql/%2e%2E/x", "/graphql/x/../..", "/../graphql"), - False, - ) - assert {p: linear.routes_to_apollo(linear.express_path(p)) for p in served} == served - +# A path httpx would resolve or escape before sending it, written as it is to `backlot serve` with +# the key and `{ __typename }`, and the status and body api.linear.app answers it with: the +# endpoint, Express's 404 quoting the path Linear's front reads (`express_path`), its greeting, and +# Cloudflare's page for a `..` past the root (`request_target.refused_path`). +_LINEAR_SENT_RAW = [ + *( + pytest.param("POST", p, 200, _TYPENAME_DATA, id=p) + for p in ( + "/./graphql", + "/x/../graphql", + "/graphql/.", + "/graphql/x/..", + "/graphql/x/%2E%2E", + "/..\\graphql", + "/graphql//..", + ) + ), + *( + pytest.param("POST", p, 404, _express_404("POST", echo), id=p) + for p, echo in ( + ("/graphql/..", "/"), + ("/graphql/../x", "/x"), + ("/graphql/%2e%2E/x", "/x"), + ("/graphql/x/../..", "/"), + ("/..", "/"), + ("/./..", "/"), + ("/x/..", "/"), + ("/./nosuch", "/nosuch"), + ("/x//../graphql", "/x/graphql"), + ("/x/.//graphql", "/x/graphql"), + ("/r/./b", "/r/b"), + ("/r/b/.", "/r/b/"), + ("/r/b/%2e%2e", "/r/"), + ("/x/..%2F..", "/x/..%2F.."), + *((f"/nosucha{char}b", f"/nosucha%{ord(char):02X}b") for char in '"<>`{}'), + ) + ), + *(pytest.param("GET", p, 200, _GREETING, id=f"GET {p}") for p in ("/r/..", "/./", "/%2e")), + *( + pytest.param("POST", p, 400, _CLOUDFLARE_PAGE % ((b"400 Bad Request",) * 2), id=p) + for p in ( + "/../graphql", + "/./../graphql", + "/x/../../", + "//../graphql", + "/../.", + "/graphql/../../x", + ) + ), +] -def test_linears_front_escapes_a_character_httpx_escapes_first(): - """The characters :func:`backlot.routers.linear.express_path` escapes that httpx escapes - before it sends a path, so `_LINEAR_HTTP` cannot send them bare; it sends the other two, `|` - and `^`.""" - from backlot.routers import linear - escaped = {char: f"/nosucha%{ord(char):02X}b" for char in '"<>`{}'} - assert {char: linear.express_path(f"/nosucha{char}b") for char in escaped} == escaped +@pytest.mark.parametrize("method, path, status, body", _LINEAR_SENT_RAW) +def test_linear_answers_a_path_sent_as_written_as_linear_does( + live_server, method, path, status, body +): + base_url, settings = live_server + request = ( + b"%s /linear%s HTTP/1.1\r\nHost: x\r\nAuthorization: %s\r\n" + b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n%s" + % ( + method.encode(), + path.encode(), + settings.admin_token.encode(), + len(_TYPENAME) if method == "POST" else 0, + _TYPENAME if method == "POST" else b"", + ) + ) + assert raw_exchange(base_url, request)[::2] == (status, body) diff --git a/tests/test_request_target.py b/tests/test_request_target.py index 8591baf20..c60cd0641 100644 --- a/tests/test_request_target.py +++ b/tests/test_request_target.py @@ -17,6 +17,7 @@ import pytest from backlot import errors, request_target +from tests._helpers import raw_exchange, read_answer _UTF8, _LONE = b"\xc3\xa9", b"\x80" _UVICORN = b"Invalid HTTP request received." @@ -35,9 +36,10 @@ _AS = {"del": "control", "ff": "lone"} # What the front door answers each kind with, in the path and in a query value. `encoded` is the -# answer to the request with the byte percent-encoded, `?` the one with each high byte in the path -# read as a `?` and the other bytes percent-encoded, and ` closed` after either that answer with the -# connection closed after it. +# answer to the request with the byte percent-encoded, `encoded, cut` the one with the byte +# percent-encoded and the `%` that ends the path taken off with the hex digit after it, `?` the one +# with each high byte in the path read as a `?` and the other bytes percent-encoded, and ` closed` +# after one of them that answer with the connection closed after it. _GOOGLE = dict.fromkeys(("utf-8", "lone"), "encoded") | { "control": "google 54", "nul": "google 54", @@ -118,8 +120,10 @@ ("/notion/v1/", b"nope x?b=\x80x", "cloudflare empty"), ("/notion/v1/", b"nope\x01x?b= x", "cloudflare empty"), ("/notion/v1/", b"nope\x80y\x0dx", "cloudflare page"), - # Linear and Fireflies: a broken escape in the path beside a raw byte, Cloudflare's page - # (`request_target.broken_escape`), and one in the query, the empty 400 + # Linear and Fireflies: a path Cloudflare refuses beside a raw byte, its page + # (`request_target.refused_path`), a broken escape in the query and one the end of the path + # cuts short (`request_target.cut_escape`), the 400 the byte draws, and a cut one beside a + # space, the path read as its percent-encoding without it *( (base, rest, "cloudflare page") for base in ("/linear/", "/fireflies/") @@ -129,13 +133,18 @@ b"a\x01%zz", b"a\xc3\xa9%00", b"a%zz?b=\xc3\xa9", + b"a%2?b=\xc3\xa9", + b"a%2\xc3\xa9", + b"../a\xc3\xa9", + b"../a b", ) ), *( (base, rest, "cloudflare empty") for base in ("/linear/", "/fireflies/") - for rest in (b"a\xc3\xa9?b=%zz", b"a\x01?b=%00") + for rest in (b"a\xc3\xa9?b=%zz", b"a\x01?b=%00", b"a\xc3\xa9%2", b"a\x01%2") ), + *((base, b"a b%2", "encoded, cut") for base in ("/linear/", "/fireflies/")), ("/s3/no-such-bucket/", b"nope\x0dy x", "s3 empty"), ("/s3/no-such-bucket/", b"nope\x09y x", "s3 505"), ("/s3/no-such-bucket/", b"nope y\x09x", "s3 505"), @@ -228,33 +237,6 @@ def _rows(): ) -def _read(reader) -> tuple[int, dict[str, str], bytes] | None: - """One answer off ``reader``, by its `Content-Length` or its chunks, or ``None`` where the - connection closed ahead of it.""" - if not (status_line := reader.readline()): - return None - headers = {} - while (line := reader.readline()) not in (b"\r\n", b""): - name, _, value = line.decode("latin-1").partition(":") - headers[name.strip().lower()] = value.strip() - if headers.get("transfer-encoding") == "chunked": - body = b"" - while size := int(reader.readline().split(b";")[0], 16): - body += reader.read(size) - reader.readline() - reader.readline() - else: - body = reader.read(int(headers.get("content-length", 0))) - return int(status_line.split()[1]), headers, body - - -def _exchange(base_url: str, request: bytes) -> tuple[int, dict[str, str], bytes]: - url = urllib.parse.urlsplit(base_url) - with socket.create_connection((url.hostname, url.port), timeout=10) as c: - c.sendall(request) - return _read(c.makefile("rb")) - - def _send( base_url: str, method: bytes, @@ -265,7 +247,7 @@ def _send( ) -> tuple[int, dict[str, str], bytes]: credential = b"Authorization: Bearer %s\r\n" % admin.encode() * times if admin else b"" request = b"%s %s HTTP/1.1\r\nHost: x\r\n%s%s\r\n" % (method, target, credential, header) - return _exchange(base_url, request) + return raw_exchange(base_url, request) def _encoded(target: bytes, question_marks: bool = False) -> bytes: @@ -279,7 +261,7 @@ def _encoded(target: bytes, question_marks: bool = False) -> bytes: #: The answers after which the connection reads another request; it is closed after every other one #: (``backlot.request_target._page``). -_KEEPS_THE_CONNECTION = {"encoded", "?", "cloudflare empty", "graph empty"} +_KEEPS_THE_CONNECTION = {"encoded", "encoded, cut", "?", "cloudflare empty", "graph empty"} def _ids_left_out(base: str, body: bytes) -> object: @@ -328,8 +310,10 @@ def test_a_raw_byte_in_the_target_is_answered_as_the_vendors_front_door_answers_ status, headers, body = _send(base_url, method, target, admin, authorizations, header) assert headers.get("connection") == (None if answer in _KEEPS_THE_CONNECTION else "close") reading = answer.removesuffix(" closed") - if reading in ("encoded", "?"): + if reading in ("encoded", "encoded, cut", "?"): encoded = _encoded(target, question_marks=reading == "?") + if reading == "encoded, cut": + encoded = re.sub(rb"%[0-9A-Fa-f]?\Z", b"", encoded) twin = _send(base_url, method, encoded, admin, header=header) assert status == twin[0] assert _ids_left_out(base, body) == _ids_left_out(base, twin[2]) @@ -408,7 +392,7 @@ def test_a_request_line_in_two_reads_after_another_on_one_connection( early.append(bool(select.select([c], [], [], 1)[0])) if not early[-1]: c.sendall(second) - answer = _read(reader) + answer = read_answer(reader) except OSError: # closed after the answer before break if answer is None: diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 0af8316e4..e4dae87a8 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -690,6 +690,24 @@ def test_an_error_code_is_the_envelopes_own_identifier(source, status, body, exp {"headers": LINEAR | JSON, "json": {"query": "{ __typename }"}}, ("linear", "unmatched", "unknown", 400, None, "client"), ), + ( + "POST", + "/linear/a%2", + {"headers": LINEAR | JSON, "json": {"query": "{ __typename }"}}, + ("linear", "unmatched", "unknown", 400, None, "client"), + ), + ( + "POST", + "/fireflies/%2e%2e/graphql", + {"headers": JSON, "json": {"query": "{ __typename }"}}, + ("fireflies", "unmatched", "unknown", 400, None, "client"), + ), + ( + "POST", + "/fireflies/%2e%2e", + {"headers": JSON, "json": {"query": "{ __typename }"}}, + ("fireflies", "unmatched", "unknown", 400, None, "client"), + ), ( "POST", "/fireflies/graphql/", From aaa69abf8ff5651ad313575f9228c98101f06342 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:13:17 +0900 Subject: [PATCH 52/52] tests: each raw request is built by one helper beside raw_exchange --- tests/_helpers.py | 25 +++++++++++++++++++++++++ tests/test_fireflies.py | 14 +++----------- tests/test_linear.py | 14 +++----------- tests/test_request_target.py | 6 +++--- 4 files changed, 34 insertions(+), 25 deletions(-) diff --git a/tests/_helpers.py b/tests/_helpers.py index f76d524db..35e793a81 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -341,6 +341,31 @@ def read_answer(reader) -> tuple[int, dict[str, str], bytes] | None: return int(status_line.split()[1]), headers, body +def raw_request( + method: str | bytes, + target: str | bytes, + authorization: str | bytes | None = None, + *, + times: int = 1, + headers: bytes = b"", + body: bytes | None = None, +) -> bytes: + """An HTTP/1.1 request for :func:`raw_exchange`: ``target`` as written, `Host: x`, the + `Authorization` value ``times`` times, ``headers`` (whole lines), and with a ``body`` its + `Content-Type: application/json` and `Content-Length` ahead of it. A `str` is sent as its + latin-1 bytes.""" + method, target, authorization = ( + v.encode("latin-1") if isinstance(v, str) else v for v in (method, target, authorization) + ) + request = b"%s %s HTTP/1.1\r\nHost: x\r\n" % (method, target) + if authorization is not None: + request += b"Authorization: %s\r\n" % authorization * times + request += headers + if body is not None: + request += b"Content-Type: application/json\r\nContent-Length: %d\r\n" % len(body) + return request + b"\r\n" + (body or b"") + + def raw_exchange(base_url: str, request: bytes) -> tuple[int, dict[str, str], bytes]: """``request`` written as it is to the server at ``base_url``, and its answer: for a target a client library would resolve, escape or refuse before sending it.""" diff --git a/tests/test_fireflies.py b/tests/test_fireflies.py index 05395ceaf..ba2abd86d 100644 --- a/tests/test_fireflies.py +++ b/tests/test_fireflies.py @@ -29,6 +29,7 @@ http_row, padded_query, raw_exchange, + raw_request, sent_as, served_id, tiny_corpus, @@ -2771,15 +2772,6 @@ def test_fireflies_answers_a_path_sent_as_written_as_fireflies_does( live_server, method, path, status, body ): base_url, settings = live_server - request = ( - b"%s /fireflies%s HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer %s\r\n" - b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n%s" - % ( - method.encode(), - path.encode(), - settings.admin_token.encode(), - len(_TYPENAME) if method == "POST" else 0, - _TYPENAME if method == "POST" else b"", - ) - ) + sent = _TYPENAME if method == "POST" else b"" + request = raw_request(method, f"/fireflies{path}", f"Bearer {settings.admin_token}", body=sent) assert raw_exchange(base_url, request)[::2] == (status, body) diff --git a/tests/test_linear.py b/tests/test_linear.py index d318b9953..d1d9e5e34 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -36,6 +36,7 @@ http_row, padded_query, raw_exchange, + raw_request, selected_field_count, selected_fields, sent_as, @@ -6944,15 +6945,6 @@ def test_linear_answers_a_path_sent_as_written_as_linear_does( live_server, method, path, status, body ): base_url, settings = live_server - request = ( - b"%s /linear%s HTTP/1.1\r\nHost: x\r\nAuthorization: %s\r\n" - b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n%s" - % ( - method.encode(), - path.encode(), - settings.admin_token.encode(), - len(_TYPENAME) if method == "POST" else 0, - _TYPENAME if method == "POST" else b"", - ) - ) + sent = _TYPENAME if method == "POST" else b"" + request = raw_request(method, f"/linear{path}", settings.admin_token, body=sent) assert raw_exchange(base_url, request)[::2] == (status, body) diff --git a/tests/test_request_target.py b/tests/test_request_target.py index c60cd0641..09178cbf5 100644 --- a/tests/test_request_target.py +++ b/tests/test_request_target.py @@ -17,7 +17,7 @@ import pytest from backlot import errors, request_target -from tests._helpers import raw_exchange, read_answer +from tests._helpers import raw_exchange, raw_request, read_answer _UTF8, _LONE = b"\xc3\xa9", b"\x80" _UVICORN = b"Invalid HTTP request received." @@ -245,8 +245,8 @@ def _send( times: int = 1, header: bytes = b"", ) -> tuple[int, dict[str, str], bytes]: - credential = b"Authorization: Bearer %s\r\n" % admin.encode() * times if admin else b"" - request = b"%s %s HTTP/1.1\r\nHost: x\r\n%s%s\r\n" % (method, target, credential, header) + authorization = f"Bearer {admin}" if admin else None + request = raw_request(method, target, authorization, times=times, headers=header) return raw_exchange(base_url, request)