From 366a2e667866890cf4f6f0bfc1f98f39cfece160 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 17:40:32 +0100 Subject: [PATCH] test: keep every GraphQL operation within the request limits of Unraid 7.4 Unraid 7.4.0 refuses requests above a cost of 1,000, depth 20, 100 aliases and 5,000 GraphQL tokens (unraid/api request-protection.ts, graphql-armor 3.2.0). A query that grows past them would only show up as a failed screen. GraphqlRequestLimitsTest re-implements the server's formulas (documented with their source in GraphqlDocumentMetrics), measures every operation in app/src/main/graphql as Apollo sends it and fails, naming the operation and the measure, when one passes 70 percent of a limit. Its other tests pin the formulas to the server's own boundaries. HANDOFF.md records the limits, the margin and the result measured on a real Unraid 7.4.0. No app code changes: the largest operation (GetArray) costs 59 of 1,000. Closes #320 Co-Authored-By: Claude Sonnet 5.5 --- HANDOFF.md | 21 + .../nova/data/api/GraphqlDocumentMetrics.kt | 402 ++++++++++++++++++ .../nova/data/api/GraphqlRequestLimitsTest.kt | 166 ++++++++ 3 files changed, 589 insertions(+) create mode 100644 app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlDocumentMetrics.kt create mode 100644 app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlRequestLimitsTest.kt diff --git a/HANDOFF.md b/HANDOFF.md index 9c9eb0a0..650f5296 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -248,6 +248,7 @@ JSON-scalar decision rationale (incl. why-not-String): [ADR-0007](docs/adr/0007- - **JSON scalar → kotlin.Any via Apollo's `AnyAdapter`** — the Unraid GraphQL `JSON` scalar serialises as inline JSON values (objects/arrays/primitives), not pre-stringified text. Custom `kotlin.String` adapters crash. We map it to `kotlin.Any` with `com.apollographql.apollo.api.AnyAdapter` (public, built-in). Don't try to write your own with `reader.readAny()` — that's `@ApolloInternal`. - **Update check** lives under `data/update/`: `UpdateRepository` polls `https://api.github.com/repos/hellwand/nova/releases` (installed versions up to 0.1.42 still carry the old `nofuturekid/nova` name and reach it through GitHub's redirect; no auth, 60 req/h rate limit fine for on-start checks), `UpdateDialog` only sends the browser to the release's APK (or the release page when it has no APK) with `ACTION_VIEW`; the app downloads and installs nothing itself and declares no `REQUEST_INSTALL_PACKAGES` in any flavor (ADR-0046). The check is compiled into both flavors and disabled at runtime when `BuildConfig.HAS_UPDATER` is false (`store`). The Settings screen exposes the "Include pre-releases" toggle + manual "Check now". Banner on MainScreen dismisses per-tag via `dismissedUpdateTag` in DataStore. - Cleartext HTTP requires `android:usesCleartextTraffic="true"` in the manifest. LAN access against `http://192.168.x.x` needs this. The base-config in `network_security_config.xml` permits cleartext; the GitHub hosts (`api.github.com`, `github.com`, `objects.githubusercontent.com`) are pinned to HTTPS. +- **Unraid 7.4 refuses oversized requests** (cost 1,000, depth 20, 100 aliases, 5,000 tokens). `GraphqlRequestLimitsTest` fails CI when an operation passes 70 % of any limit; numbers and sources under "Unraid 7.4 request limits (#320)" below. - **CI `test` job (ADR-0036) can transiently fail at Gradle _configuration_** with a plugin-not-resolvable error (e.g. `com.google.devtools.ksp` not found) on a plugin-portal/registry blip — it fetches plugins fresh under PR `cache-read-only`. **Re-run the job; it is not a code defect** (see ADR-0036 "Known consequence — transient plugin-fetch in the `test` job" note; PR #163 2026-05-19). ### What the Unraid API does NOT expose @@ -261,6 +262,26 @@ Minimum server API: every operation the app sends, except the two network-speed - VmDomain has only id/name/state — no vcpu/memGb/gpu fields. UI placeholder zeros (still not exposed). - Container icons may come back as **root-relative paths** (`/state/plugins/dynamix.docker.manager/images/foo.png`). We prepend the configured server base URL in `ContainerIcon.resolveIconUrl`. +### Unraid 7.4 request limits (#320) + +Unraid 7.4.0 refuses GraphQL requests above fixed limits. Source: `api/src/unraid-api/graph/request-protection.ts` and `graph.module.ts` in unraid/api at commit `8e587fe` (2026-11-04; the API version `4.37.11+8e587fe2` that the 7.4.0 test server reports is built from exactly that commit), `@escape.tech/graphql-armor` 3.2.0, `graphql` 16.11.0, Fastify 5.12.5. + +| Measure | Server limit | How it is counted | NOVA's own ceiling (70 %) | +| ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | +| Cost | 1,000 | every field costs 1 (object and scalar alike, depth factor 1), fragments flattened (a spread adds 1 + 1 + its fields); a `first`/`last` integer literal multiplies; `limit` does not | 700 | +| Depth | 20 | field nesting; fragments and inline fragments add no level | 14 | +| Aliases | 100 | every aliased field, no allow-list | 70 | +| Tokens | 5,000 | graphql-js tokens of the whole document (HTTP and WebSocket subscribe messages); the `connection_init` message is not GraphQL | 3,500 | +| Request body | 1 MiB | no `bodyLimit` is set, so Fastify's default applies | 734,003 bytes (the test measures the query text only; variables are small) | + +Only `__schema` and `__type` (introspection) are blocked; NOVA sends none. A refused request is HTTP 400 with `Syntax Error: Query Cost limit of 1000 exceeded, found N.` (or the alias, depth or token variant). + +**Guard:** `GraphqlRequestLimitsTest` (unit test, runs in CI) re-implements these formulas, measures every operation in `app/src/main/graphql` as Apollo sends it (generated documents, `__typename` included) and fails with the operation and the measure when one passes 70 % of a limit. Its own tests pin the formulas to the server's boundaries. If it fails, slim the query down (fewer fields, a second query) rather than raising the margin. + +**Measured 2026-11-13 on Unraid 7.4.0 (API 4.37.11):** the largest operation is `GetArray` with cost 59 (6 % of 1,000), depth 4; the deepest is `GetMetrics` with depth 5; most tokens `GetNotificationList` with 78 (2 % of 5,000); at most 2 aliases; largest document 477 bytes. All 15 shipped queries answer HTTP 200 with data. Live control requests (harmless `__typename` and copies of `GetArray` and `GetNotificationList`) were refused at exactly the predicted numbers (cost 1001, 1003 = 17 x 59, 1023 = 31 x 33; 101 aliases; 5,001 tokens) and 1,000 cost / 100 aliases were accepted, so the formulas match the live server. Not measured live: depth 21 (needs a recursive type) and the body limit (no oversized request was sent); both come from the source. The subscriptions were not sent live; their documents use 8 to 20 tokens. + +NOVA does not need introspection. The limits come from the server's code on that commit; a later 7.4.x or 7.5 API may change them. Re-read `request-protection.ts` then and update `GraphqlRequestLimits` in the test. + ## Secrets ### GitHub Actions secrets (already set on the repo) diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlDocumentMetrics.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlDocumentMetrics.kt new file mode 100644 index 00000000..5b7b0fa7 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlDocumentMetrics.kt @@ -0,0 +1,402 @@ +package io.github.nofuturekid.nova.data.api + +import kotlin.math.max + +/** + * Test-only measuring tool for GraphQL documents: the same numbers the Unraid + * API (7.4+) checks before it runs a request (#320). + * + * Source of the limits and of the formulas: unraid/api, + * `api/src/unraid-api/graph/request-protection.ts` (commit 8e587fe, 2026-11-04), + * which configures `@escape.tech/graphql-armor` 3.2.0 and the `graphql` 16.11.0 + * parser. The formulas below are re-implemented from the plugins' published + * code, not from memory: + * + * - cost: `@escape.tech/graphql-armor-cost-limit` 2.4.3 (`CostLimitVisitor.computeComplexity`) + * - depth: `@escape.tech/graphql-armor-max-depth` 2.4.2 (`MaxDepthVisitor.countDepth`) + * - aliases: `@escape.tech/graphql-armor-max-aliases` 2.6.2 (`MaxAliasesVisitor.countAliases`) + * - tokens: `graphql` 16.11.0 `Parser.advanceLexer` (`parseOptions.maxTokens`) + * + * The one deliberate simplification: the plugins memoise fragment results in a + * per-validation map; for documents that pass GraphQL's own "no fragment + * cycles" rule that memo returns exactly what a fresh walk returns, so it is + * left out here. Everything else (flattened fragments, no introspection + * special case, `first`/`last` multiplier) follows the server's configuration. + * + * The cost needs no schema knowledge: with object cost 1, scalar cost 1 and + * depth factor 1 the plugin does not look at types, only at whether a field has + * a selection set. The only argument it reads is an integer literal `first` or + * `last` (a multiplier); NOVA uses neither (`limit: 200` is not one), so the + * number reported here is exact, not an upper bound. A variable `first`/`last` + * would count as multiplier 1 on the server too. + */ +internal object GraphqlRequestLimits { + // request-protection.ts: costLimit { maxCost: 1_000, objectCost: 1, scalarCost: 1, + // depthCostFactor: 1, flattenFragments: true, ignoreIntrospection: false } + const val MAX_COST = 1_000 + const val OBJECT_COST = 1L + const val SCALAR_COST = 1L + const val DEPTH_COST_FACTOR = 1L + + // request-protection.ts: maxDepth { n: 20, flattenFragments: true, ignoreIntrospection: false } + const val MAX_DEPTH = 20 + + // request-protection.ts: maxAliases { n: 100, allowList: [] } + const val MAX_ALIASES = 100 + + // request-protection.ts: maxGraphqlTokens = 5_000 (graph.module.ts parseOptions for HTTP, + // createGraphqlWsOptions.onSubscribe for WebSocket subscribe messages). + const val MAX_TOKENS = 5_000 + + // No bodyLimit is set anywhere in the API (main.ts builds `new FastifyAdapter({ trustProxy })`), + // so Fastify 5's default of 1 MiB applies to the JSON request body. + const val MAX_BODY_BYTES = 1_048_576 + + /** NOVA's own safety margin: no shipped operation may use more than this share of any limit. */ + const val MARGIN = 0.7 +} + +internal enum class TokenKind { PUNCT, NAME, INT, FLOAT, STRING } + +internal data class GqlToken(val kind: TokenKind, val text: String) + +internal sealed interface Selection + +internal data class FieldSelection( + val alias: String?, + val name: String, + /** The `first`/`last` multiplier of the cost plugin; 1 when absent or not an integer literal. */ + val multiplier: Int, + /** null for a leaf field (no selection set). */ + val selections: List?, +) : Selection + +internal data class InlineFragmentSelection(val selections: List) : Selection + +internal data class FragmentSpreadSelection(val name: String) : Selection + +internal data class OperationDefinition(val kind: String, val name: String?, val selections: List) + +internal data class FragmentDefinition(val name: String, val selections: List) + +internal class ParsedDocument( + val operations: List, + val fragments: Map, + val tokenCount: Int, +) + +/** Hand-written lexer: the Apollo compiler is a Gradle-plugin dependency and not on the test classpath. */ +internal fun tokenizeGraphql(src: String): List { + val out = mutableListOf() + var i = 0 + while (i < src.length) { + val c = src[i] + when { + c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',' || c == '' -> i++ + c == '#' -> while (i < src.length && src[i] != '\n' && src[i] != '\r') i++ + c == '.' -> { + require(src.startsWith("...", i)) { "Unexpected '.' at $i" } + out += GqlToken(TokenKind.PUNCT, "...") + i += 3 + } + c in "!$&():=@[]{|}" -> { + out += GqlToken(TokenKind.PUNCT, c.toString()) + i++ + } + c == '"' -> { + val start = i + if (src.startsWith("\"\"\"", i)) { + i += 3 + while (!src.startsWith("\"\"\"", i) || src[i - 1] == '\\') { + i++ + require(i < src.length) { "Unterminated block string at $start" } + } + i += 3 + } else { + i++ + while (src[i] != '"') { + i += if (src[i] == '\\') 2 else 1 + require(i < src.length) { "Unterminated string at $start" } + } + i++ + } + out += GqlToken(TokenKind.STRING, src.substring(start, i)) + } + c == '-' || c.isDigit() -> { + val start = i + i++ + while (i < src.length && (src[i].isDigit() || src[i] in ".eE+-")) i++ + val text = src.substring(start, i) + val isFloat = text.any { it == '.' || it == 'e' || it == 'E' } + out += GqlToken(if (isFloat) TokenKind.FLOAT else TokenKind.INT, text) + } + c == '_' || c in 'a'..'z' || c in 'A'..'Z' -> { + val start = i + while (i < src.length && (src[i] == '_' || src[i].isLetterOrDigit())) i++ + out += GqlToken(TokenKind.NAME, src.substring(start, i)) + } + else -> error("Unexpected character '$c' at $i") + } + } + return out +} + +/** The graphql-js token count: every token except comments, whitespace, commas and the end marker. */ +internal fun countGraphqlTokens(src: String): Int = tokenizeGraphql(src).size + +/** Parses operations and fragments; type-system definitions are not supported (NOVA ships none in documents). */ +internal fun parseGraphqlDocument(src: String): ParsedDocument { + val parser = GqlParser(tokenizeGraphql(src)) + return parser.parseDocument() +} + +private class GqlParser(private val tokens: List) { + private var pos = 0 + + private fun peek(): GqlToken? = tokens.getOrNull(pos) + private fun isPunct(p: String) = peek()?.let { it.kind == TokenKind.PUNCT && it.text == p } == true + private fun isName(n: String? = null) = peek()?.let { it.kind == TokenKind.NAME && (n == null || it.text == n) } == true + + private fun next(): GqlToken = tokens.getOrNull(pos++) ?: error("Unexpected end of document") + + private fun expectPunct(p: String) { + val t = next() + require(t.kind == TokenKind.PUNCT && t.text == p) { "Expected '$p' but found '${t.text}' (token ${pos - 1})" } + } + + private fun expectName(): String { + val t = next() + require(t.kind == TokenKind.NAME) { "Expected a name but found '${t.text}' (token ${pos - 1})" } + return t.text + } + + fun parseDocument(): ParsedDocument { + val operations = mutableListOf() + val fragments = linkedMapOf() + while (peek() != null) { + when { + isPunct("{") -> operations += OperationDefinition("query", null, parseSelectionSet()) + isName("fragment") -> { + next() + val name = expectName() + require(isName("on")) { "Fragment $name: expected 'on'" } + next() + expectName() + skipDirectives() + fragments[name] = FragmentDefinition(name, parseSelectionSet()) + } + isName("query") || isName("mutation") || isName("subscription") -> { + val kind = next().text + val name = if (isName()) next().text else null + if (isPunct("(")) skipVariableDefinitions() + skipDirectives() + operations += OperationDefinition(kind, name, parseSelectionSet()) + } + else -> error("Unexpected token '${peek()?.text}' at the top level (token $pos)") + } + } + return ParsedDocument(operations, fragments, tokens.size) + } + + private fun skipVariableDefinitions() { + expectPunct("(") + while (!isPunct(")")) { + expectPunct("$") + expectName() + expectPunct(":") + skipType() + if (isPunct("=")) { + next() + parseValue() + } + skipDirectives() + } + expectPunct(")") + } + + private fun skipType() { + if (isPunct("[")) { + next() + skipType() + expectPunct("]") + } else { + expectName() + } + if (isPunct("!")) next() + } + + private fun skipDirectives() { + while (isPunct("@")) { + next() + expectName() + if (isPunct("(")) parseArguments() + } + } + + private fun parseSelectionSet(): List { + expectPunct("{") + val out = mutableListOf() + while (!isPunct("}")) out += parseSelection() + expectPunct("}") + return out + } + + private fun parseSelection(): Selection { + if (isPunct("...")) { + next() + if (isName() && !isName("on")) { + val name = next().text + skipDirectives() + return FragmentSpreadSelection(name) + } + if (isName("on")) { + next() + expectName() + } + skipDirectives() + return InlineFragmentSelection(parseSelectionSet()) + } + var name = expectName() + var alias: String? = null + if (isPunct(":")) { + next() + alias = name + name = expectName() + } + val multiplier = if (isPunct("(")) parseArguments() else 1 + skipDirectives() + val selections = if (isPunct("{")) parseSelectionSet() else null + return FieldSelection(alias, name, multiplier, selections) + } + + /** Returns the cost plugin's multiplier: the first `first`/`last` argument decides, an integer literal counts. */ + private fun parseArguments(): Int { + expectPunct("(") + var multiplier = 1 + var decided = false + while (!isPunct(")")) { + val name = expectName() + expectPunct(":") + val value = parseValue() + if (!decided && (name == "first" || name == "last")) { + decided = true + if (value.kind == TokenKind.INT) multiplier = max(value.text.toIntOrNull() ?: 1, 1) + } + } + expectPunct(")") + return multiplier + } + + private fun parseValue(): GqlToken { + val t = next() + when { + t.kind == TokenKind.PUNCT && t.text == "$" -> expectName() + t.kind == TokenKind.PUNCT && t.text == "[" -> { + while (!isPunct("]")) parseValue() + expectPunct("]") + } + t.kind == TokenKind.PUNCT && t.text == "{" -> { + while (!isPunct("}")) { + expectName() + expectPunct(":") + parseValue() + } + expectPunct("}") + } + t.kind == TokenKind.PUNCT -> error("Unexpected '${t.text}' in a value") + } + return t + } +} + +internal data class OperationMetrics( + val name: String, + val kind: String, + val tokens: Int, + val depth: Int, + val aliases: Int, + val cost: Long, + val bodyBytes: Int, +) + +/** Measures every operation of [src] by the server's formulas; fragments of the same document are resolved. */ +internal fun measureGraphqlDocument(src: String): List { + val doc = parseGraphqlDocument(src) + val bytes = src.toByteArray(Charsets.UTF_8).size + return doc.operations.map { op -> + OperationMetrics( + name = op.name ?: "(anonymous)", + kind = op.kind, + tokens = doc.tokenCount, + depth = depthOf(op.selections, 0, doc.fragments), + aliases = aliasesOf(op.selections, doc.fragments), + cost = op.selections.sumOf { rootCost(it, doc.fragments) }, + bodyBytes = bytes, + ) + } +} + +// graphql-armor-cost-limit: computeComplexity for the operation's own children (no depth factor). +private fun rootCost(sel: Selection, fragments: Map): Long = when (sel) { + is FieldSelection -> fieldCost(sel, fragments) + is InlineFragmentSelection -> GraphqlRequestLimits.OBJECT_COST + childTerms(sel.selections, fragments) + is FragmentSpreadSelection -> spreadCost(sel, fragments) +} + +private fun fieldCost(f: FieldSelection, fragments: Map): Long { + val children = f.selections ?: return GraphqlRequestLimits.SCALAR_COST + return (GraphqlRequestLimits.OBJECT_COST + childTerms(children, fragments)) * f.multiplier +} + +// flattenFragments: fields below a selection set are weighted by the depth factor, fragments are added as they are. +private fun childTerms(children: List, fragments: Map): Long = + children.sumOf { child -> + when (child) { + is FieldSelection -> GraphqlRequestLimits.DEPTH_COST_FACTOR * fieldCost(child, fragments) + is InlineFragmentSelection -> + GraphqlRequestLimits.OBJECT_COST + childTerms(child.selections, fragments) + is FragmentSpreadSelection -> spreadCost(child, fragments) + } + } + +private fun spreadCost(s: FragmentSpreadSelection, fragments: Map): Long { + val def = fragments[s.name] ?: error("Fragment ${s.name} is spread but not defined in the document") + return GraphqlRequestLimits.SCALAR_COST + GraphqlRequestLimits.OBJECT_COST + childTerms(def.selections, fragments) +} + +// graphql-armor-max-depth, flattenFragments: a field sits one level below its parent; inline +// fragments and spreads add no level of their own. +private fun depthOf(children: List, parentDepth: Int, fragments: Map): Int { + var depth = parentDepth + for (child in children) { + depth = max( + depth, + when (child) { + is FieldSelection -> { + val level = parentDepth + 1 + child.selections?.let { depthOf(it, level, fragments) } ?: level + } + is InlineFragmentSelection -> depthOf(child.selections, parentDepth, fragments) + is FragmentSpreadSelection -> { + val def = fragments[child.name] ?: error("Fragment ${child.name} is spread but not defined") + parentDepth + depthOf(def.selections, 0, fragments) + } + }, + ) + } + return depth +} + +// graphql-armor-max-aliases with allowList []: every aliased field counts, also inside fragments. +private fun aliasesOf(children: List, fragments: Map): Int = + children.sumOf { child -> + when (child) { + is FieldSelection -> + (if (child.alias != null) 1 else 0) + (child.selections?.let { aliasesOf(it, fragments) } ?: 0) + is InlineFragmentSelection -> aliasesOf(child.selections, fragments) + is FragmentSpreadSelection -> { + val def = fragments[child.name] ?: error("Fragment ${child.name} is spread but not defined") + aliasesOf(def.selections, fragments) + } + } + } diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlRequestLimitsTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlRequestLimitsTest.kt new file mode 100644 index 00000000..7a3f9756 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlRequestLimitsTest.kt @@ -0,0 +1,166 @@ +package io.github.nofuturekid.nova.data.api + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * #320 — Unraid 7.4 refuses requests above fixed limits (cost 1,000, depth 20, + * 100 aliases, 5,000 GraphQL tokens, 1 MiB body; see [GraphqlRequestLimits]). + * The refusal reaches the user as a failed screen, long after the query that + * grew too large was merged. This test measures every operation NOVA ships by + * the server's own formulas and fails in CI when one passes [GraphqlRequestLimits.MARGIN] + * of any limit, so the query is slimmed down while it is still a pull request. + */ +class GraphqlRequestLimitsTest { + private val margin = GraphqlRequestLimits.MARGIN + + private data class Measure(val label: String, val value: Long, val limit: Int) { + val threshold: Long get() = (limit * GraphqlRequestLimits.MARGIN).toLong() + } + + private fun measures(m: OperationMetrics) = listOf( + Measure("cost", m.cost, GraphqlRequestLimits.MAX_COST), + Measure("depth", m.depth.toLong(), GraphqlRequestLimits.MAX_DEPTH), + Measure("aliases", m.aliases.toLong(), GraphqlRequestLimits.MAX_ALIASES), + Measure("tokens", m.tokens.toLong(), GraphqlRequestLimits.MAX_TOKENS), + Measure("request document bytes", m.bodyBytes.toLong(), GraphqlRequestLimits.MAX_BODY_BYTES), + ) + + private fun violations(all: List): List = all.flatMap { m -> + measures(m).filter { it.value > it.threshold }.map { + "${m.kind} ${m.name}: ${it.label} ${it.value} exceeds ${it.threshold} " + + "(${(margin * 100).toInt()}% of the Unraid 7.4 limit ${it.limit})" + } + } + + // ── the formula must be the server's formula ───────────────────────── + + private fun one(src: String) = measureGraphqlDocument(src).single() + + @Test fun cost_countsEveryFieldOnce_andRejectsAboveTheLimitLikeTheServer() { + // WHY: upstream request-protection.spec.ts accepts the limit and rejects limit+1; if our + // numbers drifted by one the margin check would give false comfort. + assertEquals(1_000L, one("{ ${"value ".repeat(1_000)} }").cost) + assertEquals(1_001L, one("{ ${"value ".repeat(1_001)} }").cost) + } + + @Test fun cost_objectFieldIsOnePlusItsChildren() { + assertEquals(1L + 3L, one("{ a { x y z } }").cost) + assertEquals(2L + 2L, one("{ a { x } b { y } }").cost) // two object fields, two leaves + } + + @Test fun cost_fragmentSpreadCostsSpreadPlusFragmentObjectPlusFields() { + // flattenFragments: spread (scalar cost 1) + fragment definition (object cost 1) + its fields. + assertEquals(1L + 1L + 2L, one("{ ...F } fragment F on Q { x y }").cost) + } + + @Test fun cost_firstAndLastIntegerLiteralsMultiply_otherLimitArgumentsDoNot() { + assertEquals((1L + 1L) * 50, one("{ a(first: 50) { x } }").cost) + // WHY: NOVA's GetNotificationList passes `limit: 200`; the server does not multiply by it. + assertEquals(1L + 1L, one("{ a(limit: 200) { x } }").cost) + // A variable is not an IntValue, so the server uses multiplier 1. + assertEquals(1L + 1L, one("query(\$n: Int) { a(first: \$n) { x } }").cost) + } + + @Test fun depth_matchesServerBoundary() { + fun nested(levels: Int) = "{ ${"child { ".repeat(levels)}value${" }".repeat(levels)} }" + assertEquals(20, one(nested(19)).depth) // 19 objects + the leaf = depth 20: still allowed + assertEquals(21, one(nested(20)).depth) // upstream: "rejects deeply nested fields" + } + + @Test fun depth_fragmentsAndInlineFragmentsAddNoLevel() { + assertEquals(2, one("{ a { ...F } } fragment F on A { b }").depth) + assertEquals(2, one("{ a { ... on A { b } } }").depth) + // upstream: "does not count named fragments as field depth" + val chain = (0..20).joinToString(" ") { i -> + "fragment F$i on Query { ${if (i == 20) "value" else "...F${i + 1}"} }" + } + assertEquals(1, one("{ ...F0 } $chain").depth) + } + + @Test fun aliases_countedAtTheLimitBoundaryAndInsideFragments() { + fun aliased(n: Int) = "{ ${(0 until n).joinToString(" ") { "a$it: value" }} }" + assertEquals(100, one(aliased(100)).aliases) + assertEquals(101, one(aliased(101)).aliases) + assertEquals(2, one("{ ...F ...F } fragment F on Q { x: value }").aliases) + // allowList is [] on the server: even an aliased __typename counts. + assertEquals(1, one("{ t: __typename }").aliases) + } + + @Test fun tokens_countPunctuationAndVariablesButNotCommentsOrCommas() { + // query Q ( $ id : ID ! ) { a ( x : 1 ) } = 17 tokens in graphql-js. + assertEquals(17, one("# comment\nquery Q(\$id: ID!) { a(x: 1) }").tokens) + assertEquals(one("{ a b c }").tokens, one("{ a, b, c }").tokens) + assertEquals(8, one("{ a(x: \"s, {}\") }").tokens) // { a ( x : "..." ) } - the string is one token + } + + @Test fun aTooLargeQueryIsCaught() { + // WHY: proves the gate can fail - 800 fields cost 800, above the 70 % margin (700) + // but below the server limit (1,000), which is exactly the case it exists for. + val big = one("query Big { ${"value ".repeat(800)} }") + val found = violations(listOf(big)) + assertTrue(found.toString(), found.any { it.contains("Big") && it.contains("cost 800") }) + } + + // ── what NOVA ships ────────────────────────────────────────────────── + + private fun graphqlDir(): File = + listOf(File("src/main/graphql"), File("app/src/main/graphql")).firstOrNull { it.isDirectory } + ?: error("app/src/main/graphql not found from ${File(".").absolutePath}") + + /** The document exactly as Apollo sends it: generated code, so added `__typename`s are included. */ + private fun wireDocument(kind: String, name: String): String { + val suffix = when (kind) { + "query" -> "Query" + "mutation" -> "Mutation" + else -> "Subscription" + } + val cls = Class.forName("io.github.nofuturekid.nova.graphql.$name$suffix") + // Apollo 5 generates `companion object { val OPERATION_DOCUMENT: String get() = "..." }`. + val companion = cls.getField("Companion").get(null) + return companion.javaClass.getMethod("getOPERATION_DOCUMENT").invoke(companion) as String + } + + @Test fun everyShippedOperation_staysWithinTheMarginOfTheUnraidRequestLimits() { + val files = graphqlDir().walkTopDown().filter { it.isFile && it.extension == "graphql" }.sortedBy { it.path }.toList() + assertTrue("No .graphql documents found", files.isNotEmpty()) + + // Discovery from the source documents (so a new operation cannot be forgotten), numbers + // from the generated wire document (so what the server sees is what is measured). + val declared = files.flatMap { parseGraphqlDocument(it.readText()).operations } + assertTrue("No operations found", declared.isNotEmpty()) + val measured = declared.map { op -> + val name = requireNotNull(op.name) { "Anonymous ${op.kind} in the shipped documents" } + measureGraphqlDocument(wireDocument(op.kind, name)).single() + } + + val table = buildString { + appendLine() + appendLine("GraphQL request limits (Unraid 7.4; margin ${(margin * 100).toInt()}%)") + appendLine("%-34s %-13s %7s %6s %7s %6s %6s".format("operation", "kind", "cost", "depth", "aliases", "tokens", "bytes")) + appendLine( + "%-34s %-13s %7s %6s %7s %6s %6s".format( + "limit (threshold)", "", + "${GraphqlRequestLimits.MAX_COST}(${(GraphqlRequestLimits.MAX_COST * margin).toInt()})", + "${GraphqlRequestLimits.MAX_DEPTH}(${(GraphqlRequestLimits.MAX_DEPTH * margin).toInt()})", + "${GraphqlRequestLimits.MAX_ALIASES}(${(GraphqlRequestLimits.MAX_ALIASES * margin).toInt()})", + "${GraphqlRequestLimits.MAX_TOKENS}(${(GraphqlRequestLimits.MAX_TOKENS * margin).toInt()})", + "1MiB", + ), + ) + measured.forEach { + appendLine("%-34s %-13s %7d %6d %7d %6d %6d".format(it.name, it.kind, it.cost, it.depth, it.aliases, it.tokens, it.bodyBytes)) + } + } + println(table) + + val found = violations(measured) + assertTrue( + "Operations over the safety margin of the Unraid 7.4 request limits " + + "(slim the query down before the server starts refusing it):\n" + found.joinToString("\n") + "\n" + table, + found.isEmpty(), + ) + } +}