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 <addr>:<port> 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)
