local cjson = require "cjson" local lyaml = require "lyaml" local kong_table = require "kong.tools.table" local pl_utils = require "pl.utils" local helpers = require "spec.helpers" local Errors = require "kong.db.errors" local mocker = require("spec.fixtures.mocker") local ssl_fixtures = require "spec.fixtures.ssl" local deepcompare = require("pl.tablex").deepcompare local inspect = require "inspect" local nkeys = require "table.nkeys" local typedefs = require "kong.db.schema.typedefs" local schema = require "kong.db.schema" local uuid = require("kong.tools.uuid").uuid local WORKER_SYNC_TIMEOUT = 10 local LMDB_MAP_SIZE = "10m" local TEST_CONF = helpers.test_conf -- XXX: Kong EE supports more service/route protocols than OSS, so we must -- calculate the expected error message at runtime local SERVICE_PROTOCOL_ERROR do local proto = assert(schema.new({ type = "record", fields = { { protocol = typedefs.protocol } } })) local _, err = proto:validate({ protocol = "no" }) assert(type(err) == "table") assert(type(err.protocol) == "string") SERVICE_PROTOCOL_ERROR = err.protocol end local function it_content_types(title, fn) local test_form_encoded = fn("application/x-www-form-urlencoded") local test_multipart = fn("multipart/form-data") local test_json = fn("application/json") it(title .. " with application/www-form-urlencoded", test_form_encoded) it(title .. " with multipart/form-data", test_multipart) it(title .. " with application/json", test_json) end describe("Admin API #off", function() local client lazy_setup(function() assert(helpers.start_kong({ database = "off", lmdb_map_size = LMDB_MAP_SIZE, stream_listen = "127.0.0.1:9011", nginx_conf = "spec/fixtures/custom_nginx.template", })) end) lazy_teardown(function() helpers.stop_kong() end) before_each(function() client = assert(helpers.admin_client()) end) after_each(function() if client then client:close() end end) describe("/routes", function() describe("POST", function() it_content_types("doesn't allow to creates a route", function(content_type) return function() if content_type == "multipart/form-data" then -- the client doesn't play well with this return end local res = client:post("/routes", { body = { protocols = { "http" }, hosts = { "my.route.test" }, service = { id = uuid() }, }, headers = { ["Content-Type"] = content_type } }) local body = assert.res_status(405, res) local json = cjson.decode(body) assert.same({ code = Errors.codes.OPERATION_UNSUPPORTED, name = Errors.names[Errors.codes.OPERATION_UNSUPPORTED], message = "cannot create 'routes' entities when not using a database", }, json) end end) it_content_types("doesn't allow to creates a complex route", function(content_type) return function() if content_type == "multipart/form-data" then -- the client doesn't play well with this return end local res = client:post("/routes", { body = { protocols = { "http" }, methods = { "GET", "POST", "PATCH" }, hosts = { "foo.api.test", "bar.api.test" }, paths = { "/foo", "/bar" }, service = { id = uuid() }, }, headers = { ["Content-Type"] = content_type } }) local body = assert.res_status(405, res) local json = cjson.decode(body) assert.same({ code = Errors.codes.OPERATION_UNSUPPORTED, name = Errors.names[Errors.codes.OPERATION_UNSUPPORTED], message = "cannot create 'routes' entities when not using a database", }, json) end end) end) describe("GET", function() describe("errors", function() it("handles invalid offsets", function() local res = client:get("/routes", { query = { offset = "x" } }) local body = assert.res_status(400, res) assert.same({ code = Errors.codes.INVALID_OFFSET, name = "invalid offset", message = "'x' is not a valid offset: bad base64 encoding" }, cjson.decode(body)) res = client:get("/routes", { query = { offset = "|potato|" } }) body = assert.res_status(400, res) local json = cjson.decode(body) json.message = nil assert.same({ code = Errors.codes.INVALID_OFFSET, name = "invalid offset", }, json) end) end) end) it("returns HTTP 405 on invalid method", function() local methods = { "DELETE", "PUT", "PATCH", "POST" } for i = 1, #methods do local res = assert(client:send { method = methods[i], path = "/routes", body = { paths = { "/" }, service = { id = uuid() } }, headers = { ["Content-Type"] = "application/json" } }) local body = assert.response(res).has.status(405) local json = cjson.decode(body) if methods[i] == "POST" then assert.same({ code = Errors.codes.OPERATION_UNSUPPORTED, name = Errors.names[Errors.codes.OPERATION_UNSUPPORTED], message = "cannot create 'routes' entities when not using a database", }, json) else assert.same({ message = "Method not allowed" }, json) end end end) end) describe("/routes/{route}", function() it("returns HTTP 405 on invalid method", function() local methods = { "PUT", "POST" } for i = 1, #methods do local res = assert(client:send { method = methods[i], path = "/routes/" .. uuid(), body = { paths = { "/" }, service = { id = uuid() } }, headers = { ["Content-Type"] = "application/json" } }) local body = assert.response(res).has.status(405) local json = cjson.decode(body) if methods[i] ~= "POST" then assert.same({ code = Errors.codes.OPERATION_UNSUPPORTED, name = Errors.names[Errors.codes.OPERATION_UNSUPPORTED], message = "cannot create or update 'routes' entities when not using a database", }, json) else assert.same({ message = "Method not allowed" }, json) end end end) end) describe("/config", function() describe("POST", function() it("accepts configuration as JSON body", function() local res = assert(client:send { method = "POST", path = "/config", body = { _format_version = "1.1", consumers = { { username = "bobby_in_json_body", }, }, }, headers = { ["Content-Type"] = "application/json" }, }) assert.response(res).has.status(201) end) it("accepts configuration as YAML body", function() local res = assert(client:send { method = "POST", path = "/config", body = helpers.unindent([[ _format_version: "1.1" consumers: - username: "bobby_in_yaml_body" ]]), headers = { ["Content-Type"] = "application/yaml" }, }) assert.response(res).has.status(201) end) it("accepts configuration as a JSON string under `config` JSON key", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = [[ { "_format_version" : "1.1", "consumers" : [ { "username" : "bobby_in_json_under_config" } ] } ]], }, headers = { ["Content-Type"] = "application/json" }, }) assert.response(res).has.status(201) end) it("accepts configuration as a YAML string under `config` JSON key", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = helpers.unindent([[ _format_version: "1.1" consumers: - username: "bobby_in_yaml_under_config" ]]), }, headers = { ["Content-Type"] = "application/json" }, }) assert.response(res).has.status(201) end) it("fails with 413 and preserves previous cache if config does not fit in cache", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = [[ { "_format_version" : "1.1", "consumers" : [ { "username" : "previous" } ] } ]], type = "json", }, headers = { ["Content-Type"] = "application/json" }, }) assert.response(res).has.status(201) helpers.wait_until(function() res = assert(client:send { method = "GET", path = "/consumers/previous", headers = { ["Content-Type"] = "application/json" } }) local body = res:read_body() local json = cjson.decode(body) if res.status == 200 and json.username == "previous" then return true end end, WORKER_SYNC_TIMEOUT) client:close() client = assert(helpers.admin_client()) local consumers = {} for i = 1, 20000 do table.insert(consumers, [[ { "username" : "bobby-]] .. i .. [[" } ]]) end local config = [[ { "_format_version" : "1.1", "consumers" : [ ]] .. table.concat(consumers, ", ") .. [[ ] } ]] res = assert(client:send { method = "POST", path = "/config", body = { config = config, }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(413) helpers.wait_until(function() client:close() client = assert(helpers.admin_client()) res = assert(client:send { method = "GET", path = "/consumers/previous", headers = { ["Content-Type"] = "application/json" } }) local body = res:read_body() local json = cjson.decode(body) if res.status == 200 and json.username == "previous" then return true end end, WORKER_SYNC_TIMEOUT) end) it("accepts configuration containing null as a YAML string", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = [[ _format_version: "1.1" routes: - paths: - "/" service: null ]], }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(201) end) it("hides workspace related fields from /config response", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = [[ _format_version: "1.1" services: - name: my-service id: 0855b320-0dd2-547d-891d-601e9b38647f url: https://example.com plugins: - name: file-log id: 0611a5a9-de73-5a2d-a4e6-6a38ad4c3cb2 config: path: /tmp/file.log - name: key-auth id: 661199ff-aa1c-5498-982c-d57a4bd6e48b routes: - name: my-route id: 481a9539-f49c-51b6-b2e2-fe99ee68866c paths: - / consumers: - username: my-user id: 4b1b701d-de2b-5588-9aa2-3b97061d9f52 keyauth_credentials: - key: my-key id: 487ab43c-b2c9-51ec-8da5-367586ea2b61 ]], }, headers = { ["Content-Type"] = "application/json" } }) local body = assert.response(res).has.status(201) local entities = cjson.decode(body) assert.is_nil(entities.workspaces) assert.is_nil(entities.consumers["4b1b701d-de2b-5588-9aa2-3b97061d9f52"].ws_id) assert.is_nil(entities.keyauth_credentials["487ab43c-b2c9-51ec-8da5-367586ea2b61"].ws_id) assert.is_nil(entities.plugins["0611a5a9-de73-5a2d-a4e6-6a38ad4c3cb2"].ws_id) assert.is_nil(entities.plugins["661199ff-aa1c-5498-982c-d57a4bd6e48b"].ws_id) local services = entities.services["0855b320-0dd2-547d-891d-601e9b38647f"] local routes = entities.routes["481a9539-f49c-51b6-b2e2-fe99ee68866c"] assert.is_not_nil(services) assert.is_not_nil(routes) assert.is_nil(services.ws_id) assert.is_nil(routes.ws_id) assert.equals(routes.service.id, services.id) end) it("certificates should be auto-related with attach snis from /config response", function() local res = assert(client:send { method = "POST", path = "/config", body = { _format_version = "1.1", certificates = { { cert = ssl_fixtures.cert, id = "d83994d2-c24c-4315-b431-ee76b6611dcb", key = ssl_fixtures.key, snis = { { name = "foo.example", id = "1c6e83b7-c9ad-40ac-94e8-52f5ee7bde44", }, } } }, }, headers = { ["Content-Type"] = "application/json" } }) local body = assert.response(res).has.status(201) local entities = cjson.decode(body) local certificates = entities.certificates["d83994d2-c24c-4315-b431-ee76b6611dcb"] local snis = entities.snis["1c6e83b7-c9ad-40ac-94e8-52f5ee7bde44"] assert.is_not_nil(certificates) assert.is_not_nil(snis) assert.equals(snis.certificate.id, certificates.id) end) it("certificates should be auto-related with separate snis from /config response", function() local res = assert(client:send { method = "POST", path = "/config", body = { _format_version = "1.1", certificates = { { cert = ssl_fixtures.cert, id = "d83994d2-c24c-4315-b431-ee76b6611dcb", key = ssl_fixtures.key, }, }, snis = { { name = "foo.example", id = "1c6e83b7-c9ad-40ac-94e8-52f5ee7bde44", certificate = { id = "d83994d2-c24c-4315-b431-ee76b6611dcb" } }, }, }, headers = { ["Content-Type"] = "application/json" } }) local body = assert.response(res).has.status(201) local entities = cjson.decode(body) local certificates = entities.certificates["d83994d2-c24c-4315-b431-ee76b6611dcb"] local snis = entities.snis["1c6e83b7-c9ad-40ac-94e8-52f5ee7bde44"] assert.is_not_nil(certificates) assert.is_not_nil(snis) assert.equals(snis.certificate.id, certificates.id) end) it("can reload upstreams (regression test)", function() local config = [[ _format_version: "1.1" services: - host: foo routes: - paths: - "/" upstreams: - name: "foo" targets: - target: 10.20.30.40 ]] local res = assert(client:send { method = "POST", path = "/config", body = { config = config, }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(201) client:close() client = helpers.admin_client() local res = assert(client:send { method = "POST", path = "/config", body = { config = config, }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(201) end) it("returns 304 if checking hash and configuration is identical", function() local res = assert(client:send { method = "POST", path = "/config?check_hash=1", body = { config = [[ _format_version: "1.1" consumers: - username: bobby_tables ]], }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(201) client:close() client = helpers.admin_client() res = assert(client:send { method = "POST", path = "/config?check_hash=1", body = { config = [[ _format_version: "1.1" consumers: - username: bobby_tables ]], }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(304) end) it("returns 400 on an invalid config string", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = "bobby tables", }, headers = { ["Content-Type"] = "application/json" } }) local body = assert.response(res).has.status(400) local json = cjson.decode(body) assert.same({ code = 14, fields = { error ="failed parsing declarative configuration: expected an object", }, message = [[declarative config is invalid: ]] .. [[{error="failed parsing declarative configuration: ]] .. [[expected an object"}]], name = "invalid declarative configuration", }, json) end) it("returns 400 on a validation error", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = [[ _format_version: "1.1" services: - port: -12 ]], }, headers = { ["Content-Type"] = "application/json" } }) local body = assert.response(res).has.status(400) local json = cjson.decode(body) assert.same({ code = 14, fields = { services = { { host = "required field missing", port = "value should be between 0 and 65535", } } }, message = [[declarative config is invalid: ]] .. [[{services={{host="required field missing",]] .. [[port="value should be between 0 and 65535"}}}]], name = "invalid declarative configuration", }, json) end) it("returns 400 on an primary key uniqueness error", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = [[ _format_version: "1.1" services: - id: 0855b320-0dd2-547d-891d-601e9b38647f name: foo host: example.com protocol: https routes: - name: foo methods: ["GET"] plugins: - name: key-auth - name: http-log config: http_endpoint: https://example.com - id: 0855b320-0dd2-547d-891d-601e9b38647f name: bar host: example.test port: 3000 routes: - name: bar paths: - / plugins: - name: basic-auth - name: tcp-log config: host: 127.0.0.1 port: 10000 ]], }, headers = { ["Content-Type"] = "application/json" } }) local body = assert.response(res).has.status(400) local json = cjson.decode(body) assert.same({ code = 14, fields = { services = { cjson.null, "uniqueness violation: 'services' entity with primary key set to '0855b320-0dd2-547d-891d-601e9b38647f' already declared", } }, message = [[declarative config is invalid: ]] .. [[{services={[2]="uniqueness violation: 'services' entity with primary key set to '0855b320-0dd2-547d-891d-601e9b38647f' already declared"}}]], name = "invalid declarative configuration", }, json) end) it("returns 400 on an endpoint key uniqueness error", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = [[ _format_version: "1.1" services: - name: foo host: example.com protocol: https routes: - name: foo methods: ["GET"] plugins: - name: key-auth - name: http-log config: http_endpoint: https://example.com - name: foo host: example.test port: 3000 routes: - name: bar paths: - / plugins: - name: basic-auth - name: tcp-log config: host: 127.0.0.1 port: 10000 ]], }, headers = { ["Content-Type"] = "application/json" } }) local body = assert.response(res).has.status(400) local json = cjson.decode(body) assert.same({ code = 14, fields = { services = { cjson.null, "uniqueness violation: 'services' entity with name set to 'foo' already declared", } }, message = [[declarative config is invalid: ]] .. [[{services={[2]="uniqueness violation: 'services' entity with name set to 'foo' already declared"}}]], name = "invalid declarative configuration", }, json) end) it("returns 400 on a regular key uniqueness error", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = [[ _format_version: "1.1" consumers: - username: foo custom_id: conflict - username: bar custom_id: conflict ]], }, headers = { ["Content-Type"] = "application/json" } }) local body = assert.response(res).has.status(400) local json = cjson.decode(body) assert.same({ code = 14, fields = { consumers = { bar = "uniqueness violation: 'consumers' entity with custom_id set to 'conflict' already declared", } }, message = [[declarative config is invalid: ]] .. [[{consumers={bar="uniqueness violation: 'consumers' entity with custom_id set to 'conflict' already declared"}}]], name = "invalid declarative configuration", }, json) end) it("returns 400 when given no input", function() local res = assert(client:send { method = "POST", path = "/config", }) local body = assert.response(res).has.status(400) local json = cjson.decode(body) assert.same({ message = "expected a declarative configuration", }, json) end) it("sparse responses are correctly generated", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = [[ { "_format_version" : "1.1", "plugins": [{ "name": "key-auth", "enabled": true, "protocols": ["http", "https"] }, { "name": "key-auth", "enabled": true, "protocols": ["http", "https"] }, { "name": "key-auth", "enabled": true, "protocols": ["http", "https"] }, { "name": "key-auth", "enabled": true, "protocols": ["http", "https"] }, { "name": "key-auth", "enabled": true, "protocols": ["http", "https"] }, { "name": "key-auth", "enabled": true, "protocols": ["http", "https"] }, { "name": "key-auth", "enabled": true, "protocols": ["http", "https"] }, { "name": "key-auth", "enabled": true, "protocols": ["http", "https"] }, { "name": "key-auth", "enabled": true, "protocols": ["http", "https"] }, { "name": "key-auth", "enabled": true, "protocols": ["http", "https"] }, { "name": "cors", "config": { "credentials": true, "exposed_headers": ["*"], "headers": ["*"], "methods": ["*"], "origins": ["*"], "preflight_continue": true }, "enabled": true, "protocols": ["http", "https"] }] } ]], }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(400) end) end) describe("GET", function() it("returns back the configuration", function() local res = assert(client:send { method = "POST", path = "/config", body = { _format_version = "1.1", consumers = { { username = "bobo", id = "d885e256-1abe-5e24-80b6-8f68fe59ea8e", created_at = 1566863706, }, }, }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(201) local res = assert(client:send { method = "GET", path = "/config", }) local body = assert.response(res).has.status(200) local json = cjson.decode(body) local config = assert(lyaml.load(json.config)) assert.same({ _format_version = "3.0", _transform = false, consumers = { { id = "d885e256-1abe-5e24-80b6-8f68fe59ea8e", created_at = 1566863706, updated_at = config.consumers[1].updated_at, username = "bobo", custom_id = lyaml.null, tags = lyaml.null, }, }, }, config) end) end) it("can load large declarative config (regression test)", function() local config = assert(pl_utils.readfile("spec/fixtures/burst.yml")) local res = assert(client:send { method = "POST", path = "/config", body = { config = config, }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(201) end) it("updates stream subsystem config", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = [[ _format_version: "1.1" services: - connect_timeout: 60000 host: 127.0.0.1 name: mock port: 15557 protocol: tcp routes: - name: mock_route protocols: - tcp destinations: - port: 9011 ]], }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(201) helpers.wait_until(function() local sock = ngx.socket.tcp() assert(sock:connect("127.0.0.1", 9011)) assert(sock:send("hi\n")) local pok = pcall(helpers.wait_until, function() return sock:receive() == "hi" end, 1) sock:close() return pok == true end) end) end) describe("/upstreams", function() it("can set target health without port", function() local config = [[ _format_version: "1.1" services: - host: foo routes: - paths: - "/" upstreams: - name: "foo" targets: - target: 10.20.30.40 healthchecks: passive: healthy: successes: 1 unhealthy: http_failures: 1 ]] local res = assert(client:send { method = "POST", path = "/config", body = { config = config, }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(201) res = client:get("/upstreams/foo/targets") assert.response(res).has.status(200) local json = assert.response(res).has.jsonbody() assert.is_table(json.data) assert.same(1, #json.data) assert.is_table(json.data[1]) local id = assert.is_string(json.data[1].id) helpers.wait_until(function() local res = assert(client:send { method = "PUT", path = "/upstreams/foo/targets/" .. id .. "/10.20.30.40/unhealthy", }) return pcall(function() assert.response(res).has.status(204) end) end, 10) client:close() end) it("targets created missing ports listed with ports", function() local config = [[ _format_version: "1.1" services: - host: foo routes: - paths: - "/" upstreams: - name: "foo" targets: - target: 10.20.30.40 - target: 50.60.70.80:90 ]] local res = assert(client:send { method = "POST", path = "/config", body = { config = config, }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(201) local res = assert(client:send { method = "GET", path = "/upstreams/foo/targets/all", }) local body = assert.response(res).has.status(200) local json = cjson.decode(body) table.sort(json.data, function(t1, t2) return t1.target < t2.target end) assert.same("10.20.30.40:8000", json.data[1].target) assert.same("50.60.70.80:90", json.data[2].target) client:close() end) end) end) describe("Admin API #off /config [flattened errors]", function() local client local tags local function make_tag_t(name) return setmetatable({ name = name, count = 0, last = nil, }, { __index = function(self, k) if k == "next" then self.count = self.count + 1 local tag = ("%s-%02d"):format(self.name, self.count) self.last = tag return tag else error("unknown key: " .. k) end end, }) end lazy_setup(function() assert(helpers.start_kong({ database = "off", lmdb_map_size = LMDB_MAP_SIZE, stream_listen = "127.0.0.1:9011", nginx_conf = "spec/fixtures/custom_nginx.template", plugins = "bundled", vaults = "bundled", log_level = "warn", })) end) lazy_teardown(function() helpers.stop_kong() end) before_each(function() client = assert(helpers.admin_client()) helpers.clean_logfile() tags = setmetatable({}, { __index = function(self, k) self[k] = make_tag_t(k) return self[k] end, }) end) after_each(function() if client then client:close() end end) local function sort_errors(t) if type(t) ~= "table" then return end table.sort(t, function(a, b) if a.type ~= b.type then return a.type < b.type end if a.field ~= b.field then return a.field < b.field end return a.message < b.message end) end local function is_fk(value) return type(value) == "table" and nkeys(value) == 1 and value.id ~= nil end local compare_entity local function compare_field(field, exp, got, diffs) -- Entity IDs are a special case -- -- In general, we don't want to bother comparing them because they -- are going to be auto-generated at random for each test run. The -- exception to this rule is that when the expected data explicitly -- specifies an ID, we want to compare it. if field == "entity_id" or field == "id" or is_fk(got) then if exp == nil then got = nil elseif exp == ngx.null then exp = nil end elseif field == "entity" then return compare_entity(exp, got, diffs) -- sort the errors array; its order is not guaranteed and does not -- really matter, so sorting is just for ease of deep comparison elseif field == "errors" then sort_errors(exp) sort_errors(got) end if not deepcompare(exp, got) then if diffs then table.insert(diffs, field) end return false end return true end function compare_entity(exp, got, diffs) local seen = {} for field in pairs(exp) do if not compare_field(field, exp[field], got[field]) then table.insert(diffs, "entity." .. field) end seen[field] = true end for field in pairs(got) do -- NOTE: certain fields may be present in the actual response -- but missing from the expected response (e.g. `id`) if not seen[field] and not compare_field(field, exp[field], got[field]) then table.insert(diffs, "entity." .. field) end end end local function compare(exp, got, diffs) if type(exp) ~= "table" or type(got) ~= "table" then return exp == got end local seen = {} for field in pairs(exp) do seen[field] = true compare_field(field, exp[field], got[field], diffs) end for field in pairs(got) do if not seen[field] then compare_field(field, exp[field], got[field], diffs) end end return #diffs == 0 end local function get_by_tag(tag, haystack) if type(tag) == "table" then tag = tag[1] end for i = 1, #haystack do local item = haystack[i] if item.entity.tags and item.entity.tags[1] == tag then return table.remove(haystack, i) end end end local function find(needle, haystack) local tag = needle.entity and needle.entity.tags and needle.entity.tags[1] if not tag then return end return get_by_tag(tag, haystack) end local function post_config(config, debug) config._format_version = config._format_version or "3.0" local res = client:post("/config?flatten_errors=1", { body = config, headers = { ["Content-Type"] = "application/json" }, }) assert.response(res).has.status(400) local body = assert.response(res).has.jsonbody() local errors = body.flattened_errors assert.not_nil(errors, "`flattened_errors` is missing from the response") assert.is_table(errors, "`flattened_errors` is not a table") if debug then helpers.intercept(body) end assert.logfile().has.no.line("[emerg]", true, 0) assert.logfile().has.no.line("[crit]", true, 0) assert.logfile().has.no.line("[alert]", true, 0) assert.logfile().has.no.line("[error]", true, 0) assert.logfile().has.no.line("[warn]", true, 0) return errors end -- Testing Methodology: -- -- 1. Iterate through each array (expected, received) -- 2. Correlate expected and received entries by comparing the first -- entity tag of each -- 3. Compare the two entries local function validate(expected, received) local errors = {} while #expected > 0 do local exp = table.remove(expected) local got = find(exp, received) local diffs = {} if not compare(exp, got, diffs) then table.insert(errors, { exp = exp, got = got, diffs = diffs }) end end -- everything left in flattened is an unexpected, extra entry for _, got in ipairs(received) do assert.is_nil(find(got, expected)) table.insert(errors, { got = got }) end if #errors > 0 then local msg = {} for i, err in ipairs(errors) do local exp, got = err.exp, err.got table.insert(msg, ("\n======== Error #%00d ========\n"):format(i)) if not exp then table.insert(msg, "Unexpected entry:\n") table.insert(msg, inspect(got)) table.insert(msg, "\n") elseif not got then table.insert(msg, "Missing entry:\n") table.insert(msg, inspect(exp)) table.insert(msg, "\n") else table.insert(msg, "Expected:\n\n") table.insert(msg, inspect(exp)) table.insert(msg, "\n\n") table.insert(msg, "Got:\n\n") table.insert(msg, inspect(got)) table.insert(msg, "\n\n") table.insert(msg, "Unmatched Fields:\n") for _, field in ipairs(err.diffs) do table.insert(msg, (" - %s\n"):format(field)) end end table.insert(msg, "\n") end assert.equals(0, #errors, table.concat(msg)) end end it("sanity", function() -- Test Cases -- -- The first tag string in the entity tags table is a unique ID for -- that entity. This allows the test code to locate and correlate -- each item in the actual response to one in the expected response -- when deepcompare() will not consider the entries to be equivalent. -- -- Use the tag helper table to generate this tag for each entity you -- add to the input (`tag.ENTITY_NAME.next`): -- -- tags = { tags.consumer.next } -> { "consumer-01" } -- tags = { tags.consumer.next } -> { "consumer-02" } -- -- You can use `tag.ENTITY_NAME.last` if you want to refer to the last -- ID that was generated for an entity type. This has no special -- meaning in the tests, but it can be helpful in correlating an entity -- with its parent when debugging: -- -- services = { -- { -- name = "foo", -- tags = { tags.service.next }, -- > "service-01", -- routes = { -- tags = { -- tags.route_service.next, -- > "route_service-01", -- tags.service.last -- > "service-01", -- }, -- } -- } -- } -- -- Additional tags can be added after the first one, and they will be -- deepcompare()-ed when error-checking is done. local input = { consumers = { { username = "valid_user", tags = { tags.consumer.next }, }, { username = "bobby_in_json_body", not_allowed = true, tags = { tags.consumer.next }, }, { username = "super_valid_user", tags = { tags.consumer.next }, }, { username = "credentials", tags = { tags.consumer.next }, basicauth_credentials = { { username = "superduper", password = "hard2guess", tags = { tags.basicauth_credentials.next, tags.consumer.last }, }, { username = "dont-add-extra-fields-yo", password = "12354", extra_field = "NO!", tags = { tags.basicauth_credentials.next, tags.consumer.last }, }, }, }, }, plugins = { { name = "http-log", config = { http_endpoint = "invalid::#//url", }, tags = { tags.global_plugin.next }, }, }, certificates = { { cert = [[-----BEGIN CERTIFICATE----- MIICIzCCAYSgAwIBAgIUUMiD8e3GDZ+vs7XBmdXzMxARUrgwCgYIKoZIzj0EAwIw IzENMAsGA1UECgwES29uZzESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIyMTIzMDA0 MDcwOFoXDTQyMTIyNTA0MDcwOFowIzENMAsGA1UECgwES29uZzESMBAGA1UEAwwJ bG9jYWxob3N0MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBxSldGzzRAtjt825q Uwl+BNgxecswnvbQFLiUDqJjVjCfs/B53xQfV97ddxsRymES2viC2kjAm1Ete4TH CQmVltUBItHzI77HB+UsfqHoUdjl3lC/HC1yDSPBp5wd9eRRSagdl0eiJwnB9lof MEnmOQLg177trb/YPz1vcCCZj7ikhzCjUzBRMB0GA1UdDgQWBBSUI6+CKqKFz/Te ZJppMNl/Dh6d9DAfBgNVHSMEGDAWgBSUI6+CKqKFz/TeZJppMNl/Dh6d9DAPBgNV HRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA4GMADCBiAJCAZL3qX21MnGtQcl9yOMr hNR54VrDKgqLR+ChU7/358n/sK/sVOjmrwVyQ52oUyqaQlfBQS2EufQVO/01+2sx 86gzAkIB/4Ilf4RluN2/gqHYlVEDRZzsqbwVJBHLeNKsZBSJkhNNpJBwa2Ndl9/i u2tDk0KZFSAvRnqRAo9iDBUkIUI1ahA= -----END CERTIFICATE-----]], key = [[-----BEGIN EC PRIVATE KEY----- MIHcAgEBBEIARPKnAYLB54bxBvkDfqV4NfZ+Mxl79rlaYRB6vbWVwFpy+E2pSZBR doCy1tHAB/uPo+QJyjIK82Zwa3Kq0i1D2QigBwYFK4EEACOhgYkDgYYABAHFKV0b PNEC2O3zbmpTCX4E2DF5yzCe9tAUuJQOomNWMJ+z8HnfFB9X3t13GxHKYRLa+ILa SMCbUS17hMcJCZWW1QEi0fMjvscH5Sx+oehR2OXeUL8cLXINI8GnnB315FFJqB2X R6InCcH2Wh8wSeY5AuDXvu2tv9g/PW9wIJmPuKSHMA== -----END EC PRIVATE KEY-----]], tags = { tags.certificate.next }, }, { cert = [[-----BEGIN CERTIFICATE----- MIICIzCCAYSgAwIBAgIUUMiD8e3GDZ+vs7XBmdXzMxARUrgwCgYIKoZIzj0EAwIw IzENMAsGA1UECgwES29uZzESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIyMTIzMDA0 MDcwOFoXDTQyohnoooooooooooooooooooooooooooooooooooooooooooasdfa Uwl+BNgxecswnvbQFLiUDqJjVjCfs/B53xQfV97ddxsRymES2viC2kjAm1Ete4TH CQmVltUBItHzI77AAAAAAAAAAAAAAAC/HC1yDSBBBBBBBBBBBBBdl0eiJwnB9lof MEnmOQLg177trb/AAAAAAAAAAAAAAACjUzBRMBBBBBBBBBBBBBBUI6+CKqKFz/Te ZJppMNl/Dh6d9DAAAAAAAAAAAAAAAASUI6+CKqBBBBBBBBBBBBB/Dh6d9DAPBgNV HRMBAf8EBTADAQHAAAAAAAAAAAAAAAMCA4GMADBBBBBBBBBBBBB1MnGtQcl9yOMr hNR54VrDKgqLR+CAAAAAAAAAAAAAAAjmrwVyQ5BBBBBBBBBBBBBEufQVO/01+2sx 86gzAkIB/4Ilf4RluN2/gqHYlVEDRZzsqbwVJBHLeNKsZBSJkhNNpJBwa2Ndl9/i u2tDk0KZFSAvRnqRAo9iDBUkIUI1ahA= -----END CERTIFICATE-----]], key = [[-----BEGIN EC PRIVATE KEY----- MIHcAgEBBEIARPKnAYLB54bxBvkDfqV4NfZ+Mxl79rlaYRB6vbWVwFpy+E2pSZBR doCy1tHAB/uPo+QJyjIK82Zwa3Kq0i1D2QigBwYFK4EEACOhgYkDgYYABAHFKV0b PNEC2O3zbmpTCX4E2DF5yzCe9tAUuJQOomNWMJ+z8HnfFB9X3t13GxHKYRLa+ILa SMCbUS17hMcJCZWW1QEi0fMjvscH5Sx+oehR2OXeUL8cLXINI8GnnB315FFJqB2X R6InCcH2Wh8wSeY5AuDXvu2tv9g/PW9wIJmPuKSHMA== -----END EC PRIVATE KEY-----]], tags = { tags.certificate.next }, }, }, services = { { name = "nope", host = "localhost", port = 1234, protocol = "nope", tags = { tags.service.next }, routes = { { name = "valid.route", protocols = { "http", "https" }, methods = { "GET" }, hosts = { "test" }, tags = { tags.route_service.next, tags.service.last }, }, { name = "nope.route", protocols = { "tcp" }, tags = { tags.route_service.next, tags.service.last }, } }, }, { name = "mis-matched", host = "localhost", protocol = "tcp", path = "/path", tags = { tags.service.next }, routes = { { name = "invalid", protocols = { "http", "https" }, hosts = { "test" }, methods = { "GET" }, tags = { tags.route_service.next, tags.service.last }, }, }, }, { name = "okay", url = "http://localhost:1234", tags = { tags.service.next }, routes = { { name = "probably-valid", protocols = { "http", "https" }, methods = { "GET" }, hosts = { "test" }, tags = { tags.route_service.next, tags.service.last }, plugins = { { name = "http-log", config = { not_endpoint = "anything" }, tags = { tags.route_service_plugin.next, tags.route_service.last, tags.service.last, }, }, }, }, }, }, { name = "bad-service-plugins", url = "http://localhost:1234", tags = { tags.service.next }, plugins = { { name = "i-dont-exist", config = {}, tags = { tags.service_plugin.next, tags.service.last }, }, { name = "tcp-log", config = { deeply = { nested = { undefined = true } }, port = 1234, }, tags = { tags.service_plugin.next, tags.service.last }, }, }, }, { name = "bad-client-cert", url = "https://localhost:1234", tags = { tags.service.next }, client_certificate = { cert = "", key = "", tags = { tags.service_client_certificate.next, tags.service.last, }, }, }, { name = "invalid-id", id = 123456, url = "https://localhost:1234", tags = { tags.service.next, "invalid-id" }, }, { name = "invalid-tags", url = "https://localhost:1234", tags = { tags.service.next, "invalid-tags", {1,2,3}, true }, }, { name = "", url = "https://localhost:1234", tags = { tags.service.next, tags.invalid_service_name.next }, }, { name = 1234, url = "https://localhost:1234", tags = { tags.service.next, tags.invalid_service_name.next }, }, }, upstreams = { { name = "ok", tags = { tags.upstream.next }, hash_on = "ip", }, { name = "bad", tags = { tags.upstream.next }, hash_on = "ip", healthchecks = { active = { type = "http", http_path = "/", https_verify_certificate = true, https_sni = "example.com", timeout = 1, concurrency = -1, healthy = { interval = 0, successes = 0, }, unhealthy = { interval = 0, http_failures = 0, }, }, }, host_header = 123, }, { name = "ok-bad-targets", tags = { tags.upstream.next }, targets = { { target = "127.0.0.1:99", tags = { tags.upstream_target.next, tags.upstream.last, }, }, { target = "hostname:1.0", tags = { tags.upstream_target.next, tags.upstream.last, }, }, }, } }, vaults = { { name = "env", prefix = "test", config = { prefix = "SSL_" }, tags = { tags.vault.next }, }, { name = "vault-not-installed", prefix = "env", config = { prefix = "SSL_" }, tags = { tags.vault.next, "vault-not-installed" }, }, }, } local expect = { { entity = { extra_field = "NO!", password = "12354", tags = { "basicauth_credentials-02", "consumer-04", }, username = "dont-add-extra-fields-yo", }, entity_tags = { "basicauth_credentials-02", "consumer-04", }, entity_type = "basicauth_credential", errors = { { field = "extra_field", message = "unknown field", type = "field" } } }, { entity = { config = { prefix = "SSL_" }, name = "vault-not-installed", prefix = "env", tags = { "vault-02", "vault-not-installed" } }, entity_name = "vault-not-installed", entity_tags = { "vault-02", "vault-not-installed" }, entity_type = "vault", errors = { { field = "name", message = "vault 'vault-not-installed' is not installed", type = "field" } } }, { -- note entity_name is nil, but entity.name is not entity_name = nil, entity = { name = "", tags = { "service-08", "invalid_service_name-01" }, url = "https://localhost:1234" }, entity_tags = { "service-08", "invalid_service_name-01" }, entity_type = "service", errors = { { field = "name", message = "length must be at least 1", type = "field" } } }, { -- note entity_name is nil, but entity.name is not entity_name = nil, entity = { name = 1234, tags = { "service-09", "invalid_service_name-02" }, url = "https://localhost:1234" }, entity_tags = { "service-09", "invalid_service_name-02" }, entity_type = "service", errors = { { field = "name", message = "expected a string", type = "field" } } }, { -- note entity_tags is nil, but entity.tags is not entity_tags = nil, entity = { name = "invalid-tags", tags = { "service-07", "invalid-tags", { 1, 2, 3 }, true }, url = "https://localhost:1234" }, entity_name = "invalid-tags", entity_type = "service", errors = { { field = "tags.3", message = "expected a string", type = "field" }, { field = "tags.4", message = "expected a string", type = "field" } } }, { entity_id = ngx.null, entity = { name = "invalid-id", id = 123456, tags = { "service-06", "invalid-id" }, url = "https://localhost:1234" }, entity_name = "invalid-id", entity_tags = { "service-06", "invalid-id" }, entity_type = "service", errors = { { field = "id", message = "expected a string", type = "field" } } }, { entity = { cert = "-----BEGIN CERTIFICATE-----\nMIICIzCCAYSgAwIBAgIUUMiD8e3GDZ+vs7XBmdXzMxARUrgwCgYIKoZIzj0EAwIw\nIzENMAsGA1UECgwES29uZzESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIyMTIzMDA0\nMDcwOFoXDTQyohnoooooooooooooooooooooooooooooooooooooooooooasdfa\nUwl+BNgxecswnvbQFLiUDqJjVjCfs/B53xQfV97ddxsRymES2viC2kjAm1Ete4TH\nCQmVltUBItHzI77AAAAAAAAAAAAAAAC/HC1yDSBBBBBBBBBBBBBdl0eiJwnB9lof\nMEnmOQLg177trb/AAAAAAAAAAAAAAACjUzBRMBBBBBBBBBBBBBBUI6+CKqKFz/Te\nZJppMNl/Dh6d9DAAAAAAAAAAAAAAAASUI6+CKqBBBBBBBBBBBBB/Dh6d9DAPBgNV\nHRMBAf8EBTADAQHAAAAAAAAAAAAAAAMCA4GMADBBBBBBBBBBBBB1MnGtQcl9yOMr\nhNR54VrDKgqLR+CAAAAAAAAAAAAAAAjmrwVyQ5BBBBBBBBBBBBBEufQVO/01+2sx\n86gzAkIB/4Ilf4RluN2/gqHYlVEDRZzsqbwVJBHLeNKsZBSJkhNNpJBwa2Ndl9/i\nu2tDk0KZFSAvRnqRAo9iDBUkIUI1ahA=\n-----END CERTIFICATE-----", key = "-----BEGIN EC PRIVATE KEY-----\nMIHcAgEBBEIARPKnAYLB54bxBvkDfqV4NfZ+Mxl79rlaYRB6vbWVwFpy+E2pSZBR\ndoCy1tHAB/uPo+QJyjIK82Zwa3Kq0i1D2QigBwYFK4EEACOhgYkDgYYABAHFKV0b\nPNEC2O3zbmpTCX4E2DF5yzCe9tAUuJQOomNWMJ+z8HnfFB9X3t13GxHKYRLa+ILa\nSMCbUS17hMcJCZWW1QEi0fMjvscH5Sx+oehR2OXeUL8cLXINI8GnnB315FFJqB2X\nR6InCcH2Wh8wSeY5AuDXvu2tv9g/PW9wIJmPuKSHMA==\n-----END EC PRIVATE KEY-----", tags = { "certificate-02", } }, entity_tags = { "certificate-02", }, entity_type = "certificate", errors = { { field = "cert", message = "invalid certificate: x509.new: error:688010A:asn1 encoding routines:asn1_item_embed_d2i:nested asn1 error:asn1/tasn_dec.c:349:", type = "field" } } }, { entity = { hash_on = "ip", healthchecks = { active = { concurrency = -1, healthy = { interval = 0, successes = 0 }, http_path = "/", https_sni = "example.com", https_verify_certificate = true, timeout = 1, type = "http", unhealthy = { http_failures = 0, interval = 0 } } }, host_header = 123, name = "bad", tags = { "upstream-02" } }, entity_name = "bad", entity_tags = { "upstream-02" }, entity_type = "upstream", errors = { { field = "host_header", message = "expected a string", type = "field" }, { field = "healthchecks.active.concurrency", message = "value should be between 1 and 2147483648", type = "field" }, } }, { entity = { config = { http_endpoint = "invalid::#//url" }, name = "http-log", tags = { "global_plugin-01", } }, entity_name = "http-log", entity_tags = { "global_plugin-01", }, entity_type = "plugin", errors = { { field = "config.http_endpoint", message = "missing host in url", type = "field" } } }, { entity = { not_allowed = true, tags = { "consumer-02" }, username = "bobby_in_json_body" }, entity_tags = { "consumer-02" }, entity_type = "consumer", errors = { { field = "not_allowed", message = "unknown field", type = "field" } } }, { entity = { name = "nope.route", protocols = { "tcp" }, tags = { "route_service-02", "service-01", } }, entity_name = "nope.route", entity_tags = { "route_service-02", "service-01", }, entity_type = "route", errors = { { message = "must set one of 'sources', 'destinations', 'snis' when 'protocols' is 'tcp', 'tls' or 'udp'", type = "entity" } } }, { entity = { host = "localhost", name = "nope", port = 1234, protocol = "nope", tags = { "service-01" } }, entity_name = "nope", entity_tags = { "service-01" }, entity_type = "service", errors = { { field = "protocol", message = SERVICE_PROTOCOL_ERROR, type = "field" } } }, { entity = { host = "localhost", name = "mis-matched", path = "/path", protocol = "tcp", tags = { "service-02" } }, entity_name = "mis-matched", entity_tags = { "service-02" }, entity_type = "service", errors = { { field = "path", message = "value must be null", type = "field" }, { message = "failed conditional validation given value of field 'protocol'", type = "entity" } } }, { entity = { config = { not_endpoint = "anything" }, name = "http-log", tags = { "route_service_plugin-01", "route_service-04", "service-03", } }, entity_name = "http-log", entity_tags = { "route_service_plugin-01", "route_service-04", "service-03", }, entity_type = "plugin", errors = { { field = "config.not_endpoint", message = "unknown field", type = "field" }, { field = "config.http_endpoint", message = "required field missing", type = "field" } } }, { entity = { config = {}, name = "i-dont-exist", tags = { "service_plugin-01", "service-04", } }, entity_name = "i-dont-exist", entity_tags = { "service_plugin-01", "service-04", }, entity_type = "plugin", errors = { { field = "name", message = "plugin 'i-dont-exist' not enabled; add it to the 'plugins' configuration property", type = "field" } } }, { entity = { config = { deeply = { nested = { undefined = true } }, port = 1234 }, name = "tcp-log", tags = { "service_plugin-02", "service-04", } }, entity_name = "tcp-log", entity_tags = { "service_plugin-02", "service-04", }, entity_type = "plugin", errors = { { field = "config.deeply", message = "unknown field", type = "field" }, { field = "config.host", message = "required field missing", type = "field" } } }, { entity = { cert = "", key = "", tags = { "service_client_certificate-01", "service-05", } }, entity_tags = { "service_client_certificate-01", "service-05", }, entity_type = "certificate", errors = { { field = "key", message = "length must be at least 1", type = "field" }, { field = "cert", message = "length must be at least 1", type = "field" }, }, }, { entity = { tags = { "upstream_target-02", "upstream-03", }, target = "hostname:1.0" }, entity_tags = { "upstream_target-02", "upstream-03", }, entity_type = "target", errors = { { field = "target", message = "Invalid target ('hostname:1.0'); not a valid hostname or ip address", type = "field" } } }, } validate(expect, post_config(input)) end) it("flattens nested, non-entity field errors", function() local upstream = { name = "bad", tags = { tags.upstream.next }, hash_on = "ip", healthchecks = { active = { type = "http", http_path = "/", https_verify_certificate = true, https_sni = "example.com", timeout = 1, concurrency = -1, healthy = { interval = 0, successes = 0, }, unhealthy = { interval = 0, http_failures = 0, }, }, }, host_header = 123, } validate({ { entity_type = "upstream", entity_name = "bad", entity_tags = { tags.upstream.last }, entity = upstream, errors = { { field = "healthchecks.active.concurrency", message = "value should be between 1 and 2147483648", type = "field" }, { field = "host_header", message = "expected a string", type = "field" }, }, }, }, post_config({ upstreams = { upstream } })) end) it("flattens nested, entity field errors", function() local input = { services = { { name = "bad-client-cert", url = "https://localhost:1234", tags = { tags.service.next }, -- error client_certificate = { cert = "", key = "", tags = { tags.service_client_certificate.next, tags.service.last, }, }, routes = { { hosts = { "test" }, paths = { "/" }, protocols = { "http" }, tags = { tags.service_route.next }, plugins = { -- error { name = "http-log", config = { a = { b = { c = "def" } } }, tags = { tags.route_service_plugin.next }, }, }, }, -- error { hosts = { "invalid" }, paths = { "/" }, protocols = { "nope" }, tags = { tags.service_route.next }, }, }, plugins = { -- error { name = "i-do-not-exist", config = {}, tags = { tags.service_plugin.next }, }, }, }, } } validate({ { entity = { cert = "", key = "", tags = { "service_client_certificate-01", "service-01" } }, entity_tags = { "service_client_certificate-01", "service-01" }, entity_type = "certificate", errors = { { field = "cert", message = "length must be at least 1", type = "field" }, { field = "key", message = "length must be at least 1", type = "field" } } }, { entity = { hosts = { "invalid" }, paths = { "/" }, protocols = { "nope" }, tags = { "service_route-02" } }, entity_tags = { "service_route-02" }, entity_type = "route", errors = { { field = "protocols", message = "unknown type: nope", type = "field" } } }, { entity = { config = { a = { b = { c = "def" } } }, name = "http-log", tags = { "route_service_plugin-01" }, }, entity_name = "http-log", entity_type = "plugin", entity_tags = { "route_service_plugin-01" }, errors = { { field = "config.a", message = "unknown field", type = "field" }, { field = "config.http_endpoint", message = "required field missing", type = "field" } } }, { entity = { config = {}, name = "i-do-not-exist", tags = { "service_plugin-01" } }, entity_name = "i-do-not-exist", entity_tags = { "service_plugin-01" }, entity_type = "plugin", errors = { { field = "name", message = "plugin 'i-do-not-exist' not enabled; add it to the 'plugins' configuration property", type = "field" } } }, }, post_config(input)) end) it("correctly handles nested entity errors", function() local consumers = { { id = "cdac30ee-cd7e-465c-99b6-84f3e4e17015", username = "consumer-01", tags = { "consumer-01" }, basicauth_credentials = { { id = "089091f4-cb8b-48f5-8463-8319097be716", username = "user-01", password = "pwd", tags = { "consumer-01-credential-01" }, }, { id = "b1443d61-ccd9-4359-b82a-f37515442295", username = "user-11", password = "pwd", tags = { "consumer-01-credential-02" }, }, { id = "2603d010-edbe-4713-94ef-145e281cbf4c", username = "user-02", password = "pwd", tags = { "consumer-01-credential-03" }, }, { id = "760cf441-613c-48a2-b377-36aebc9f9ed0", -- unique violation! username = "user-11", password = "pwd", tags = { "consumer-01-credential-04" }, } }, }, { id = "c0c021f5-dae1-4031-bcf6-42e3c4d9ced9", username = "consumer-02", tags = { "consumer-02" }, basicauth_credentials = { { id = "d0cd1919-bb07-4c85-b407-f33feb74f6e2", username = "user-99", password = "pwd", tags = { "consumer-02-credential-01" }, } }, }, { id = "9acb0270-73aa-4968-b9e4-a4924e4aced5", username = "consumer-03", tags = { "consumer-03" }, basicauth_credentials = { { id = "7e8bcd10-cdcd-41f1-8c4d-9790d34aa67d", -- unique violation! username = "user-01", password = "pwd", tags = { "consumer-03-credential-01" }, }, { id = "7fe186bd-44e5-4b97-854d-85a24929889d", username = "user-33", password = "pwd", tags = { "consumer-03-credential-02" }, }, { id = "6547c325-5332-41fc-a954-d4972926cdb5", -- unique violation! username = "user-02", password = "pwd", tags = { "consumer-03-credential-03" }, }, { id = "e091a955-9ee1-4403-8d0a-a7f1f8bafaca", -- unique violation! username = "user-33", password = "pwd", tags = { "consumer-03-credential-04" }, } }, } } local input = { _format_version = "3.0", consumers = consumers, } validate({ -- consumer 1 / credential 4 { entity = { consumer = { id = consumers[1].id }, id = consumers[1].basicauth_credentials[4].id, tags = consumers[1].basicauth_credentials[4].tags, password = "pwd", username = "user-11" }, entity_id = consumers[1].basicauth_credentials[4].id, entity_tags = consumers[1].basicauth_credentials[4].tags, entity_type = "basicauth_credential", errors = { { message = "uniqueness violation: 'basicauth_credentials' entity with username set to 'user-11' already declared", type = "entity" } } }, -- consumer 3 / credential 1 { entity = { consumer = { id = consumers[3].id }, id = consumers[3].basicauth_credentials[1].id, tags = consumers[3].basicauth_credentials[1].tags, password = "pwd", username = "user-01" }, entity_id = consumers[3].basicauth_credentials[1].id, entity_tags = consumers[3].basicauth_credentials[1].tags, entity_type = "basicauth_credential", errors = { { message = "uniqueness violation: 'basicauth_credentials' entity with username set to 'user-01' already declared", type = "entity" } } }, -- consumer 3 / credential 3 { entity = { consumer = { id = consumers[3].id }, id = consumers[3].basicauth_credentials[3].id, tags = consumers[3].basicauth_credentials[3].tags, password = "pwd", username = "user-02" }, entity_id = consumers[3].basicauth_credentials[3].id, entity_tags = consumers[3].basicauth_credentials[3].tags, entity_type = "basicauth_credential", errors = { { message = "uniqueness violation: 'basicauth_credentials' entity with username set to 'user-02' already declared", type = "entity" } } }, -- consumer 3 / credential 4 { entity = { consumer = { id = consumers[3].id }, id = consumers[3].basicauth_credentials[4].id, tags = consumers[3].basicauth_credentials[4].tags, password = "pwd", username = "user-33" }, entity_id = consumers[3].basicauth_credentials[4].id, entity_tags = consumers[3].basicauth_credentials[4].tags, entity_type = "basicauth_credential", errors = { { message = "uniqueness violation: 'basicauth_credentials' entity with username set to 'user-33' already declared", type = "entity" } } }, }, post_config(input)) end) it("preserves IDs from the input", function() local id = "0175e0e8-3de9-56b4-96f1-b12dcb4b6691" local service = { id = id, name = "nope", host = "localhost", port = 1234, protocol = "nope", tags = { tags.service.next }, } local flattened = post_config({ services = { service } }) local got = get_by_tag(tags.service.last, flattened) assert.not_nil(got) assert.equals(id, got.entity_id) assert.equals(id, got.entity.id) end) it("preserves foreign keys from nested entity collections", function() local id = "cb019421-62c2-47a8-b714-d7567b114037" local service = { id = id, name = "test", host = "localhost", port = 1234, protocol = "nope", tags = { tags.service.next }, routes = { { super_duper_invalid = true, tags = { tags.route.next }, } }, } local flattened = post_config({ services = { service } }) local got = get_by_tag(tags.route.last, flattened) assert.not_nil(got) assert.is_table(got.entity) assert.is_table(got.entity.service) assert.same({ id = id }, got.entity.service) end) it("omits top-level entity_* fields if they are invalid", function() local service = { id = 1234, name = false, tags = { tags.service.next, { 1.5 }, }, url = "http://localhost:1234", } local flattened = post_config({ services = { service } }) local got = get_by_tag(tags.service.last, flattened) assert.not_nil(got) assert.is_nil(got.entity_id) assert.is_nil(got.entity_name) assert.is_nil(got.entity_tags) assert.equals(1234, got.entity.id) assert.equals(false, got.entity.name) assert.same({ tags.service.last, { 1.5 }, }, got.entity.tags) end) it("drains errors from the top-level fields object", function() local function post(config, flatten) config._format_version = config._format_version or "3.0" local path = ("/config?flatten_errors=%s"):format(flatten or "off") local res = client:post(path, { body = config, headers = { ["Content-Type"] = "application/json" }, }) assert.response(res).has.status(400) return assert.response(res).has.jsonbody() end local input = { _format_version = "3.0", abnormal_extra_field = 123, services = { { name = "nope", host = "localhost", port = 1234, protocol = "nope", tags = { tags.service.next }, routes = { { name = "valid.route", protocols = { "http", "https" }, methods = { "GET" }, hosts = { "test" }, tags = { tags.route_service.next, tags.service.last }, }, { name = "nope.route", protocols = { "tcp" }, tags = { tags.route_service.next, tags.service.last }, } }, }, { name = "mis-matched", host = "localhost", protocol = "tcp", path = "/path", tags = { tags.service.next }, routes = { { name = "invalid", protocols = { "http", "https" }, hosts = { "test" }, methods = { "GET" }, tags = { tags.route_service.next, tags.service.last }, }, }, }, { name = "okay", url = "http://localhost:1234", tags = { tags.service.next }, routes = { { name = "probably-valid", protocols = { "http", "https" }, methods = { "GET" }, hosts = { "test" }, tags = { tags.route_service.next, tags.service.last }, plugins = { { name = "http-log", config = { not_endpoint = "anything" }, tags = { tags.route_service_plugin.next, tags.route_service.last, tags.service.last, }, }, }, }, }, }, }, } local original = post(input, false) assert.same({ abnormal_extra_field = "unknown field", services = { { protocol = "expected one of: grpc, grpcs, http, https, tcp, tls, tls_passthrough, udp", routes = { ngx.null, { ["@entity"] = { "must set one of 'sources', 'destinations', 'snis' when 'protocols' is 'tcp', 'tls' or 'udp'" } } } }, { ["@entity"] = { "failed conditional validation given value of field 'protocol'" }, path = "value must be null" }, { routes = { { plugins = { { config = { http_endpoint = "required field missing", not_endpoint = "unknown field" } } } } } } } }, original.fields) assert.is_nil(original.flattened_errors) -- XXX: top-level fields are not currently flattened because they don't -- really have an `entity_type` that we can use... maybe something that -- we'll address later on. local flattened = post(input, true) assert.same({ abnormal_extra_field = "unknown field" }, flattened.fields) assert.equals(4, #flattened.flattened_errors, "unexpected number of flattened errors") end) it("does not throw for invalid input - (#10767)", function() -- The problem with this input is that the user has attempted to associate -- two different plugin instances with the same `consumer.username`. The -- final error that is returned ("consumer.id / missing primary key") is -- somewhat nonsensical. That is okay, because the purpose of this test is -- really just to ensure that we don't throw a 500 error for this kind of -- input. -- -- If at some later date we improve the flattening logic of the -- declarative config parser, this test may fail and require an update, -- as the "shape" of the error will likely be changed--hopefully to -- something that is more helpful to the end user. -- NOTE: the fact that the username is a UUID *should not* be assumed to -- have any real significance here. It was chosen to keep the test input -- 1-1 with the github issue that resulted this test. As of this writing, -- the test behaves exactly the same with any random string as it does -- with a UUID. local username = "774f8446-6427-43f9-9962-ce7ab8097fe4" local consumer_id = "68d5de9f-2211-5ed8-b827-22f57a492d0f" local service_name = "default.nginx-sample-1.nginx-sample-1.80" local upstream_name = "nginx-sample-1.default.80.svc" local plugin = { name = "rate-limiting", consumer = username, config = { error_code = 429, error_message = "API rate limit exceeded", fault_tolerant = true, hide_client_headers = false, limit_by = "consumer", policy = "local", second = 2000, }, enabled = true, protocols = { "grpc", "grpcs", "http", "https", }, tags = { "k8s-name:nginx-sample-1-rate", "k8s-namespace:default", "k8s-kind:KongPlugin", "k8s-uid:5163972c-543d-48ae-b0f6-21701c43c1ff", "k8s-group:configuration.konghq.com", "k8s-version:v1", }, } local input = { _format_version = "3.0", consumers = { { acls = { { group = "app", tags = { "k8s-name:app-acl", "k8s-namespace:default", "k8s-kind:Secret", "k8s-uid:f1c5661c-a087-4c4b-b545-2d8b3870d661", "k8s-version:v1", }, }, }, basicauth_credentials = { { password = "6ef728de-ba68-4e59-acb9-6e502c28ae0b", tags = { "k8s-name:app-cred", "k8s-namespace:default", "k8s-kind:Secret", "k8s-uid:aadd4598-2969-49ea-82ac-6ab5159e2f2e", "k8s-version:v1", }, username = username, }, }, id = consumer_id, tags = { "k8s-name:app", "k8s-namespace:default", "k8s-kind:KongConsumer", "k8s-uid:7ee19bea-72d5-402b-bf0f-f57bf81032bf", "k8s-group:configuration.konghq.com", "k8s-version:v1", }, username = username, }, }, plugins = { plugin, { config = { error_code = 429, error_message = "API rate limit exceeded", fault_tolerant = true, hide_client_headers = false, limit_by = "consumer", policy = "local", second = 2000, }, consumer = username, enabled = true, name = "rate-limiting", protocols = { "grpc", "grpcs", "http", "https", }, tags = { "k8s-name:nginx-sample-2-rate", "k8s-namespace:default", "k8s-kind:KongPlugin", "k8s-uid:89fa1cd1-78da-4c3e-8c3b-32be1811535a", "k8s-group:configuration.konghq.com", "k8s-version:v1", }, }, { config = { allow = { "nginx-sample-1", "app", }, hide_groups_header = false, }, enabled = true, name = "acl", protocols = { "grpc", "grpcs", "http", "https", }, service = service_name, tags = { "k8s-name:nginx-sample-1", "k8s-namespace:default", "k8s-kind:KongPlugin", "k8s-uid:b9373482-32e1-4ac3-bd2a-8926ab728700", "k8s-group:configuration.konghq.com", "k8s-version:v1", }, }, }, services = { { connect_timeout = 60000, host = upstream_name, id = "8c17ab3e-b6bd-51b2-b5ec-878b4d608b9d", name = service_name, path = "/", port = 80, protocol = "http", read_timeout = 60000, retries = 5, routes = { { https_redirect_status_code = 426, id = "84d45463-1faa-55cf-8ef6-4285007b715e", methods = { "GET", }, name = "default.nginx-sample-1.nginx-sample-1..80", path_handling = "v0", paths = { "/sample/1", }, preserve_host = true, protocols = { "http", "https", }, regex_priority = 0, request_buffering = true, response_buffering = true, strip_path = false, tags = { "k8s-name:nginx-sample-1", "k8s-namespace:default", "k8s-kind:Ingress", "k8s-uid:916a6e5a-eebe-4527-a78d-81963eb3e043", "k8s-group:networking.k8s.io", "k8s-version:v1", }, }, }, tags = { "k8s-name:nginx-sample-1", "k8s-namespace:default", "k8s-kind:Service", "k8s-uid:f7cc87f4-d5f7-41f8-b4e3-70608017e588", "k8s-version:v1", }, write_timeout = 60000, }, }, upstreams = { { algorithm = "round-robin", name = upstream_name, tags = { "k8s-name:nginx-sample-1", "k8s-namespace:default", "k8s-kind:Service", "k8s-uid:f7cc87f4-d5f7-41f8-b4e3-70608017e588", "k8s-version:v1", }, targets = { { target = "nginx-sample-1.default.svc:80", }, }, }, }, } local flattened = post_config(input) validate({ { entity_type = "plugin", entity_name = plugin.name, entity_tags = plugin.tags, entity = plugin, errors = { { field = "consumer.id", message = "missing primary key", type = "field", } }, }, }, flattened) end) it("origin error do not loss when enable flatten_errors - (#12167)", function() local input = { _format_version = "3.0", consumers = { { id = "a73dc9a7-93df-584d-97c0-7f41a1bbce3d", username = "test-consumer-1", tags = { "consumer-1" }, }, { id = "a73dc9a7-93df-584d-97c0-7f41a1bbce32", username = "test-consumer-1", tags = { "consumer-2" }, }, }, } local flattened = post_config(input) validate({ { entity_type = "consumer", entity_id = "a73dc9a7-93df-584d-97c0-7f41a1bbce32", entity_name = nil, entity_tags = { "consumer-2" }, entity = { id = "a73dc9a7-93df-584d-97c0-7f41a1bbce32", username = "test-consumer-1", tags = { "consumer-2" }, }, errors = { { type = "entity", message = "uniqueness violation: 'consumers' entity with username set to 'test-consumer-1' already declared", } }, }, }, flattened) end) it("correctly handles duplicate upstream target errors", function() local target = { target = "10.244.0.12:80", weight = 1, tags = { "target-1" }, } -- this has the same : tuple as the first target, so it will -- be assigned the same id local dupe_target = kong_table.deep_copy(target) dupe_target.tags = { "target-2" } local input = { _format_version = "3.0", services = { { connect_timeout = 60000, host = "httproute.default.httproute-testing.0", id = "4e3cb785-a8d0-5866-aa05-117f7c64f24d", name = "httproute.default.httproute-testing.0", port = 8080, protocol = "http", read_timeout = 60000, retries = 5, routes = { { https_redirect_status_code = 426, id = "073fc413-1c03-50b4-8f44-43367c13daba", name = "httproute.default.httproute-testing.0.0", path_handling = "v0", paths = { "~/httproute-testing$", "/httproute-testing/", }, preserve_host = true, protocols = { "http", "https", }, strip_path = true, tags = {}, }, }, tags = {}, write_timeout = 60000, }, }, upstreams = { { algorithm = "round-robin", name = "httproute.default.httproute-testing.0", id = "e9792964-6797-482c-bfdf-08220a4f6832", tags = { "k8s-name:httproute-testing", "k8s-namespace:default", "k8s-kind:HTTPRoute", "k8s-uid:f9792964-6797-482c-bfdf-08220a4f6839", "k8s-group:gateway.networking.k8s.io", "k8s-version:v1", }, targets = { { target = "10.244.0.11:80", weight = 1, }, { target = "10.244.0.12:80", weight = 1, }, }, }, { algorithm = "round-robin", name = "httproute.default.httproute-testing.1", id = "f9792964-6797-482c-bfdf-08220a4f6839", tags = { "k8s-name:httproute-testing", "k8s-namespace:default", "k8s-kind:HTTPRoute", "k8s-uid:f9792964-6797-482c-bfdf-08220a4f6839", "k8s-group:gateway.networking.k8s.io", "k8s-version:v1", }, targets = { target, dupe_target, }, }, }, } local flattened = post_config(input) local entry = get_by_tag(dupe_target.tags[1], flattened) assert.not_nil(entry, "no error for duplicate target in the response") -- sanity assert.same(dupe_target.tags, entry.entity_tags) assert.is_table(entry.errors, "missing entity errors table") assert.equals(1, #entry.errors, "expected 1 entity error") assert.is_table(entry.errors[1], "entity error is not a table") local e = entry.errors[1] assert.equals("entity", e.type) local exp = string.format("uniqueness violation: 'targets' entity with primary key set to '%s' already declared", entry.entity_id) assert.equals(exp, e.message) end) end) describe("Admin API (concurrency tests) #off", function() local client before_each(function() assert(helpers.start_kong({ database = "off", nginx_worker_processes = 8, lmdb_map_size = LMDB_MAP_SIZE, })) client = assert(helpers.admin_client()) end) after_each(function() helpers.stop_kong() if client then client:close() end end) it("succeeds with 200 and replaces previous cache if config fits in cache", function() -- stress test to check for worker concurrency issues for k = 1, 100 do if client then client:close() client = helpers.admin_client() end local res = assert(client:send { method = "POST", path = "/config", body = { config = [[ { "_format_version" : "1.1", "consumers" : [ { "username" : "previous", }, ], } ]], }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(201) client:close() local consumers = {} for i = 1, 10 do table.insert(consumers, [[ { "username" : "bobby-]] .. k .. "-" .. i .. [[", } ]]) end local config = [[ { "_format_version" : "1.1", "consumers" : [ ]] .. table.concat(consumers, ", ") .. [[ ] } ]] client = assert(helpers.admin_client()) res = assert(client:send { method = "POST", path = "/config", body = { config = config, }, headers = { ["Content-Type"] = "application/json" } }) assert.response(res).has.status(201) client:close() helpers.wait_until(function() client = assert(helpers.admin_client()) res = assert(client:send { method = "GET", path = "/consumers/previous", headers = { ["Content-Type"] = "application/json" } }) client:close() return res.status == 404 end, WORKER_SYNC_TIMEOUT) helpers.wait_until(function() client = assert(helpers.admin_client()) res = assert(client:send { method = "GET", path = "/consumers/bobby-" .. k .. "-10", headers = { ["Content-Type"] = "application/json" } }) local body = res:read_body() client:close() if res.status ~= 200 then return false end local json = cjson.decode(body) return "bobby-" .. k .. "-10" == json.username end, WORKER_SYNC_TIMEOUT) end end) end) describe("Admin API #off with Unique Foreign #unique", function() local client lazy_setup(function() assert(helpers.start_kong({ database = "off", plugins = "unique-foreign", nginx_worker_processes = 1, lmdb_map_size = LMDB_MAP_SIZE, })) end) lazy_teardown(function() helpers.stop_kong() end) before_each(function() client = assert(helpers.admin_client()) end) after_each(function() if client then client:close() end end) it("unique foreign works with dbless", function() local config = [[ _format_version: "1.1" unique_foreigns: - name: name unique_references: - note: note ]] local res = assert(client:send { method = "POST", path = "/config", body = { config = config, }, headers = { ["Content-Type"] = "application/json" } }) assert.res_status(201, res) local res = assert(client:get("/unique-foreigns")) local body = assert.res_status(200, res) local foreigns = cjson.decode(body) assert.equal(foreigns.data[1].name, "name") local res = assert(client:get("/unique-references")) local body = assert.res_status(200, res) local references = cjson.decode(body) assert.equal(references.data[1].note, "note") assert.equal(references.data[1].unique_foreign.id, foreigns.data[1].id) -- get default workspace id in lmdb local cmd = string.format( [[resty --main-conf "lmdb_environment_path %s/%s;" spec/fixtures/dump_lmdb_key.lua %q]], TEST_CONF.prefix, TEST_CONF.lmdb_environment_path, require("kong.constants").DECLARATIVE_DEFAULT_WORKSPACE_KEY) local handle = io.popen(cmd) local ws_id = handle:read("*a") handle:close() -- get unique_field_key local declarative = require "kong.db.declarative" local key = declarative.unique_field_key("unique_references", ws_id, "unique_foreign", foreigns.data[1].id, true) local cmd = string.format( [[resty --main-conf "lmdb_environment_path %s/%s;" spec/fixtures/dump_lmdb_key.lua %q]], TEST_CONF.prefix, TEST_CONF.lmdb_environment_path, key) local handle = io.popen(cmd) local unique_field_key = handle:read("*a") handle:close() assert.is_string(unique_field_key, "non-string result from unique lookup") assert.not_equals("", unique_field_key, "empty result from unique lookup") -- get the entity value local cmd = string.format( [[resty --main-conf "lmdb_environment_path %s/%s;" spec/fixtures/dump_lmdb_key.lua %q]], TEST_CONF.prefix, TEST_CONF.lmdb_environment_path, unique_field_key) local handle = io.popen(cmd) local result = handle:read("*a") handle:close() assert.not_equals("", result, "empty result from unique lookup") local cached_reference = assert(require("kong.db.declarative.marshaller").unmarshall(result)) -- NOTE: we have changed internl LDMB storage format, and dao does not has this field(ws_id) cached_reference.ws_id = nil assert.same(cached_reference, references.data[1]) local cache = { get = function(_, k) if k ~= "unique_references|" ..ws_id .. "|unique_foreign:" .. foreigns.data[1].id then return nil end return cached_reference end } mocker.setup(finally, { kong = { core_cache = cache, } }) local _, db = helpers.get_db_utils("off", {}, { "unique-foreign" }) local i = 1 while true do local n, v = debug.getupvalue(db.unique_references.strategy.select_by_field, i) if not n then break end if n == "select_by_key" then local j = 1 while true do local n, v = debug.getupvalue(v, j) if not n then break end if n == "kong" then v.core_cache = cache break end j = j + 1 end break end i = i + 1 end -- TODO: figure out how to mock LMDB in busted -- local unique_reference, err, err_t = db.unique_references:select_by_unique_foreign({ -- id = foreigns.data[1].id, -- }) -- assert.is_nil(err) -- assert.is_nil(err_t) -- assert.equal(references.data[1].id, unique_reference.id) -- assert.equal(references.data[1].note, unique_reference.note) -- assert.equal(references.data[1].unique_foreign.id, unique_reference.unique_foreign.id) end) end) describe("Admin API #off with cache key vs endpoint key #unique", function() local client lazy_setup(function() assert(helpers.start_kong({ database = "off", plugins = "cache-key-vs-endpoint-key", nginx_worker_processes = 1, lmdb_map_size = LMDB_MAP_SIZE, })) end) lazy_teardown(function() helpers.stop_kong() end) before_each(function() client = assert(helpers.admin_client()) end) after_each(function() if client then client:close() end end) it("prefers cache key rather than endpoint key from primary key uniqueness", function() local res = assert(client:send { method = "POST", path = "/config", body = { config = [[ _format_version: "1.1" ck_vs_ek_testcase: - name: foo service: my_service - name: bar service: my_service services: - name: my_service url: http://example.com path: / ]], }, headers = { ["Content-Type"] = "application/json" } }) local body = assert.response(res).has.status(400) local json = cjson.decode(body) assert.same(14, json.code) assert.same("invalid declarative configuration", json.name) assert.matches("uniqueness violation: 'ck_vs_ek_testcase' entity " .. "with primary key set to '.*' already declared", json.fields.ck_vs_ek_testcase[2]) assert.matches([[declarative config is invalid: ]] .. [[{ck_vs_ek_testcase={%[2%]="uniqueness violation: ]] .. [['ck_vs_ek_testcase' entity with primary key set to ]] .. [['.*' already declared"}}]], json.message) end) end) describe("Admin API #off worker_consistency=eventual", function() local client local WORKER_STATE_UPDATE_FREQ = 0.1 lazy_setup(function() assert(helpers.start_kong({ database = "off", lmdb_map_size = LMDB_MAP_SIZE, worker_consistency = "eventual", worker_state_update_frequency = WORKER_STATE_UPDATE_FREQ, })) end) lazy_teardown(function() helpers.stop_kong() end) before_each(function() client = assert(helpers.admin_client()) end) after_each(function() if client then client:close() end end) it("does not increase timer usage (regression)", function() -- 1. configure a simple service local res = assert(client:send { method = "POST", path = "/config", body = helpers.unindent([[ _format_version: '1.1' services: - name: konghq url: http://konghq.com path: / plugins: - name: prometheus ]]), headers = { ["Content-Type"] = "application/yaml" }, }) assert.response(res).has.status(201) -- 2. check the timer count res = assert(client:send { method = "GET", path = "/metrics", }) local res_body = assert.res_status(200, res) local req1_pending_timers = assert.matches('kong_nginx_timers{state="pending"} %d+', res_body) local req1_running_timers = assert.matches('kong_nginx_timers{state="running"} %d+', res_body) req1_pending_timers = assert(tonumber(string.match(req1_pending_timers, "%d+"))) req1_running_timers = assert(tonumber(string.match(req1_running_timers, "%d+"))) -- 3. update the service res = assert(client:send { method = "POST", path = "/config", body = helpers.unindent([[ _format_version: '1.1' services: - name: konghq url: http://konghq.com path: /install#kong-community plugins: - name: prometheus ]]), headers = { ["Content-Type"] = "application/yaml" }, }) assert.response(res).has.status(201) -- 4. check if timer count is still the same res = assert(client:send { method = "GET", path = "/metrics", }) local res_body = assert.res_status(200, res) local req2_pending_timers = assert.matches('kong_nginx_timers{state="pending"} %d+', res_body) local req2_running_timers = assert.matches('kong_nginx_timers{state="running"} %d+', res_body) req2_pending_timers = assert(tonumber(string.match(req2_pending_timers, "%d+"))) req2_running_timers = assert(tonumber(string.match(req2_running_timers, "%d+"))) assert.equal(req1_pending_timers, req2_pending_timers) assert.equal(req1_running_timers, req2_running_timers) end) end)