Files
2026-07-13 12:32:21 +08:00

925 lines
33 KiB
Lua

local cjson = require "cjson"
local uuid = require "kong.tools.uuid"
local helpers = require "spec.helpers"
local Errors = require "kong.db.errors"
local unindent = helpers.unindent
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
for _, strategy in helpers.each_strategy() do
describe("Admin API #" .. strategy, function()
local bp
local db
local client
lazy_setup(function()
bp, db = helpers.get_db_utils(strategy, {
"routes",
"services",
})
assert(helpers.start_kong({
database = strategy,
}))
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("/services", function()
describe("POST", function()
it_content_types("creates a service", function(content_type)
return function()
local res = client:post("/services", {
body = {
protocol = "http",
host = "service.test",
},
headers = { ["Content-Type"] = content_type },
})
local body = assert.res_status(201, res)
local json = cjson.decode(body)
assert.is_string(json.id)
assert.is_number(json.created_at)
assert.is_number(json.updated_at)
assert.equals(cjson.null, json.name)
assert.equals("http", json.protocol)
assert.equals("service.test", json.host)
assert.equals(80, json.port)
assert.equals(60000, json.connect_timeout)
assert.equals(60000, json.write_timeout)
assert.equals(60000, json.read_timeout)
end
end)
it_content_types("creates a service with url", function(content_type)
return function()
local res = client:post("/services", {
body = {
url = "http://service.test/",
},
headers = { ["Content-Type"] = content_type },
})
local body = assert.res_status(201, res)
local json = cjson.decode(body)
assert.is_string(json.id)
assert.is_number(json.created_at)
assert.is_number(json.updated_at)
assert.equals(cjson.null, json.name)
assert.equals("http", json.protocol)
assert.equals("service.test", json.host)
assert.equals("/", json.path)
assert.equals(80, json.port)
assert.equals(60000, json.connect_timeout)
assert.equals(60000, json.write_timeout)
assert.equals(60000, json.read_timeout)
end
end)
it_content_types("'port' defaults to 443 when 'url' scheme is https", function(content_type)
return function()
local res = client:post("/services", {
body = {
url = "https://service.test/",
},
headers = { ["Content-Type"] = content_type },
})
local body = assert.res_status(201, res)
local json = cjson.decode(body)
assert.is_string(json.id)
assert.is_number(json.created_at)
assert.is_number(json.updated_at)
assert.equals(cjson.null, json.name)
assert.equals("https", json.protocol)
assert.equals("service.test", json.host)
assert.equals("/", json.path)
assert.equals(443, json.port)
assert.equals(60000, json.connect_timeout)
assert.equals(60000, json.write_timeout)
assert.equals(60000, json.read_timeout)
end
end)
it_content_types("client error with with empty url", function(content_type)
return function()
local res = client:post("/services", {
body = {
url = "",
},
headers = { ["Content-Type"] = content_type },
})
assert.res_status(400, res)
end
end)
it_content_types("client error with invalid url", function(content_type)
return function()
local res = client:post("/services", {
body = {
url = " ",
},
headers = { ["Content-Type"] = content_type },
})
assert.res_status(400, res)
end
end)
end)
describe("GET", function()
describe("with data", function()
lazy_setup(function()
db:truncate("services")
for _ = 1, 10 do
assert(bp.named_services:insert())
end
end)
it("retrieves the first page", function()
local res = client:get("/services")
local res = assert.res_status(200, res)
local json = cjson.decode(res)
assert.equal(10, #json.data)
end)
it("paginates a set", function()
local pages = {}
local offset
for i = 1, 4 do
local res = client:get("/services",
{ query = { size = 3, offset = offset }})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
if i < 4 then
assert.equal(3, #json.data)
else
assert.equal(1, #json.data)
end
if i > 1 then
-- check all pages are different
assert.not_same(pages[i-1], json)
end
offset = json.offset
pages[i] = json
end
end)
it("propagate in next a page size", function()
local res = client:get("/services",
{ query = { size = 3 }})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.equals("/services?offset=" .. ngx.escape_uri(json.offset) .. "&size=3", json.next)
end)
end)
describe("with no data", function()
lazy_setup(function()
db:truncate("services")
end)
it("data property is an empty array and not an empty hash", function()
local res = client:get("/services")
local body = assert.res_status(200, res)
assert.matches('"data":%[%]', body)
local json = cjson.decode(body)
assert.same({ data = {}, next = cjson.null }, json)
end)
end)
describe("errors", function()
it("handles invalid filters", function()
local res = client:get("/services", { query = { foo = "bar" } })
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.same({ data = {}, next = cjson.null }, json)
end)
it("ignores an invalid body", function()
local res = client:get("/services", {
body = "this fails if decoded as json",
headers = {
["Content-Type"] = "application/json",
}
})
assert.res_status(200, res)
end)
end)
end)
describe("/services/{service}", function()
describe("GET", function()
it("retrieves by id", function()
local service = bp.services:insert(nil, { nulls = true })
local res = client:get("/services/" .. service.id)
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.same(service, json)
end)
it("retrieves by name", function()
local service = bp.named_services:insert(nil, { nulls = true })
local res = client:get("/services/" .. service.name)
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.same(service, json)
end)
it("retrieves by utf-8 name and percent-escaped utf-8 name", function()
local service = bp.services:insert({ name = "円" }, { nulls = true })
local res = client:get("/services/" .. service.name)
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.same(service, json)
res = client:get("/services/%E5%86%86")
body = assert.res_status(200, res)
json = cjson.decode(body)
assert.same(service, json)
end)
it("returns 404 if not found", function()
local res = client:get("/services/" .. uuid.uuid())
assert.res_status(404, res)
end)
it("returns 404 if not found by name", function()
local res = client:get("/services/not-found")
assert.res_status(404, res)
end)
it("ignores an invalid body", function()
local service = bp.services:insert()
local res = client:get("/services/" .. service.id, {
headers = {
["Content-Type"] = "application/json"
},
body = "this fails if decoded as json",
})
assert.res_status(200, res)
end)
it("ignores an invalid body by name", function()
local service = bp.named_services:insert()
local res = client:get("/services/" .. service.name, {
headers = {
["Content-Type"] = "application/json"
},
body = "this fails if decoded as json",
})
assert.res_status(200, res)
end)
end)
describe("PATCH", function()
it_content_types("updates if found", function(content_type)
return function()
if content_type == "multipart/form-data" then
-- the client doesn't play well with this
return
end
local service = bp.services:insert()
local res = client:patch("/services/" .. service.id, {
headers = {
["Content-Type"] = content_type
},
body = {
name = cjson.null,
protocol = "https",
},
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.equal(cjson.null, json.name)
assert.equal("https", json.protocol)
assert.equal(service.id, json.id)
local in_db = assert(db.services:select(service, { nulls = true }))
assert.same(json, in_db)
end
end)
it_content_types("updates if found by name", function(content_type)
return function()
local service = bp.named_services:insert()
local res = client:patch("/services/" .. service.name, {
headers = {
["Content-Type"] = content_type
},
body = {
protocol = "https",
},
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.equal("https", json.protocol)
assert.equal(service.id, json.id)
assert.equal(service.name, json.name)
local in_db = assert(db.services:select_by_name(service.name, { nulls = true }))
assert.same(json, in_db)
end
end)
it_content_types("updates with url", function(content_type)
return function()
if content_type == "multipart/form-data" then
-- the client doesn't play well with this
return
end
local service = bp.services:insert()
local res = client:patch("/services/" .. service.id, {
headers = {
["Content-Type"] = content_type
},
body = {
url = "http://example.test:443"
},
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.equal("http", json.protocol)
assert.equal("example.test", json.host)
assert.equal(443, json.port)
assert.equal(cjson.null, json.path)
assert.equal(service.id, json.id)
local in_db = assert(db.services:select(service, { nulls = true }))
assert.same(json, in_db)
local res = client:patch("/services/" .. service.id, {
headers = {
["Content-Type"] = content_type
},
body = {
url = "https://example2.test:80/"
},
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.equal("https", json.protocol)
assert.equal("example2.test", json.host)
assert.equal(80, json.port)
assert.equal("/", json.path)
assert.equal(service.id, json.id)
local in_db = assert(db.services:select(service, { nulls = true }))
assert.same(json, in_db)
local res = client:patch("/services/" .. service.id, {
headers = {
["Content-Type"] = content_type
},
body = {
url = "http://example2.test"
},
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.equal("http", json.protocol)
assert.equal("example2.test", json.host)
assert.equal(80, json.port)
assert.equal(cjson.null, json.path)
assert.equal(service.id, json.id)
local in_db = assert(db.services:select(service, { nulls = true }))
assert.same(json, in_db)
end
end)
end)
describe("DELETE", function()
it("deletes a service", function()
local service = bp.services:insert()
local res = client:delete("/services/" .. service.id)
local body = assert.res_status(204, res)
assert.equal("", body)
local in_db, err = db.services:select(service, { nulls = true })
assert.is_nil(err)
assert.is_nil(in_db)
end)
it("deletes a service by name", function()
local service = bp.named_services:insert()
local res = client:delete("/services/" .. service.name)
local body = assert.res_status(204, res)
assert.equal("", body)
local in_db, err = db.services:select_by_name(service.name)
assert.is_nil(err)
assert.is_nil(in_db)
end)
describe("errors", function()
it("returns HTTP 204 even if not found", function()
local res = client:delete("/services/" .. uuid.uuid())
assert.res_status(204, res)
end)
it("returns HTTP 204 even if not found by name", function()
local res = client:delete("/services/not-found")
assert.res_status(204, res)
end)
end)
end)
end)
describe("/services/{service}/routes", function()
it_content_types("lists all routes belonging to service", function(content_type)
return function()
local service = db.services:insert({
protocol = "http",
host = "service.test",
})
local route = db.routes:insert({
protocol = "http",
hosts = { "service.test" },
service = service,
})
local _ = db.routes:insert({
protocol = "http",
hosts = { "service.test" },
})
local res = client:get("/services/" .. service.id .. "/routes", {
headers = { ["Content-Type"] = content_type },
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.same({ data = { route }, next = cjson.null }, json)
end
end)
end)
describe("/services/{service}/plugins", function()
--local service = bp.named_services:insert()
local inputs = {
["application/x-www-form-urlencoded"] = {
name = "key-auth",
["config.key_names[1]"] = "apikey",
["config.key_names[2]"] = "key",
},
["application/json"] = {
name = "key-auth",
config = {
key_names = {"apikey", "key"}
},
},
}
describe("POST", function()
it_content_types("creates a plugin config for a Service", function(content_type)
return function()
if content_type == "multipart/form-data" then
-- the client doesn't play well with this
return
end
local service = bp.services:insert()
local res = assert(client:send {
method = "POST",
path = "/services/" .. service.id .. "/plugins",
body = inputs[content_type],
headers = { ["Content-Type"] = content_type }
})
local body = assert.res_status(201, res)
local json = cjson.decode(body)
assert.equal("key-auth", json.name)
assert.same({ "apikey", "key" }, json.config.key_names)
end
end)
it_content_types("references a Service by name", function(content_type)
return function()
if content_type == "multipart/form-data" then
-- the client doesn't play well with this
return
end
local service = bp.named_services:insert()
local res = assert(client:send {
method = "POST",
path = "/services/" .. service.name .. "/plugins",
body = inputs[content_type],
headers = { ["Content-Type"] = content_type }
})
local body = assert.res_status(201, res)
local json = cjson.decode(body)
assert.equal("key-auth", json.name)
assert.same({ "apikey", "key" }, json.config.key_names)
end
end)
describe("errors", function()
it_content_types("handles invalid input", function(content_type)
return function()
local service = bp.services:insert()
local res = assert(client:send {
method = "POST",
path = "/services/" .. service.id .. "/plugins",
body = {},
headers = { ["Content-Type"] = content_type }
})
local body = assert.res_status(400, res)
local json = cjson.decode(body)
assert.same({
code = Errors.codes.SCHEMA_VIOLATION,
name = "schema violation",
message = "schema violation (name: required field missing)",
fields = {
name = "required field missing",
}
}, json)
end
end)
it_content_types("returns 409 on conflict (same plugin name)", function(content_type)
return function()
local service = bp.services:insert()
-- insert initial plugin
local res = assert(client:send {
method = "POST",
path = "/services/" .. service.id .. "/plugins",
body = {
name = "basic-auth",
},
headers = { ["Content-Type"] = content_type }
})
assert.response(res).has.status(201)
assert.response(res).has.jsonbody()
-- do it again, to provoke the error
local res = assert(client:send {
method = "POST",
path = "/services/" .. service.id .. "/plugins",
body = {
name = "basic-auth",
},
headers = { ["Content-Type"] = content_type }
})
assert.response(res).has.status(409)
local json = assert.response(res).has.jsonbody()
assert.same({
code = Errors.codes.UNIQUE_VIOLATION,
name = "unique constraint violation",
fields = {
consumer = ngx.null,
name = "basic-auth",
route = ngx.null,
service = {
id = service.id,
}
},
message = [[UNIQUE violation detected on '{consumer=null,name="basic-auth",]] ..
[[route=null,service={id="]] .. service.id ..
[["}}']],
}, json)
end
end)
it("returns 409 on id conflict (same plugin id)", function(content_type)
return function()
local service = bp.services:insert()
-- insert initial plugin
local res = assert(client:send {
method = "POST",
path = "/services/" .. service.id .. "/plugins",
body = {
name = "basic-auth",
},
headers = { ["Content-Type"] = content_type }
})
local body = assert.res_status(201, res)
local plugin = cjson.decode(body)
-- do it again, to provoke the error
local conflict_res = assert(client:send {
method = "POST",
path = "/services/" .. service.id .. "/plugins",
body = {
name = "key-auth",
id = plugin.id,
},
headers = { ["Content-Type"] = content_type }
})
local conflict_body = assert.res_status(409, conflict_res)
local json = cjson.decode(conflict_body)
assert.same({ id = "already exists with value '" .. plugin.id .. "'"}, json)
end
end)
end)
end)
describe("PATCH", function()
it("updates a plugin", function()
local service = bp.services:insert()
bp.routes:insert({
service = { id = service.id },
hosts = { "example.test" },
})
local plugin = bp.key_auth_plugins:insert({ service = service })
local res = assert(client:send {
method = "PATCH",
path = "/services/" .. service.id .. "/plugins/" .. plugin.id,
body = {enabled = false},
headers = {["Content-Type"] = "application/json"}
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.False(json.enabled)
local in_db = assert(db.plugins:select(plugin, { nulls = true }))
assert.same(json, in_db)
end)
it("updates a plugin bis", function()
local service = bp.services:insert()
bp.routes:insert({
service = { id = service.id },
hosts = { "example.test" },
})
local plugin = bp.key_auth_plugins:insert({ service = service })
plugin.enabled = not plugin.enabled
plugin.created_at = nil
plugin.updated_at = nil
local res = assert(client:send {
method = "PATCH",
path = "/services/" .. service.id .. "/plugins/" .. plugin.id,
body = plugin,
headers = {["Content-Type"] = "application/json"}
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.equal(plugin.enabled, json.enabled)
end)
it("updates a plugin (removing foreign key reference)", function()
local service = bp.services:insert()
local plugin = bp.key_auth_plugins:insert({ service = service })
local res = assert(client:send {
method = "PATCH",
path = "/services/" .. service.id .. "/plugins/" .. plugin.id,
body = {
service = cjson.null,
},
headers = { ["Content-Type"] = "application/json" }
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.same(ngx.null, json.service)
local in_db = assert(db.plugins:select(plugin, { nulls = true }))
assert.same(json, in_db)
end)
describe("errors", function()
it("handles invalid input", function()
local service = bp.services:insert()
bp.routes:insert({
service = { id = service.id },
hosts = { "example.test" },
})
local plugin = bp.key_auth_plugins:insert({
service = service,
config = { key_names = { "testkey" } },
})
local before = assert(db.plugins:select(plugin, { nulls = true }))
local res = assert(client:send {
method = "PATCH",
path = "/services/" .. service.id .. "/plugins/" .. plugin.id,
body = { foo = "bar" },
headers = {["Content-Type"] = "application/json"}
})
local body = cjson.decode(assert.res_status(400, res))
assert.same({
message = "schema violation (foo: unknown field)",
name = "schema violation",
fields = {
foo = "unknown field",
},
code = 2,
}, body)
local after = assert(db.plugins:select(plugin, { nulls = true }))
assert.same(before, after)
assert.same({"testkey"}, after.config.key_names)
end)
it("returns 404 if not found", function()
local service = bp.services:insert()
local res = assert(client:send {
method = "PATCH",
path = "/services/" .. service.id .. "/plugins/f4aecadc-05c7-11e6-8d41-1f3b3d5fa15c",
body = {enabled = false},
headers = {["Content-Type"] = "application/json"}
})
assert.res_status(404, res)
end)
end)
end)
describe("GET", function()
it("retrieves the first page", function()
local service = bp.services:insert()
assert(db.plugins:insert {
name = "key-auth",
service = { id = service.id },
})
local res = assert(client:send {
method = "GET",
path = "/services/" .. service.id .. "/plugins"
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.equal(1, #json.data)
end)
it("ignores an invalid body", function()
local service = bp.services:insert()
local res = assert(client:send {
method = "GET",
path = "/services/" .. service.id .. "/plugins",
body = "this fails if decoded as json",
headers = {
["Content-Type"] = "application/json",
}
})
assert.res_status(200, res)
end)
end)
end)
describe("/services/{service}/plugins/{plugin}", function()
describe("GET", function()
it("retrieves a plugin by id", function()
local service = bp.services:insert()
local plugin = bp.key_auth_plugins:insert({
service = service,
})
local res = client:get("/services/" .. service.id .. "/plugins/" .. plugin.id)
local body = assert.res_status(200, res)
local json = cjson.decode(body)
local in_db = assert(db.plugins:select(plugin, { nulls = true }))
assert.same(json, in_db)
end)
it("retrieves a plugin by instance_name", function()
local service = bp.services:insert()
local plugin = bp.key_auth_plugins:insert({
instance_name = "name-" .. uuid.uuid(),
service = service,
})
local res = client:get("/services/" .. service.id .. "/plugins/" .. plugin.instance_name)
local body = assert.res_status(200, res)
local json = cjson.decode(body)
local in_db = assert(db.plugins:select(plugin, { nulls = true }))
assert.same(json, in_db)
end)
end)
describe("DELETE", function()
it("deletes a plugin by id", function()
local service = bp.services:insert()
local plugin = bp.key_auth_plugins:insert({
service = service,
})
local res = assert(client:delete("/services/" .. service.id .. "/plugins/" .. plugin.id))
assert.res_status(204, res)
local in_db, err = db.plugins:select(plugin, { nulls = true })
assert.is_nil(err)
assert.is_nil(in_db)
end)
it("deletes a plugin by instance_name", function()
local service = bp.services:insert()
local plugin = bp.key_auth_plugins:insert({
instance_name = "name-" .. uuid.uuid(),
service = service,
})
local res = assert(client:delete("/services/" .. service.id .. "/plugins/" .. plugin.instance_name))
assert.res_status(204, res)
local in_db, err = db.plugins:select(plugin, { nulls = true })
assert.is_nil(err)
assert.is_nil(in_db)
end)
end)
end)
describe("errors", function()
it("handles malformed JSON body", function()
local res = client:post("/services", {
body = '{"hello": "world"',
headers = { ["Content-Type"] = "application/json" }
})
local body = assert.res_status(400, res)
assert.equal('{"message":"Cannot parse JSON body"}', body)
end)
it_content_types("handles invalid input", function(content_type)
return function()
-- Missing params
local res = client:post("/services", {
body = {},
headers = { ["Content-Type"] = content_type }
})
local body = assert.res_status(400, res)
local json = cjson.decode(body)
assert.same({
host = "required field missing",
}, json.fields)
-- Invalid parameter
res = client:post("/services", {
body = {
host = "example.test",
protocol = "foo",
},
headers = { ["Content-Type"] = content_type }
})
body = assert.res_status(400, res)
json = cjson.decode(body)
assert.same({ protocol = "expected one of: grpc, grpcs, http, https, tcp, tls, tls_passthrough, udp" }, json.fields)
end
end)
it_content_types("handles invalid url ", function(content_type)
return function()
local res = client:post("/services", {
body = {
url = "invalid url",
},
headers = { ["Content-Type"] = content_type },
})
local body = assert.res_status(400, res)
local json = cjson.decode(body)
assert.same(
{
name = "schema violation",
code = Errors.codes.SCHEMA_VIOLATION,
message = unindent([[
2 schema violations
(host: required field missing;
path: should start with: /)
]], true, true),
fields = {
host = "required field missing",
path = "should start with: /",
},
}, json
)
end
end)
end)
end)
end)
end