# beacon_chain # Copyright (c) 2021-2024 Status Research & Development GmbH # Licensed and distributed under either of # * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT). # * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0). # at your option. This file may not be copied, modified, or distributed except according to those terms. import std/[strutils, os, options, uri, json, tables], results, stew/[io2, base10], confutils, chronicles, httputils, chronos, chronos/streams/[asyncstream, tlsstream] const RestTesterName* = "Ethereum2 REST API Tester" RestTesterMajor*: int = 0 RestTesterMinor*: int = 0 RestTesterPatch*: int = 1 RestTesterVersion* = $RestTesterMajor & "." & $RestTesterMinor & "." & $RestTesterPatch RestTesterIdent* = "RestTester/$1 ($2/$3)" % [RestTesterVersion, hostCPU, hostOS] RestTesterCopyright* = "Copyright(C) 2021-2022" & " Status Research & Development GmbH" RestTesterHeader* = RestTesterName & ", Version " & RestTesterVersion & " [" & hostOS & ": " & hostCPU & "]\r\n" & RestTesterCopyright & "\r\n" HeadersMark = @[0x0D'u8, 0x0A'u8, 0x0D'u8, 0x0A'u8] type StatusOperatorKind* {.pure.} = enum Equals, OneOf, Inside, InsideOrEq HeaderOperatorKind {.pure.} = enum Exists, NotExists, Equals, OneOf, Substr BodyOperatorKind {.pure.} = enum Exists, JsonStructCmpS, JsonStructCmpNS, JsonStructCmpSAV, JsonStructCmpNSAV StatusExpect = object kind: StatusOperatorKind value: seq[int] HeaderExpect = object kind: HeaderOperatorKind key: string value: seq[string] HeadersExpect = object headers: seq[HeaderExpect] BodyItemExpect = object kind: BodyOperatorKind startPath: seq[string] value: JsonNode BodyExpect = object items: seq[BodyItemExpect] TestResultKind* {.pure.} = enum RuleError, NoSupportError, WriteRequestError, ReadResponseHeadersError, ReadResponseBodyError, RequestError, ResponseError, ValidationError, ValidationSuccess TestResultFlag* {.pure.} = enum ResetConnection, StatusValidationFailure, HeadersValidationFailure, BodyValidationFailure TestResult* = object kind: TestResultKind message: string flags: set[TestResultFlag] times: array[4, Duration] TestCase* = object index: int rule: JsonNode TestCaseResult* = object index: int data: TestResult HttpConnectionType* {.pure.} = enum Nonsecure, Secure HttpConnectionRef* = ref object case kind*: HttpConnectionType of HttpConnectionType.Nonsecure: discard of HttpConnectionType.Secure: stream: TLSAsyncStream treader: AsyncStreamReader twriter: AsyncStreamWriter transp: StreamTransport reader*: AsyncStreamReader writer*: AsyncStreamWriter RestTestError* = object of CatchableError ConnectionError* = object of RestTestError RestTesterConf* = object delayTime* {. defaultValue: 0 desc: "Time (in seconds) to wait before initial connection could be " & "established" abbr: "d" name: "delay" .}: int attemptTimeout* {. defaultValue: 60 desc: "Time (in seconds) during which to continue trying to establish " & "connection with remote server" name: "timeout" .}: int noVerifyCert* {. defaultValue: false desc: "Skip remote server SSL/TLS certificate validation" name: "no-verify-host" .}: bool noVerifyName* {. defaultValue: false desc: "Skep remote server name verification" name: "no-verify-name" .}: bool rulesFilename* {. defaultValue: "resttest-rules.json", desc: "JSON formatted tests file" name: "rules-file" .}: string topicsFilter* {. desc: "Topics which should be included in testing" name: "topic", abbr: "t" .}: seq[string] skipTopicsFilter* {. desc: "Topics which should be skipped in testing" name: "skip-topic", abbr: "s" .}: seq[string] connectionsCount* {. defaultValue: 1 desc: "Number of concurrent connections to remote server" name: "connections" abbr: "c" .}: int url* {. argument, desc: "Address of remote REST server to test" .}: string proc getUri(conf: RestTesterConf): Result[Uri, cstring] = var res = parseUri(conf.url) if res.scheme notin ["http", "https"]: return err("URL scheme should be http or https") if len(res.hostname) == 0: return err("URL missing hostname") if len(res.query) != 0: return err("URL should not contain query parameters") if len(res.anchor) != 0: return err("URL should not contain anchor parameter") # TODO: Disable this check when at least BASIC AUTH will be implemented. if len(res.username) != 0 or len(res.password) != 0: return err("URL should not contain username:password") if len(res.port) == 0: if res.scheme == "http": res.port = "80" else: res.port = "443" ok(res) proc getAddress(uri: Uri): Result[TransportAddress, cstring] = let txtaddr = uri.hostname & ":" & uri.port let numeric = try: initTAddress(txtaddr) except TransportAddressError: # We ignore errors here because `hostname` address could be non-numeric. TransportAddress() if numeric.family in {AddressFamily.IPv4, AddressFamily.IPv6}: ok(numeric) else: var default: seq[TransportAddress] let v4addresses = try: resolveTAddress(txtaddr, AddressFamily.IPv4) except TransportAddressError: # We ignore errors here because `hostname` could be resolved to IPv6. default if len(v4addresses) > 0: ok(v4addresses[0]) else: let v6addresses = try: resolveTAddress(txtaddr, AddressFamily.IPv6) except TransportAddressError: return err("Unable to resolve hostname") if len(v6addresses) == 0: return err("Unable to resolve hostname") ok(v6addresses[0]) proc getTLSFlags(conf: RestTesterConf): set[TLSFlags] = var res: set[TLSFlags] if conf.noVerifyName: res.incl(TLSFlags.NoVerifyServerName) if conf.noVerifyCert: res.incl(TLSFlags.NoVerifyHost) res proc checkTopic(conf: RestTesterConf, rule: JsonNode): bool = var default: seq[string] if (len(conf.topicsFilter) == 0) and (len(conf.skipTopicsFilter) == 0): true else: let topics = block: let jtopics = rule.getOrDefault("topics") if isNil(jtopics): default else: case jtopics.kind of JString: @[jtopics.str] of JArray: if len(jtopics.elems) == 0: default else: var res: seq[string] for jitem in jtopics.elems: case jitem.kind: of JString: res.add(jitem.str) else: continue res else: default if len(conf.topicsFilter) == 0: if len(topics) == 0: true else: for item in topics: if item in conf.skipTopicsFilter: return false true else: for item in topics: if item in conf.skipTopicsFilter: return false for item in topics: if item in conf.topicsFilter: return true false proc getTestRules(conf: RestTesterConf): Result[seq[JsonNode], cstring] = let data = block: let res = io2.readAllChars(conf.rulesFilename) if res.isErr(): fatal "Could not read rules file", error_msg = ioErrorMsg(res.error()), error_os_code = $res.error(), filename = conf.rulesFilename return err("Unable to read rules file") res.get() let node = try: parseJson(data) except CatchableError as exc: fatal "JSON processing error while reading rules file", error_msg = exc.msg, filename = conf.rulesFilename return err("Unable to parse json") except Exception as exc: raiseAssert exc.msg let elems = node.getElems() if len(elems) == 0: fatal "There empty array of rules found in file", filename = conf.rulesFilename return err("Incorrect json") var res: seq[JsonNode] for item in elems: if conf.checkTopic(item): res.add(item) notice "Rules file loaded", total_rules_count = len(elems), rules_count = len(res) ok(res) proc openConnection*(address: TransportAddress, uri: Uri, flags: set[TLSFlags]): Future[HttpConnectionRef] {.async.} = let transp = try: await connect(address) except TransportOsError: raise newException(ConnectionError, "Unable to establish connection") let treader = newAsyncStreamReader(transp) let twriter = newAsyncStreamWriter(transp) if uri.scheme == "http": return HttpConnectionRef( kind: HttpConnectionType.Nonsecure, transp: transp, reader: treader, writer: twriter ) else: let tlsstream = newTLSClientAsyncStream(treader, twriter, uri.hostname, flags = flags) return HttpConnectionRef( kind: HttpConnectionType.Secure, transp: transp, reader: tlsstream.reader, writer: tlsstream.writer, treader: treader, twriter: twriter ) proc closeWait*(conn: HttpConnectionRef): Future[void] {.async.} = case conn.kind of HttpConnectionType.Nonsecure: await allFutures(conn.reader.closeWait(), conn.writer.closeWait()) await conn.transp.closeWait() of HttpConnectionType.Secure: await allFutures(conn.reader.closeWait(), conn.writer.closeWait()) await allFutures(conn.treader.closeWait(), conn.twriter.closeWait()) await conn.transp.closeWait() proc checkConnection*(conf: RestTesterConf, uri: Uri): Future[void] {.async.} = let timeFut = sleepAsync(conf.attemptTimeout.seconds) var sleepTime = 1000.milliseconds let hostname = uri.hostname & ":" & uri.port while true: if timeFut.finished(): fatal "Connection with remote host could not be established in time", uri = uri.hostname, time = $conf.attemptTimeout.seconds raise newException(ConnectionError, "Unable to establish connection") let address = block: let res = uri.getAddress() if res.isErr(): fatal "Unable to resolve remote host address", host = hostname raise newException(ConnectionError, "Unable to resolve address") else: res.get() let conn = try: await openConnection(address, uri, conf.getTLSFlags()) except ConnectionError: notice "Unable to establish connection with remote host", host = hostname, sleep_until_next_attempt = $(((sleepTime * 3) div 2).seconds) nil if not(isNil(conn)): notice "Connection with remote host established", host = hostname await closeWait(conn) return if timeFut.finished(): fatal "Connection with remote host could not be established in time", uri = hostname, time = $conf.attemptTimeout.seconds raise newException(ConnectionError, "Unable to establish connection") await sleepAsync(sleepTime) # Increasing sleep time by 50%. sleepTime = (sleepTime * 3) div 2 proc compact(v: string, size: int): string = let delim = "..." doAssert(size >= (len(delim) + 2)) if len(v) <= size: v else: var length1 = (size - len(delim)) div 2 var length2 = size - length1 - len(delim) if length1 < length2: swap(length1, length2) v[0 .. (length1 - 1)] & delim & v[len(v) - length2 .. ^1] proc getTestName(rule: JsonNode): string = let request = rule.getOrDefault("request") if isNil(request): "[incorrect]" else: let juri = request.getOrDefault("url") if isNil(juri): "[incorrect]" else: compact(juri.str, 40) proc prepareRequest(uri: Uri, rule: JsonNode): Result[tuple[url: string, request: string], cstring] = let request = rule.getOrDefault("request") if isNil(request): return err("Missing `request` field") let meth = block: let jmethod = request.getOrDefault("method") if isNil(jmethod): "GET" else: if jmethod.kind != JString: return err("Field `method` should be string") jmethod.str let requestUri = block: let juri = request.getOrDefault("url") if isNil(juri): return err("Missing requests' `url`") else: if juri.kind != JString: return err("Field `url` should be string") juri.str let requestHeaders = block: var default: seq[tuple[key: string, value: string]] let jheaders = request.getOrDefault("headers") if isNil(jheaders): default else: var res: seq[tuple[key: string, value: string]] if jheaders.kind != JObject: return err("Field `headers` should be an object") for key, value in jheaders.fields: if value.kind != JString: return err("Field `headers` element should be only strings") res.add((key, value.str)) res let (requestBodyType, requestBodyData) = block: let jbody = request.getOrDefault("body") if isNil(jbody): ("", "") else: if jbody.kind != JObject: return err("Field `body` should be object") let btype = jbody.getOrDefault("content-type") if isNil(btype): return err("Field `body.content-type` must be present") if btype.kind != JString: return err("Field `body.content-type` should be string") let bdata = jbody.getOrDefault("data") if isNil(bdata): return err("Field `body.data` must be present") if bdata.kind != JString: return err("Field `body.data` should be string") (btype.str, bdata.str) var res = meth & " " & uri.path & requestUri & " HTTP/1.1\r\n" res.add("Content-Length: " & Base10.toString(uint64(len(requestBodyData))) & "\r\n") if len(requestBodyType) > 0: res.add("Content-Type: " & requestBodyType & "\r\n") for item in requestHeaders: res.add(item.key & ": " & item.value & "\r\n") let (hostPresent, datePresent) = block: var flag1 = false var flag2 = false for item in requestHeaders: if cmpIgnoreCase(item.key, "host") == 0: flag1 = true elif cmpIgnoreCase(item.key, "date") == 0: flag2 = true (flag1, flag2) if not(hostPresent): res.add("Host: " & $uri.hostname & "\r\n") if not(datePresent): res.add("Date: " & httpDate() & "\r\n") res.add("\r\n") if len(requestBodyData) > 0: res.add(requestBodyData) ok((uri.path & requestUri, res)) proc getResponseStatusExpect(rule: JsonNode): Result[StatusExpect, cstring] = let response = rule.getOrDefault("response") if isNil(response): return err("Missing `response` field") let jstatus = response.getOrDefault("status") if isNil(jstatus): return err("Missing `response.status` field") let value = block: var res: seq[int] let jvalue = jstatus.getOrDefault("value") if isNil(jvalue): return err("Field `status.value` should be present") case jvalue.kind of JString: let nres = Base10.decode(uint16, jvalue.str) if nres.isErr(): return err("Field `status.value` has incorrect value") res.add(int(nres.get())) of JInt: if jvalue.num < 0 or jvalue.num >= 1000: return err("Field `status.value` has incorrect value") res.add(int(jvalue.num)) of JArray: if len(jvalue.elems) == 0: return err("Field `status.value` has an empty array") for jitem in jvalue.elems: let iitem = case jitem.kind of JString: let nres = Base10.decode(uint16, jitem.str) if nres.isErr(): return err("Field `status.value` element has incorrect value") int(nres.get()) of JInt: if jitem.num < 0 or jitem.num >= 1000: return err("Field `status.value` element has incorrect value") int(jitem.num) else: return err("Field `status.value` has incorrect elements") res.add(iitem) else: return err("Field `status.value` should be an array, string or integer") res let kind = block: let joperator = jstatus.getOrDefault("operator") if isNil(joperator): if len(value) > 1: StatusOperatorKind.OneOf else: StatusOperatorKind.Equals else: if joperator.kind != JString: return err("Field `status.operator` should be string") case toLowerAscii(joperator.str) of "equals": StatusOperatorKind.Equals of "oneof": StatusOperatorKind.OneOf of "insideoreq": StatusOperatorKind.InsideOrEq of "inside": StatusOperatorKind.Inside else: return err("Field `status.operator` has unknown or empty value") ok(StatusExpect(kind: kind, value: value)) proc getResponseHeadersExpect(rule: JsonNode): Result[HeadersExpect, cstring] = let response = rule.getOrDefault("response") if isNil(response): return err("Missing `response` field") let jheaders = response.getOrDefault("headers") if isNil(jheaders): return ok(HeadersExpect()) if jheaders.kind != JArray: return err("`response.headers` should be array") if len(jheaders.elems) == 0: return ok(HeadersExpect()) var res: seq[HeaderExpect] for jitem in jheaders.elems: if jitem.kind != JObject: return err("`response.headers` elements should be objects") let jkey = jitem.getOrDefault("key") if isNil(jkey) or jkey.kind != JString: continue let key = jkey.str let operator = block: let jop = jitem.getOrDefault("operator") if isNil(jop) or jop.kind != JString: HeaderOperatorKind.Exists else: case toLowerAscii(jop.str) of "exists": HeaderOperatorKind.Exists of "notexists": HeaderOperatorKind.NotExists of "equals": HeaderOperatorKind.Equals of "oneof": HeaderOperatorKind.OneOf of "substr": HeaderOperatorKind.Substr else: return err("`response.header` element has incorrect operator") let value = block: var vres: seq[string] let jvalue = jitem.getOrDefault("value") if not isnil(jvalue): case jvalue.kind of JArray: if len(jvalue.elems) == 0: return err("`response.header` element has an empty array value") for jelem in jvalue.elems: case jelem.kind of JString: vres.add(jelem.str) of JInt: vres.add(Base10.toString(uint64(jvalue.num))) else: return err("`response.header` element has incorrect value") of JString: vres.add(jvalue.str) of JInt: vres.add(Base10.toString(uint64(jvalue.num))) else: return err("`response.header` element has incorrect value") vres res.add(HeaderExpect(key: key, value: value, kind: operator)) ok(HeadersExpect(headers: res)) proc getResponseBodyExpect(rule: JsonNode): Result[BodyExpect, cstring] = let response = rule.getOrDefault("response") if isNil(response): return err("Missing `response` field") let jbody = response.getOrDefault("body") if isNil(jbody): return ok(BodyExpect()) if jbody.kind != JArray: return err("`response.body` should be array") if len(jbody.elems) == 0: return ok(BodyExpect()) var res: seq[BodyItemExpect] for jitem in jbody.elems: if jitem.kind != JObject: return err("`response.body` elements should be objects") let operator = block: let jop = jitem.getOrDefault("operator") if isNil(jop) or jop.kind != JString: BodyOperatorKind.Exists else: case toLowerAscii(jop.str) of "exists": BodyOperatorKind.Exists of "jstructcmps": BodyOperatorKind.JsonStructCmpS of "jstructcmpns": BodyOperatorKind.JsonStructCmpNS of "jstructcmpsav": BodyOperatorKind.JsonStructCmpSAV of "jstructcmpnsav": BodyOperatorKind.JsonStructCmpNSAV else: return err("`response.body` element has incorrect operator") case operator of BodyOperatorKind.Exists: res.add(BodyItemExpect(kind: operator)) of BodyOperatorKind.JsonStructCmpS, BodyOperatorKind.JsonStructCmpNS, BodyOperatorKind.JsonStructCmpSAV, BodyOperatorKind.JsonStructCmpNSAV: let start = block: var default: seq[string] var rstart: seq[string] let jstart = jitem.getOrDefault("start") if isNil(jstart): default else: case jstart.kind of JString: rstart.add(jstart.str) of JArray: if len(jstart.elems) != 0: for elem in jstart.elems: case elem.kind of JString: rstart.add(elem.str) else: return err("`response.body` element has incorrect `start`" & " option") else: return err("`response.body` element has incorrect `start` option") rstart let body = block: let jvalue = jitem.getOrDefault("value") if jvalue.isNil(): return err("`response.body` element has incorrect `value` option") jvalue res.add(BodyItemExpect(kind: operator, startPath: start, value: body)) ok(BodyExpect(items: res)) proc validateStatus(status: int, expect: StatusExpect): bool = case expect.kind of StatusOperatorKind.Equals: expect.value[0] == status of StatusOperatorKind.OneOf: status in expect.value of StatusOperatorKind.InsideOrEq: if len(expect.value) < 2: status >= expect.value[0] else: status >= expect.value[0] and status <= expect.value[1] of StatusOperatorKind.Inside: if len(expect.value) < 2: status > expect.value[0] else: status > expect.value[0] and status < expect.value[1] proc validateHeaders(resp: HttpResponseHeader, expect: HeadersExpect): bool = if len(expect.headers) == 0: true else: for item in expect.headers: case item.kind of HeaderOperatorKind.Exists: if item.key notin resp: return false of HeaderOperatorKind.NotExists: if item.key in resp: return false of HeaderOperatorKind.Equals: if item.key notin resp: return false let v = resp[item.key] if cmpIgnoreCase(v, item.value[0]) != 0: return false of HeaderOperatorKind.OneOf: if item.key notin resp: return false let v = resp[item.key] var r = false for citem in item.value: if cmpIgnoreCase(citem, v) == 0: r = true break if not(r): return false of HeaderOperatorKind.Substr: if item.key notin resp: return false let v = resp[item.key] if strutils.find(v, item.value[0]) < 0: return false true proc jsonBody(body: openArray[byte]): Result[JsonNode, cstring] = var sbody = cast[string](@body) let res = try: parseJson(sbody) except CatchableError: return err("Unable to parse json") except Exception as exc: raiseAssert exc.msg ok(res) proc getPath(jobj: JsonNode, path: seq[string]): Result[JsonNode, cstring] = var jnode = jobj for item in path: let jitem = jnode.getOrDefault(item) if isNil(jitem): return err("Path not found") jnode = jitem ok(jnode) proc structCmp(j1, j2: JsonNode, strict: bool, checkvalue: bool): bool = if j1.kind != j2.kind: return false case j1.kind of JArray: # In case of array we checking first element of `expect` with all the # elements in `result`. if len(j1.elems) == 0: true else: if len(j2.elems) == 0: false else: for item in j1.elems: if not(structCmp(item, j2.elems[0], strict, checkvalue)): return false true of JObject: if strict: if len(j1.fields) != len(j2.fields): return false for key, value in j1.fields: let j2node = j2.getOrDefault(key) if isNil(j2node): return false if not(structCmp(value, j2node, strict, checkvalue)): return false true else: for key, value in j2.fields: let j1node = j1.getOrDefault(key) if isNil(j1node): return false if not(structCmp(j1node, value, strict, checkvalue)): return false true of JString: if checkvalue: j1.str == j2.str else: true of JInt: if checkvalue: j1.num == j2.num else: true of JFloat: if checkvalue: j1.fnum == j2.fnum else: true of JBool: if checkvalue: j1.bval == j2.bval else: true of JNull: true proc validateBody(body: openArray[byte], expect: BodyExpect): bool = if len(expect.items) == 0: true else: for item in expect.items: case item.kind of BodyOperatorKind.Exists: if len(body) == 0: return false of BodyOperatorKind.JsonStructCmpS, BodyOperatorKind.JsonStructCmpNS, BodyOperatorKind.JsonStructCmpSAV, BodyOperatorKind.JsonStructCmpNSAV: let jbody = block: let jres = jsonBody(body) if jres.isErr(): return false let jnode = jres.get() let jpathres = jnode.getPath(item.startPath) if jpathres.isErr(): return false jpathres.get() let strict = if item.kind in {BodyOperatorKind.JsonStructCmpS, BodyOperatorKind.JsonStructCmpSAV}: true else: false let checkvalue = if item.kind in {BodyOperatorKind.JsonStructCmpSAV, BodyOperatorKind.JsonStructCmpNSAV}: true else: false if not(structCmp(jbody, item.value, strict, checkvalue)): return false true proc failure(t: typedesc[TestResult], code: TestResultKind, message: string = "", flags: set[TestResultFlag] = {}, times: array[4, Duration]): TestResult = TestResult(kind: code, message: message, flags: flags, times: times) proc success(t: typedesc[TestResult], times: array[4, Duration]): TestResult = TestResult(kind: TestResultKind.ValidationSuccess, times: times) proc runTest(conn: HttpConnectionRef, uri: Uri, rule: JsonNode, workerIndex: int, testIndex: int): Future[TestResult] {.async.} = var times: array[4, Duration] let testName = rule.getTestName() let testPath = uri.path & rule.getTestName() debug "Running test", name = testName, test_index = testIndex, worker_index = workerIndex let (_, request) = block: let res = prepareRequest(uri, rule) if res.isErr(): return TestResult.failure(TestResultKind.RuleError, "Could not read request data: " & $res.error(), times = times) res.get() let statusExpect = block: let res = getResponseStatusExpect(rule) if res.isErr(): return TestResult.failure(TestResultKind.RuleError, "Could not read response status data: " & $res.error(), times = times) res.get() let headersExpect = block: let res = getResponseHeadersExpect(rule) if res.isErr(): return TestResult.failure(TestResultKind.RuleError, "Could not read response headers data: " & $res.error(), times = times) res.get() let bodyExpect = block: let res = getResponseBodyExpect(rule) if res.isErr(): return TestResult.failure(TestResultKind.RuleError, "Could not read response body data: " & $res.error(), times = times) res.get() let testSm = Moment.now() var headersBuf = newSeq[byte](8192) var dataBuf = newSeq[byte](8192) try: let sm = Moment.now() await conn.writer.write(request) times[0] = Moment.now() - sm debug "Request sent", name = testName, elapsed = $times[0], test_index = testIndex, worker_index = workerIndex except AsyncStreamError: return TestResult.failure(TestResultKind.WriteRequestError, "Unable to send request", {ResetConnection}, times) let rlen = try: let sm = Moment.now() let res = await conn.reader.readUntil(addr headersBuf[0], len(headersBuf), HeadersMark) times[1] = Moment.now() - sm debug "Response headers received", name = testName, length = res, elapsed = $times[1], test_index = testIndex, worker_index = workerIndex res except AsyncStreamError: return TestResult.failure(TestResultKind.ReadResponseHeadersError, "Unable to read response headers", {ResetConnection}, times) headersBuf.setLen(rlen) let resp = parseResponse(headersBuf, true) if not(resp.success()): return TestResult.failure(TestResultKind.ResponseError, "Response headers could not be parsed", {ResetConnection}, times) if "Content-Length" notin resp: return TestResult.failure(TestResultKind.ResponseError, "Content-Length header must be present", {ResetConnection}, times) let contentLength = resp.contentLength() if contentLength < 0: return TestResult.failure(TestResultKind.ResponseError, "Content-Length value is incorrect", {ResetConnection}, times) else: # TODO: We are not checking Content-Length size here if contentLength > 0: dataBuf.setLen(contentLength) try: let sm = Moment.now() await conn.reader.readExactly(addr dataBuf[0], len(dataBuf)) times[2] = Moment.now() - sm debug "Response body received", length = len(dataBuf), name = testName, elapsed = $times[2], test_index = testIndex, worker_index = workerIndex except AsyncStreamError: return TestResult.failure(TestResultKind.ReadResponseBodyError, "Unable to read response body", {ResetConnection}, times) else: debug "Response body is missing", name = testName, path = testPath dataBuf.setLen(0) let res1 = validateStatus(resp.code, statusExpect) let res2 = validateHeaders(resp, headersExpect) let res3 = validateBody(dataBuf, bodyExpect) times[3] = Moment.now() - testSm if res1 and res2 and res3: return TestResult.success(times) else: let flags = block: var res: set[TestResultFlag] if not(res1): res.incl(StatusValidationFailure) if not(res2): res.incl(HeadersValidationFailure) if not(res3): res.incl(BodyValidationFailure) res return TestResult.failure(TestResultKind.ValidationError, times = times, flags = flags) proc workerLoop(address: TransportAddress, uri: Uri, worker: int, conf: RestTesterConf, inputQueue: AsyncQueue[TestCase], outputQueue: AsyncQueue[TestCaseResult]) {.async.} = let hostname = uri.hostname & ":" & uri.port var conn: HttpConnectionRef = nil var index: int debug "Test worker has been started", worker = worker while true: try: let test = await inputQueue.popFirst() index = test.index if isNil(conn): conn = await openConnection(address, uri, conf.getTLSFlags()) debug "Opened new connection with remote host", address = $address, worker = worker let testRes = await runTest(conn, uri, test.rule, worker, test.index) let caseRes = TestCaseResult(index: test.index, data: testRes) await outputQueue.addLast(caseRes) await conn.closeWait() conn = nil index = 0 except CancelledError: if not(isNil(conn)): await conn.closeWait() conn = nil notice "Got signal, exiting", worker = worker return except ConnectionError: warn "Unable to establish connection with remote host", host = hostname, worker = worker return except CatchableError as exc: warn "Unexpected exception while running test test run", host = hostname, error_name = exc.name, error_msg = exc.msg, index = index, worker = worker return proc startTests(conf: RestTesterConf, uri: Uri, rules: seq[JsonNode]): Future[int] {.async.} = var workers = newSeq[Future[void]](conf.connectionsCount) var inputQueue = newAsyncQueue[TestCase](len(rules)) var outputQueue = newAsyncQueue[TestCaseResult](conf.connectionsCount) var results = newSeq[TestResult](len(rules)) var restarts = 0 let address = block: let res = uri.getAddress() if res.isErr(): fatal "Unable to resolve remote host address, exiting", uri = $uri return 1 res.get() for index, item in rules: inputQueue.addLastNoWait(TestCase(index: index, rule: item)) for i in 0 ..< len(workers): workers[i] = workerLoop(address, uri, i, conf, inputQueue, outputQueue) block: var pending = newSeq[FutureBase](len(workers) + 1) for i in 0 ..< len(workers): pending[i] = workers[i] var fut: Future[TestCaseResult] for i in 0 ..< len(rules): fut = outputQueue.popFirst() pending[^1] = fut discard await race(pending) for i in 0 ..< len(pending): if pending[i].finished(): if i < len(workers): warn "Test worker quits unexpectedly", index = i, restarts = restarts return 1 else: if pending[i].failed() or pending[i].cancelled(): warn "Unexpected result from queue" return 1 let tcaseRes = fut.read() results[tcaseRes.index] = tcaseRes.data notice "Got test result", name = rules[tcaseRes.index].getTestName(), index = tcaseRes.index, value = tcaseRes.data.kind pending[i] = nil debug "Stopping workers" # Stopping workers block: var pending = newSeq[Future[void]]() for worker in workers: if not(worker.finished()): pending.add(worker.cancelAndWait()) await allFutures(pending) var errCode = 0 let headerLine = "\r\n" & '-'.repeat(45 + 20 + 7 + 20 + 20) & "\r\n" & alignLeft("TEST", 45) & alignLeft("STATUS", 20) & alignLeft("ERROR", 7) & alignLeft("ELAPSED", 20) & alignLeft("MESSAGE", 20) & "\r\n" & '-'.repeat(45 + 20 + 7 + 20 + 20) echo headerLine for index, item in rules: let errorFlag = block: var tmp = "---" if StatusValidationFailure in results[index].flags: tmp[0] = 'S' if HeadersValidationFailure in results[index].flags: tmp[1] = 'H' if BodyValidationFailure in results[index].flags: tmp[2] = 'R' tmp let line = alignLeft(item.getTestName() & "#" & $index, 45) & alignLeft($results[index].kind, 20) & alignLeft(errorFlag, 7) & alignLeft($results[index].times[3], 20) & alignLeft($results[index].message, 20) echo line if results[index].kind != ValidationSuccess: errCode = 1 return errCode proc run(conf: RestTesterConf): int = let uri = block: let res = conf.getUri() if res.isErr(): fatal "Incomplete/incorrect URL", url = conf.url, error_msg = $res.error() return 1 res.get() let jnodes = block: let res = conf.getTestRules() if res.isErr(): fatal "Incomplete/incorrect rules file", file = conf.rulesFilename return 1 res.get() notice "Waiting for initial connection attempt", time = conf.delayTime try: waitFor(sleepAsync(conf.delayTime.seconds)) except CatchableError as exc: fatal "Unexpected test failure", error_name = exc.name, error_msg = exc.msg return 1 notice "Exploring remote server hostname", hostname = uri.hostname & ":" & uri.port try: waitFor(checkConnection(conf, uri)) except ConnectionError: return 1 try: return waitFor(startTests(conf, uri, jnodes)) except CatchableError as exc: fatal "Unexpected test failure", error_name = exc.name, error_msg = exc.msg return 1 when isMainModule: echo RestTesterHeader var conf = RestTesterConf.load(version = RestTesterVersion) quit run(conf)