diff --git a/build.gradle b/build.gradle
index 0100dc2..6c565a0 100644
--- a/build.gradle
+++ b/build.gradle
@@ -31,6 +31,19 @@ dependencies {
implementation "com.vladsch.flexmark:flexmark-all:0.64.0"
implementation "org.json:json:20250107"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.0'
+
+ // Test dependencies
+ testImplementation 'org.jetbrains.kotlin:kotlin-test:2.1.0'
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
+ testImplementation 'org.mockito:mockito-core:5.11.0'
+ testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1'
+ testImplementation 'com.google.code.gson:gson:2.10.1'
+ testCompileOnly 'net.portswigger.burp.extensions:montoya-api:2023.5'
+ testRuntimeOnly 'net.portswigger.burp.extensions:montoya-api:2023.5'
+}
+
+tasks.test {
+ useJUnitPlatform()
}
java {
@@ -61,6 +74,11 @@ sourceSets {
srcDirs = ['resources']
}
}
+ test {
+ kotlin {
+ srcDir 'src/test/kotlin'
+ }
+ }
}
build {
diff --git a/src/main/kotlin/inql/BurpScannerCheck.kt b/src/main/kotlin/inql/BurpScannerCheck.kt
index 38b2801..2cea0a4 100644
--- a/src/main/kotlin/inql/BurpScannerCheck.kt
+++ b/src/main/kotlin/inql/BurpScannerCheck.kt
@@ -10,6 +10,7 @@ import burp.api.montoya.scanner.audit.insertionpoint.AuditInsertionPoint
import burp.api.montoya.scanner.audit.issues.AuditIssue
import burp.api.montoya.scanner.audit.issues.AuditIssueConfidence
import burp.api.montoya.scanner.audit.issues.AuditIssueSeverity
+import inql.graphql.scanners.BatchScanner
class BurpScannerCheck : ScanCheck {
companion object {
@@ -70,6 +71,76 @@ class BurpScannerCheck : ScanCheck {
val result = Burp.Montoya.http().sendRequest(newReq)
issues.addAll(this.passiveAudit(result).auditIssues())
}
+
+ // --- Batch Query Detection ---
+ val batchScanner = BatchScanner(baseRequestResponse.request())
+ val batchResults = batchScanner.scan()
+
+ for (result in batchResults) {
+ if (result.supported) {
+ val typeLabel = when (result.type) {
+ BatchScanner.BatchType.ALIAS -> "Alias-Based"
+ BatchScanner.BatchType.ARRAY -> "Array-Based"
+ }
+ val description = when (result.type) {
+ BatchScanner.BatchType.ALIAS -> """
+ The GraphQL endpoint supports alias-based query batching.
+ An attacker can combine multiple operations into a single HTTP request using
+ GraphQL aliases (e.g., alias1: fieldName alias2: fieldName).
+ This can be exploited for:
+
+ - Brute-force attacks (e.g., 2FA bypass by sending thousands of codes in one request)
+ - Rate-limit bypass (many operations counted as one HTTP request)
+ - Denial of Service (resource-heavy queries multiplied via aliases)
+
+ """.trimIndent()
+ BatchScanner.BatchType.ARRAY -> """
+ The GraphQL endpoint supports array-based query batching.
+ An attacker can send a JSON array of independent query objects in a single
+ HTTP request. Each query executes separately on the server.
+ This can be exploited for:
+
+ - Brute-force attacks (e.g., thousands of login attempts in one request)
+ - Rate-limit bypass (one HTTP request, many GraphQL operations)
+ - Denial of Service (parallel execution of expensive queries)
+
+ """.trimIndent()
+ }
+
+ val remediation = """
+ Consider implementing:
+
+ - Query cost analysis / depth limiting
+ - Per-operation rate limiting (not just per-HTTP-request)
+ - Disable array batching if not needed (e.g., Apollo Server's
+
allowBatchedHttpRequests: false)
+ - Limit the maximum number of aliases per query
+
+ """.trimIndent()
+
+ issues.add(
+ AuditIssue.auditIssue(
+ "GraphQL $typeLabel Batch Query Support Detected",
+ description,
+ remediation,
+ baseRequestResponse.url(),
+ AuditIssueSeverity.LOW,
+ AuditIssueConfidence.CERTAIN,
+ null,
+ """""",
+ AuditIssueSeverity.LOW,
+ if (result.response != null) {
+ listOf(baseRequestResponse)
+ } else {
+ emptyList()
+ },
+ ),
+ )
+ }
+ }
return AuditResult.auditResult(issues)
}
diff --git a/src/main/kotlin/inql/Config.kt b/src/main/kotlin/inql/Config.kt
index f9b6910..2d743d7 100644
--- a/src/main/kotlin/inql/Config.kt
+++ b/src/main/kotlin/inql/Config.kt
@@ -44,6 +44,11 @@ class Config private constructor() {
"report.poi.depth" to 2,
"report.poi.format" to "text",
+ // Batch scanning
+ "report.batch" to true,
+ "report.batch.alias" to true,
+ "report.batch.array" to true,
+
// hooks on POIScanner.kt
"report.poi.auth" to true,
"report.poi.privileged" to true,
diff --git a/src/main/kotlin/inql/graphql/scanners/BatchScanner.kt b/src/main/kotlin/inql/graphql/scanners/BatchScanner.kt
new file mode 100644
index 0000000..b292274
--- /dev/null
+++ b/src/main/kotlin/inql/graphql/scanners/BatchScanner.kt
@@ -0,0 +1,255 @@
+package inql.graphql.scanners
+
+import burp.Burp
+import burp.api.montoya.http.message.requests.HttpRequest
+import burp.api.montoya.http.message.responses.HttpResponse
+import com.google.gson.Gson
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import inql.Logger
+
+class BatchScanner(private val requestTemplate: HttpRequest) {
+
+ enum class BatchType { ALIAS, ARRAY }
+
+ data class BatchScanResult(
+ val type: BatchType,
+ val supported: Boolean,
+ val statusCode: Int,
+ val response: HttpResponse?,
+ val detail: String,
+ )
+
+ companion object {
+ // Minimal introspection query — guaranteed to work on any GraphQL server
+ private const val PROBE_QUERY = "__typename"
+
+ /**
+ * Build the alias-batched query body.
+ *
+ * Produces:
+ * {
+ * "query": "query { alias1: __typename alias2: __typename }"
+ * }
+ *
+ * If the server resolves both aliases, batching via aliases is supported.
+ */
+ fun buildAliasBatchBody(): String {
+ val obj = JsonObject()
+ obj.addProperty(
+ "query",
+ "query { inql_batch_alias1: $PROBE_QUERY inql_batch_alias2: $PROBE_QUERY }"
+ )
+ return Gson().toJson(obj)
+ }
+
+ /**
+ * Build the array-batched query body.
+ *
+ * Produces:
+ * [
+ * { "query": "query { __typename }" },
+ * { "query": "query { __typename }" }
+ * ]
+ *
+ * If the server returns an array of two results, array batching is supported.
+ */
+ fun buildArrayBatchBody(): String {
+ val arr = JsonArray()
+ for (i in 1..2) {
+ val obj = JsonObject()
+ obj.addProperty("query", "query { $PROBE_QUERY }")
+ arr.add(obj)
+ }
+ return Gson().toJson(arr)
+ }
+
+ /**
+ * Analyze whether the alias batch response succeeded.
+ *
+ * Success criteria:
+ * - HTTP 200
+ * - Body is valid JSON
+ * - `data` object contains both `inql_batch_alias1` and `inql_batch_alias2`
+ * - No `errors` array, OR errors array is empty
+ */
+ fun analyzeAliasBatchResponse(resp: HttpResponse): Pair {
+ if (resp.statusCode() !in 200..299) {
+ return false to "Non-2xx status code: ${resp.statusCode()}"
+ }
+
+ return try {
+ val body = resp.bodyToString()
+ val json = JsonParser.parseString(body)
+
+ if (!json.isJsonObject) {
+ return false to "Response body is not a JSON object"
+ }
+
+ val root = json.asJsonObject
+ val data = root.getAsJsonObject("data")
+ ?: return false to "No 'data' field in response"
+
+ val hasAlias1 = data.has("inql_batch_alias1")
+ val hasAlias2 = data.has("inql_batch_alias2")
+
+ if (hasAlias1 && hasAlias2) {
+ // Check for errors
+ val errors = root.getAsJsonArray("errors")
+ if (errors != null && errors.size() > 0) {
+ false to "Both aliases resolved but errors present: ${errors.size()} error(s)"
+ } else {
+ true to "Both aliases resolved successfully"
+ }
+ } else {
+ false to "Missing aliases in response (alias1=$hasAlias1, alias2=$hasAlias2)"
+ }
+ } catch (e: Exception) {
+ false to "Failed to parse response: ${e.message}"
+ }
+ }
+
+ /**
+ * Analyze whether the array batch response succeeded.
+ *
+ * Success criteria:
+ * - HTTP 200
+ * - Body is a JSON array with exactly 2 elements
+ * - Each element has a `data` object
+ */
+ fun analyzeArrayBatchResponse(resp: HttpResponse): Pair {
+ if (resp.statusCode() !in 200..299) {
+ return false to "Non-2xx status code: ${resp.statusCode()}"
+ }
+
+ return try {
+ val body = resp.bodyToString()
+ val json = JsonParser.parseString(body)
+
+ if (!json.isJsonArray) {
+ // Some servers return a single error object when array batching
+ // is disabled — that's a clear "not supported"
+ if (json.isJsonObject) {
+ val errors = json.asJsonObject.getAsJsonArray("errors")
+ if (errors != null && errors.size() > 0) {
+ return false to "Server returned error object instead of array"
+ }
+ }
+ return false to "Response body is not a JSON array"
+ }
+
+ val arr = json.asJsonArray
+ if (arr.size() != 2) {
+ return false to "Expected 2 results, got ${arr.size()}"
+ }
+
+ val allHaveData = arr.all {
+ it.isJsonObject && it.asJsonObject.has("data")
+ }
+
+ if (allHaveData) {
+ true to "Server returned ${arr.size()} results in array"
+ } else {
+ false to "Array elements missing 'data' field"
+ }
+ } catch (e: Exception) {
+ false to "Failed to parse response: ${e.message}"
+ }
+ }
+ }
+
+ /**
+ * Run both batch probes against the target and return results.
+ */
+ fun scan(): List {
+ val results = mutableListOf()
+
+ // --- Alias Batching ---
+ try {
+ val aliasBody = buildAliasBatchBody()
+ val aliasReq = requestTemplate
+ .withHeader("Content-Type", "application/json")
+ .withBody(aliasBody)
+
+ Logger.debug("BatchScanner: sending alias batch probe to ${aliasReq.url()}")
+ val aliasResp = Burp.Montoya.http().sendRequest(aliasReq)
+ val resp = aliasResp.response()
+
+ val (supported, detail) = analyzeAliasBatchResponse(resp)
+ results.add(
+ BatchScanResult(
+ type = BatchType.ALIAS,
+ supported = supported,
+ statusCode = resp.statusCode().toInt(),
+ response = resp,
+ detail = detail,
+ )
+ )
+ Logger.info("BatchScanner: alias batching ${if (supported) "SUPPORTED" else "not supported"} — $detail")
+ } catch (e: Exception) {
+ Logger.error("BatchScanner: alias probe failed: ${e.message}")
+ results.add(
+ BatchScanResult(
+ type = BatchType.ALIAS,
+ supported = false,
+ statusCode = 0,
+ response = null,
+ detail = "Request failed: ${e.message}",
+ )
+ )
+ }
+
+ // --- Array Batching ---
+ try {
+ val arrayBody = buildArrayBatchBody()
+ val arrayReq = requestTemplate
+ .withHeader("Content-Type", "application/json")
+ .withBody(arrayBody)
+
+ Logger.debug("BatchScanner: sending array batch probe to ${arrayReq.url()}")
+ val arrayResp = Burp.Montoya.http().sendRequest(arrayReq)
+ val resp = arrayResp.response()
+
+ val (supported, detail) = analyzeArrayBatchResponse(resp)
+ results.add(
+ BatchScanResult(
+ type = BatchType.ARRAY,
+ supported = supported,
+ statusCode = resp.statusCode().toInt(),
+ response = resp,
+ detail = detail,
+ )
+ )
+ Logger.info("BatchScanner: array batching ${if (supported) "SUPPORTED" else "not supported"} — $detail")
+ } catch (e: Exception) {
+ Logger.error("BatchScanner: array probe failed: ${e.message}")
+ results.add(
+ BatchScanResult(
+ type = BatchType.ARRAY,
+ supported = false,
+ statusCode = 0,
+ response = null,
+ detail = "Request failed: ${e.message}",
+ )
+ )
+ }
+
+ return results
+ }
+
+ /**
+ * Return a human-readable summary of batch scan results.
+ */
+ fun scanAsString(): String {
+ val results = scan()
+ return results.joinToString("\n") { r ->
+ val status = if (r.supported) "✓ SUPPORTED" else "✗ Not supported"
+ val typeLabel = when (r.type) {
+ BatchType.ALIAS -> "Alias Batching"
+ BatchType.ARRAY -> "Array Batching"
+ }
+ "$typeLabel: $status (HTTP ${r.statusCode}) — ${r.detail}"
+ }
+ }
+}
diff --git a/src/main/kotlin/inql/scanner/scanresults/ScanResultsTreeNode.kt b/src/main/kotlin/inql/scanner/scanresults/ScanResultsTreeNode.kt
index 65b0738..44feeac 100644
--- a/src/main/kotlin/inql/scanner/scanresults/ScanResultsTreeNode.kt
+++ b/src/main/kotlin/inql/scanner/scanresults/ScanResultsTreeNode.kt
@@ -3,6 +3,7 @@ package inql.scanner.scanresults
import com.google.gson.Gson
import inql.Config
import inql.graphql.GQLSchema
+import inql.graphql.scanners.BatchScanner
import inql.graphql.scanners.CyclesScanner
import inql.graphql.scanners.POIScanner
import inql.graphql.scanners.POIScanner.Companion.getActiveKeywordsCount
@@ -199,6 +200,15 @@ class ScanResultTreeNode(val scanResult: ScanResult) :
this.add(CycleDetectionLazyNode("Cycle Detection", this.scanResult, gqlSchema))
}
+ // Batch Query Detection
+ if (config.getBoolean("report.batch") == true) {
+ val batchNode = LazyLeafTreeNode("Batch Query Detection") {
+ val batchScanner = BatchScanner(scanResult.requestTemplate)
+ batchScanner.scanAsString().ifBlank { "" }
+ }
+ this.add(batchNode)
+ }
+
// Add request template
this.add(TreeNodeWithCustomLabel("Request Template", scanResult.requestTemplate.withBody("").toString()))
diff --git a/src/test/kotlin/inql/graphql/scanners/BatchScannerTest.kt b/src/test/kotlin/inql/graphql/scanners/BatchScannerTest.kt
new file mode 100644
index 0000000..f063cb9
--- /dev/null
+++ b/src/test/kotlin/inql/graphql/scanners/BatchScannerTest.kt
@@ -0,0 +1,388 @@
+package inql.graphql.scanners
+
+import burp.api.montoya.http.message.responses.HttpResponse
+import com.google.gson.*
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.`when`
+
+class BatchScannerTest {
+
+ // ──────────────────────────────────────────────
+ // Helper: create a mock HttpResponse
+ // ──────────────────────────────────────────────
+ private fun mockResponse(statusCode: Short, body: String): HttpResponse {
+ val resp = mock(HttpResponse::class.java)
+ `when`(resp.statusCode()).thenReturn(statusCode)
+ `when`(resp.bodyToString()).thenReturn(body)
+ return resp
+ }
+
+ // ══════════════════════════════════════════════
+ // PAYLOAD GENERATION
+ // ══════════════════════════════════════════════
+
+ @Nested
+ @DisplayName("Alias batch payload generation")
+ inner class AliasBatchPayload {
+
+ @Test
+ fun `payload is valid JSON with a query field`() {
+ val body = BatchScanner.buildAliasBatchBody()
+ val json = JsonParser.parseString(body)
+ assertTrue(json.isJsonObject)
+ assertTrue(json.asJsonObject.has("query"))
+ }
+
+ @Test
+ fun `query contains both alias names`() {
+ val body = BatchScanner.buildAliasBatchBody()
+ val query = JsonParser.parseString(body).asJsonObject["query"].asString
+ assertTrue(query.contains("inql_batch_alias1"))
+ assertTrue(query.contains("inql_batch_alias2"))
+ }
+
+ @Test
+ fun `query uses __typename as the probe field`() {
+ val body = BatchScanner.buildAliasBatchBody()
+ val query = JsonParser.parseString(body).asJsonObject["query"].asString
+ assertTrue(query.contains("__typename"))
+ }
+ }
+
+ @Nested
+ @DisplayName("Array batch payload generation")
+ inner class ArrayBatchPayload {
+
+ @Test
+ fun `payload is a valid JSON array`() {
+ val body = BatchScanner.buildArrayBatchBody()
+ val json = JsonParser.parseString(body)
+ assertTrue(json.isJsonArray)
+ }
+
+ @Test
+ fun `array contains exactly 2 elements`() {
+ val body = BatchScanner.buildArrayBatchBody()
+ val arr = JsonParser.parseString(body).asJsonArray
+ assertEquals(2, arr.size())
+ }
+
+ @Test
+ fun `each element has a query field`() {
+ val body = BatchScanner.buildArrayBatchBody()
+ val arr = JsonParser.parseString(body).asJsonArray
+ arr.forEach { elem ->
+ assertTrue(elem.isJsonObject)
+ assertTrue(elem.asJsonObject.has("query"))
+ }
+ }
+
+ @Test
+ fun `each query contains __typename`() {
+ val body = BatchScanner.buildArrayBatchBody()
+ val arr = JsonParser.parseString(body).asJsonArray
+ arr.forEach { elem ->
+ val query = elem.asJsonObject["query"].asString
+ assertTrue(query.contains("__typename"))
+ }
+ }
+ }
+
+ // ══════════════════════════════════════════════
+ // ALIAS BATCH RESPONSE ANALYSIS
+ // ══════════════════════════════════════════════
+
+ @Nested
+ @DisplayName("Alias batch response analysis")
+ inner class AliasBatchAnalysis {
+
+ @Test
+ fun `detects supported when both aliases present in data`() {
+ val body = """
+ {
+ "data": {
+ "inql_batch_alias1": "Query",
+ "inql_batch_alias2": "Query"
+ }
+ }
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, _) = BatchScanner.analyzeAliasBatchResponse(resp)
+ assertTrue(supported)
+ }
+
+ @Test
+ fun `detects not supported when only one alias present`() {
+ val body = """
+ {
+ "data": {
+ "inql_batch_alias1": "Query"
+ }
+ }
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, detail) = BatchScanner.analyzeAliasBatchResponse(resp)
+ assertFalse(supported)
+ assertTrue(detail.contains("alias2=false"))
+ }
+
+ @Test
+ fun `detects not supported on HTTP 400`() {
+ val resp = mockResponse(400, """{"errors": [{"message": "bad request"}]}""")
+ val (supported, detail) = BatchScanner.analyzeAliasBatchResponse(resp)
+ assertFalse(supported)
+ assertTrue(detail.contains("400"))
+ }
+
+ @Test
+ fun `detects not supported on HTTP 500`() {
+ val resp = mockResponse(500, "Internal Server Error")
+ val (supported, _) = BatchScanner.analyzeAliasBatchResponse(resp)
+ assertFalse(supported)
+ }
+
+ @Test
+ fun `detects not supported when response is not JSON`() {
+ val resp = mockResponse(200, "Not Found")
+ val (supported, detail) = BatchScanner.analyzeAliasBatchResponse(resp)
+ assertFalse(supported)
+ assertTrue(detail.contains("parse") || detail.contains("not a JSON"))
+ }
+
+ @Test
+ fun `detects not supported when data field is missing`() {
+ val body = """{"errors": [{"message": "syntax error"}]}"""
+ val resp = mockResponse(200, body)
+ val (supported, detail) = BatchScanner.analyzeAliasBatchResponse(resp)
+ assertFalse(supported)
+ assertTrue(detail.contains("No 'data' field"))
+ }
+
+ @Test
+ fun `reports warning when aliases resolve but errors are present`() {
+ val body = """
+ {
+ "data": {
+ "inql_batch_alias1": "Query",
+ "inql_batch_alias2": "Query"
+ },
+ "errors": [{"message": "rate limited"}]
+ }
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, detail) = BatchScanner.analyzeAliasBatchResponse(resp)
+ assertFalse(supported)
+ assertTrue(detail.contains("errors present"))
+ }
+
+ @Test
+ fun `succeeds when errors array is empty`() {
+ val body = """
+ {
+ "data": {
+ "inql_batch_alias1": "Query",
+ "inql_batch_alias2": "Query"
+ },
+ "errors": []
+ }
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, _) = BatchScanner.analyzeAliasBatchResponse(resp)
+ assertTrue(supported)
+ }
+
+ @Test
+ fun `handles null data values gracefully`() {
+ val body = """
+ {
+ "data": {
+ "inql_batch_alias1": null,
+ "inql_batch_alias2": null
+ }
+ }
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, _) = BatchScanner.analyzeAliasBatchResponse(resp)
+ assertTrue(supported, "Null values still indicate the server processed both aliases")
+ }
+ }
+
+ // ══════════════════════════════════════════════
+ // ARRAY BATCH RESPONSE ANALYSIS
+ // ══════════════════════════════════════════════
+
+ @Nested
+ @DisplayName("Array batch response analysis")
+ inner class ArrayBatchAnalysis {
+
+ @Test
+ fun `detects supported when response is array of 2 results`() {
+ val body = """
+ [
+ {"data": {"__typename": "Query"}},
+ {"data": {"__typename": "Query"}}
+ ]
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, _) = BatchScanner.analyzeArrayBatchResponse(resp)
+ assertTrue(supported)
+ }
+
+ @Test
+ fun `detects not supported when server returns single object`() {
+ val body = """{"data": {"__typename": "Query"}}"""
+ val resp = mockResponse(200, body)
+ val (supported, detail) = BatchScanner.analyzeArrayBatchResponse(resp)
+ assertFalse(supported)
+ assertTrue(detail.contains("not a JSON array"))
+ }
+
+ @Test
+ fun `detects not supported when server returns error object`() {
+ val body = """
+ {
+ "errors": [{"message": "Batching is not allowed"}]
+ }
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, detail) = BatchScanner.analyzeArrayBatchResponse(resp)
+ assertFalse(supported)
+ assertTrue(detail.contains("error object"))
+ }
+
+ @Test
+ fun `detects not supported on HTTP 400`() {
+ val resp = mockResponse(400, "Bad Request")
+ val (supported, detail) = BatchScanner.analyzeArrayBatchResponse(resp)
+ assertFalse(supported)
+ assertTrue(detail.contains("400"))
+ }
+
+ @Test
+ fun `detects not supported when array has wrong count`() {
+ val body = """
+ [
+ {"data": {"__typename": "Query"}}
+ ]
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, detail) = BatchScanner.analyzeArrayBatchResponse(resp)
+ assertFalse(supported)
+ assertTrue(detail.contains("Expected 2"))
+ }
+
+ @Test
+ fun `detects not supported when array elements lack data field`() {
+ val body = """
+ [
+ {"errors": [{"message": "fail"}]},
+ {"errors": [{"message": "fail"}]}
+ ]
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, detail) = BatchScanner.analyzeArrayBatchResponse(resp)
+ assertFalse(supported)
+ assertTrue(detail.contains("missing 'data'"))
+ }
+
+ @Test
+ fun `handles empty response body`() {
+ val resp = mockResponse(200, "")
+ val (supported, detail) = BatchScanner.analyzeArrayBatchResponse(resp)
+ assertFalse(supported)
+ assertTrue(detail.contains("parse") || detail.contains("not a JSON"))
+ }
+
+ @Test
+ fun `handles HTTP 405 Method Not Allowed`() {
+ val resp = mockResponse(405, "Method Not Allowed")
+ val (supported, _) = BatchScanner.analyzeArrayBatchResponse(resp)
+ assertFalse(supported)
+ }
+
+ @Test
+ fun `accepts response with extra fields in array elements`() {
+ val body = """
+ [
+ {"data": {"__typename": "Query"}, "extensions": {"tracing": {}}},
+ {"data": {"__typename": "Query"}, "extensions": {"tracing": {}}}
+ ]
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, _) = BatchScanner.analyzeArrayBatchResponse(resp)
+ assertTrue(supported)
+ }
+ }
+
+ // ══════════════════════════════════════════════
+ // EDGE CASES & CROSS-CUTTING
+ // ══════════════════════════════════════════════
+
+ @Nested
+ @DisplayName("Edge cases")
+ inner class EdgeCases {
+
+ @Test
+ fun `alias analysis handles deeply nested data`() {
+ val body = """
+ {
+ "data": {
+ "inql_batch_alias1": {"nested": {"deep": true}},
+ "inql_batch_alias2": {"nested": {"deep": true}}
+ }
+ }
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, _) = BatchScanner.analyzeAliasBatchResponse(resp)
+ assertTrue(supported)
+ }
+
+ @Test
+ fun `alias analysis handles numeric values`() {
+ val body = """
+ {
+ "data": {
+ "inql_batch_alias1": 42,
+ "inql_batch_alias2": 99
+ }
+ }
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, _) = BatchScanner.analyzeAliasBatchResponse(resp)
+ assertTrue(supported)
+ }
+
+ @Test
+ fun `array analysis handles mixed success and error elements`() {
+ // Both elements have "data", even though one also has errors
+ val body = """
+ [
+ {"data": {"__typename": "Query"}},
+ {"data": null, "errors": [{"message": "not found"}]}
+ ]
+ """.trimIndent()
+ val resp = mockResponse(200, body)
+ val (supported, _) = BatchScanner.analyzeArrayBatchResponse(resp)
+ // Still counts as supported — server processed the array
+ assertTrue(supported)
+ }
+
+ @Test
+ fun `alias payload is stable across invocations`() {
+ val body1 = BatchScanner.buildAliasBatchBody()
+ val body2 = BatchScanner.buildAliasBatchBody()
+ assertEquals(body1, body2)
+ }
+
+ @Test
+ fun `array payload is stable across invocations`() {
+ val body1 = BatchScanner.buildArrayBatchBody()
+ val body2 = BatchScanner.buildArrayBatchBody()
+ assertEquals(body1, body2)
+ }
+ }
+}