blob: 31addaebd49bfe5dd2fa3fd53f2c237d768c6af2 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import axios from 'axios';
import YAML from 'yaml';
const ENDPOINT = '/apisix/admin/configs';
const HEADER_LAST_MODIFIED = 'x-last-modified';
const HEADER_DIGEST = 'x-digest';
const clientConfig = {
baseURL: 'http://localhost:1984',
headers: {
'X-API-KEY': 'edd1c9f034335f136f87ad84b625c8f1',
},
};
const config1 = {
routes: [
{
id: 'r1',
uri: '/r1',
upstream: {
nodes: { '127.0.0.1:1980': 1 },
type: 'roundrobin',
},
plugins: { 'proxy-rewrite': { uri: '/hello' } },
},
],
};
const config2 = {
routes: [
{
id: 'r2',
uri: '/r2',
upstream: {
nodes: { '127.0.0.1:1980': 1 },
type: 'roundrobin',
},
plugins: { 'proxy-rewrite': { uri: '/hello' } },
},
],
};
const invalidConfVersionConfig1 = {
routes_conf_version: -1,
};
const invalidConfVersionConfig2 = {
routes_conf_version: 'adc',
};
const routeWithModifiedIndex = {
routes: [
{
id: 'r1',
uri: '/r1',
modifiedIndex: 1,
upstream: {
nodes: { '127.0.0.1:1980': 1 },
type: 'roundrobin',
},
plugins: { 'proxy-rewrite': { uri: '/hello' } },
},
],
};
const routeWithKeyAuth = {
routes: [
{
id: 'r1',
uri: '/r1',
upstream: {
nodes: { '127.0.0.1:1980': 1 },
type: 'roundrobin',
},
plugins: {
'proxy-rewrite': { uri: '/hello' },
'key-auth': {},
},
},
],
};
const consumerWithModifiedIndex = {
routes: routeWithKeyAuth.routes,
consumers: [
{
modifiedIndex: 10,
username: 'jack',
plugins: {
'key-auth': {
key: 'jack-key',
},
},
},
],
};
const credential1 = {
routes: routeWithKeyAuth.routes,
consumers: [
{
username: 'john_1',
},
{
id: 'john_1/credentials/john-a',
plugins: {
'key-auth': {
key: 'auth-a',
},
},
},
{
id: 'john_1/credentials/john-b',
plugins: {
'key-auth': {
key: 'auth-b',
},
},
},
],
};
const unknownPlugins = {
'invalid-plugin': {},
};
const routeWithUnknownPlugins = {
routes: [
{
id: 'r1',
uri: '/r1',
plugins: unknownPlugins,
},
],
};
const servicesWithUnknownPlugins = {
services: [
{
id: 's1',
name: 's1',
plugins: unknownPlugins,
},
],
};
const invalidUpstream = {
nodes: { '127.0.0.1:1980': 1 },
type: 'chash',
hash_on: 'vars',
key: 'args_invalid',
};
const routeWithInvalidUpstream = {
routes: [
{
id: 'r1',
uri: '/r1',
upstream: invalidUpstream,
},
],
};
const serviceWithInvalidUpstream = {
services: [
{
id: 's1',
name: 's1',
upstream: invalidUpstream,
},
],
};
let mockDigest = 1;
describe('Admin - Standalone', () => {
const client = axios.create(clientConfig);
client.interceptors.response.use((response) => {
const contentType = response.headers['content-type'] || '';
if (
contentType.includes('application/yaml') &&
typeof response.data === 'string' &&
response.config.responseType !== 'text'
)
response.data = YAML.parse(response.data);
return response;
});
describe('Normal', () => {
it('dump empty config (default json format)', async () => {
const resp = await client.get(ENDPOINT);
expect(resp.status).toEqual(200);
expect(resp.data.routes_conf_version).toEqual(0);
expect(resp.data.ssls_conf_version).toEqual(0);
expect(resp.data.services_conf_version).toEqual(0);
expect(resp.data.upstreams_conf_version).toEqual(0);
expect(resp.data.consumers_conf_version).toEqual(0);
expect(resp.headers[HEADER_LAST_MODIFIED]).toBe(undefined);
expect(resp.headers[HEADER_DIGEST]).toBe(undefined);
});
it('dump empty config (yaml format)', async () => {
const resp = await client.get(ENDPOINT, {
headers: { Accept: 'application/yaml' },
});
expect(resp.status).toEqual(200);
expect(resp.headers['content-type']).toEqual('application/yaml');
expect(resp.data.routes_conf_version).toEqual(0);
expect(resp.data.ssls_conf_version).toEqual(0);
expect(resp.data.services_conf_version).toEqual(0);
expect(resp.data.upstreams_conf_version).toEqual(0);
expect(resp.data.consumers_conf_version).toEqual(0);
expect(resp.headers[HEADER_LAST_MODIFIED]).toBe(undefined);
expect(resp.headers[HEADER_DIGEST]).toBe(undefined);
});
it('update config (add routes, by json)', async () => {
const resp = await client.put(ENDPOINT, config1, {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(resp.status).toEqual(202);
expect(parseInt(resp.headers[HEADER_LAST_MODIFIED])).toBeGreaterThan(0);
expect(resp.headers[HEADER_DIGEST]).toEqual(`${mockDigest}`);
});
it('update config (same digest, no update)', async () => {
const resp = await client.put(ENDPOINT, config1, {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(resp.status).toEqual(204);
});
it('get metadata (by HTTP HEAD method)', async () => {
const resp = await client.head(ENDPOINT);
expect(resp.status).toEqual(200);
expect(parseInt(resp.headers[HEADER_LAST_MODIFIED])).toBeGreaterThan(0);
expect(resp.headers[HEADER_DIGEST]).toEqual(`${mockDigest}`);
});
it('dump config (json format)', async () => {
const resp = await client.get(ENDPOINT);
expect(resp.status).toEqual(200);
expect(resp.data.routes_conf_version).toEqual(1);
expect(resp.data.ssls_conf_version).toEqual(1);
expect(resp.data.services_conf_version).toEqual(1);
expect(resp.data.upstreams_conf_version).toEqual(1);
expect(resp.data.consumers_conf_version).toEqual(1);
expect(resp.data.routes).toEqual(config1.routes);
expect(parseInt(resp.headers[HEADER_LAST_MODIFIED])).toBeGreaterThan(0);
expect(resp.headers[HEADER_DIGEST]).toEqual(`${mockDigest}`);
});
it('dump config (yaml format)', async () => {
const resp = await client.get(ENDPOINT, {
headers: { Accept: 'application/yaml' },
responseType: 'text',
});
expect(resp.status).toEqual(200);
expect(resp.data).toContain('routes:');
expect(resp.data).toContain('id: r1');
expect(resp.data.startsWith('---')).toBe(false);
expect(resp.data.endsWith('...')).toBe(false);
expect(parseInt(resp.headers[HEADER_LAST_MODIFIED])).toBeGreaterThan(0);
expect(resp.headers[HEADER_DIGEST]).toEqual(`${mockDigest}`);
});
it('check route "r1"', async () => {
const resp = await client.get('/r1');
expect(resp.status).toEqual(200);
expect(resp.data).toEqual('hello world\n');
});
it('update config (add routes, by yaml)', async () => {
mockDigest += 1;
const resp = await client.put(ENDPOINT, YAML.stringify(config2), {
headers: {
'Content-Type': 'application/yaml',
[HEADER_DIGEST]: mockDigest,
},
});
expect(resp.status).toEqual(202);
});
it('dump config (json format)', async () => {
const resp = await client.get(ENDPOINT);
expect(resp.status).toEqual(200);
expect(resp.data.routes_conf_version).toEqual(2);
expect(resp.data.ssls_conf_version).toEqual(2);
expect(resp.data.services_conf_version).toEqual(2);
expect(resp.data.upstreams_conf_version).toEqual(2);
expect(resp.data.consumers_conf_version).toEqual(2);
expect(parseInt(resp.headers[HEADER_LAST_MODIFIED])).toBeGreaterThan(0);
expect(resp.headers[HEADER_DIGEST]).toEqual(`${mockDigest}`);
});
it('check route "r1"', () =>
expect(client.get('/r1')).rejects.toThrow(
'Request failed with status code 404',
));
it('check route "r2"', async () => {
const resp = await client.get('/r2');
expect(resp.status).toEqual(200);
expect(resp.data).toEqual('hello world\n');
});
it('update config (delete routes)', async () => {
mockDigest += 1;
const resp = await client.put(
ENDPOINT,
{},
{ headers: { [HEADER_DIGEST]: mockDigest } },
);
expect(resp.status).toEqual(202);
});
it('check route "r2"', () =>
expect(client.get('/r2')).rejects.toThrow(
'Request failed with status code 404',
));
it('only set routes_conf_version', async () => {
mockDigest += 1;
const resp = await client.put(
ENDPOINT,
YAML.stringify({ routes_conf_version: 15 }),
{
headers: {
'Content-Type': 'application/yaml',
[HEADER_DIGEST]: mockDigest,
},
},
);
expect(resp.status).toEqual(202);
const resp_1 = await client.get(ENDPOINT);
expect(resp_1.status).toEqual(200);
expect(resp_1.data.routes_conf_version).toEqual(15);
expect(resp_1.data.ssls_conf_version).toEqual(4);
expect(resp_1.data.services_conf_version).toEqual(4);
expect(resp_1.data.upstreams_conf_version).toEqual(4);
expect(resp_1.data.consumers_conf_version).toEqual(4);
expect(resp_1.headers[HEADER_DIGEST]).toEqual(`${mockDigest}`);
mockDigest += 1;
const resp2 = await client.put(
ENDPOINT,
YAML.stringify({ routes_conf_version: 17 }),
{
headers: {
'Content-Type': 'application/yaml',
[HEADER_DIGEST]: mockDigest,
},
},
);
expect(resp2.status).toEqual(202);
const resp2_1 = await client.get(ENDPOINT);
expect(resp2_1.status).toEqual(200);
expect(resp2_1.data.routes_conf_version).toEqual(17);
expect(resp2_1.data.ssls_conf_version).toEqual(5);
expect(resp2_1.data.services_conf_version).toEqual(5);
expect(resp2_1.data.upstreams_conf_version).toEqual(5);
expect(resp2_1.data.consumers_conf_version).toEqual(5);
expect(resp2_1.headers[HEADER_DIGEST]).toEqual(`${mockDigest}`);
});
it('control resource changes using modifiedIndex', async () => {
const c1 = structuredClone(routeWithModifiedIndex);
c1.routes[0].modifiedIndex = 1;
const c2 = structuredClone(c1);
c2.routes[0].uri = '/r2';
const c3 = structuredClone(c2);
c3.routes[0].modifiedIndex = 2;
// Update with c1
mockDigest += 1;
const resp = await client.put(ENDPOINT, c1, {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(resp.status).toEqual(202);
// Check route /r1 exists
const resp_1 = await client.get('/r1');
expect(resp_1.status).toEqual(200);
// Update with c2
mockDigest += 1;
const resp2 = await client.put(ENDPOINT, c2, {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(resp2.status).toEqual(202);
// Check route /r1 exists
// But it is not applied because the modifiedIndex is the same as the old value
const resp2_2 = await client.get('/r1');
expect(resp2_2.status).toEqual(200);
// Check route /r2 not exists
const resp2_1 = await client.get('/r2').catch((err) => err.response);
expect(resp2_1.status).toEqual(404);
// Update with c3
mockDigest += 1;
const resp3 = await client.put(ENDPOINT, c3, {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(resp3.status).toEqual(202);
// Check route /r1 not exists
const resp3_1 = await client.get('/r1').catch((err) => err.response);
expect(resp3_1.status).toEqual(404);
// Check route /r2 exists
const resp3_2 = await client.get('/r2');
expect(resp3_2.status).toEqual(200);
});
it('apply consumer with modifiedIndex', async () => {
mockDigest += 1;
const resp = await client.put(ENDPOINT, consumerWithModifiedIndex, {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(resp.status).toEqual(202);
const resp_1 = await client
.get('/r1', { headers: { apikey: 'invalid-key' } })
.catch((err) => err.response);
expect(resp_1.status).toEqual(401);
const resp_2 = await client.get('/r1', {
headers: { apikey: 'jack-key' },
});
expect(resp_2.status).toEqual(200);
const updatedConsumer = structuredClone(consumerWithModifiedIndex);
// update key of key-auth plugin, but modifiedIndex is not changed
updatedConsumer.consumers[0].plugins['key-auth'] = {
key: 'jack-key-updated',
};
mockDigest += 1;
const resp2 = await client.put(ENDPOINT, updatedConsumer, {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(resp2.status).toEqual(202);
const resp2_1 = await client
.get('/r1', { headers: { apikey: 'jack-key-updated' } })
.catch((err) => err.response);
expect(resp2_1.status).toEqual(401);
const resp2_2 = await client.get('/r1', {
headers: { apikey: 'jack-key' },
});
expect(resp2_2.status).toEqual(200);
// update key of key-auth plugin, and modifiedIndex is changed
updatedConsumer.consumers[0].modifiedIndex++;
mockDigest += 1;
const resp3 = await client.put(ENDPOINT, updatedConsumer, {
headers: { [HEADER_DIGEST]: mockDigest },
});
const resp3_1 = await client.get('/r1', {
headers: { apikey: 'jack-key-updated' },
});
expect(resp3_1.status).toEqual(200);
const resp3_2 = await client
.get('/r1', { headers: { apikey: 'jack-key' } })
.catch((err) => err.response);
expect(resp3_2.status).toEqual(401);
});
it('apply consumer with credentials', async () => {
mockDigest += 1;
const resp = await client.put(ENDPOINT, credential1, {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(resp.status).toEqual(202);
const resp_1 = await client.get('/r1', { headers: { apikey: 'auth-a' } });
expect(resp_1.status).toEqual(200);
const resp_2 = await client.get('/r1', { headers: { apikey: 'auth-b' } });
expect(resp_2.status).toEqual(200);
const resp_3 = await client
.get('/r1', { headers: { apikey: 'invalid-key' } })
.catch((err) => err.response);
expect(resp_3.status).toEqual(401);
});
});
describe('Exceptions', () => {
const clientException = axios.create({
...clientConfig,
validateStatus: () => true,
});
it('update config (without digest)', async () => {
const resp = await clientException.put(ENDPOINT, {
routes_conf_version: 100,
});
expect(resp.status).toEqual(400);
expect(resp.data).toEqual({ error_msg: 'missing digest header' });
});
it('update config (lower conf_version)', async () => {
mockDigest += 1;
const resp = await clientException.put(
ENDPOINT,
{ routes_conf_version: 100 },
{ headers: { [HEADER_DIGEST]: mockDigest } },
);
expect(resp.status).toEqual(202);
mockDigest += 1;
const resp2 = await clientException.put(
ENDPOINT,
YAML.stringify(invalidConfVersionConfig1),
{
headers: {
'Content-Type': 'application/yaml',
[HEADER_DIGEST]: mockDigest,
},
},
);
expect(resp2.status).toEqual(400);
expect(resp2.data).toEqual({
error_msg: 'routes_conf_version must be greater than or equal to (100)',
});
});
it('update config (invalid conf_version)', async () => {
const resp = await clientException.put(
ENDPOINT,
YAML.stringify(invalidConfVersionConfig2),
{
headers: {
'Content-Type': 'application/yaml',
[HEADER_DIGEST]: mockDigest,
},
},
);
expect(resp.status).toEqual(400);
expect(resp.data).toEqual({
error_msg: 'routes_conf_version must be a number',
});
});
it('update config (invalid json format)', async () => {
const resp = await clientException.put(ENDPOINT, '{abcd', {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(resp.status).toEqual(400);
expect(resp.data).toEqual({
error_msg:
'invalid request body: Expected object key string but found invalid token at character 2',
});
});
it('update config (not compliant with jsonschema)', async () => {
const data = structuredClone(config1);
(data.routes[0].uri as unknown) = 123;
const resp = await clientException.put(ENDPOINT, data, {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(resp.status).toEqual(400);
expect(resp.data).toMatchObject({
error_msg:
'invalid routes at index 0, err: invalid configuration: property "uri" validation failed: wrong type: expected string, got number',
});
});
it('update config (empty request body)', async () => {
const resp = await clientException.put(ENDPOINT, '', {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(resp.status).toEqual(400);
expect(resp.data).toEqual({
error_msg: 'invalid request body: empty request body',
});
});
it('update config (invalid plugin)', async () => {
const resp = await clientException.put(
ENDPOINT,
servicesWithUnknownPlugins,
{
headers: { [HEADER_DIGEST]: mockDigest },
},
);
expect(resp.status).toEqual(400);
expect(resp.data).toEqual({
error_msg:
'invalid services at index 0, err: unknown plugin [invalid-plugin]',
});
const resp2 = await clientException.put(
ENDPOINT,
routeWithUnknownPlugins,
{
headers: { [HEADER_DIGEST]: mockDigest },
},
);
expect(resp2.status).toEqual(400);
expect(resp2.data).toEqual({
error_msg:
'invalid routes at index 0, err: unknown plugin [invalid-plugin]',
});
});
it('update config (invalid upstream)', async () => {
const resp = await clientException.put(
ENDPOINT,
serviceWithInvalidUpstream,
{
headers: { [HEADER_DIGEST]: mockDigest },
},
);
expect(resp.status).toEqual(400);
expect(resp.data).toEqual({
error_msg:
'invalid services at index 0, err: invalid configuration: failed to match pattern "^((uri|server_name|server_addr|request_uri|remote_port|remote_addr|query_string|host|hostname|mqtt_client_id)|arg_[0-9a-zA-z_-]+)$" with "args_invalid"',
});
const resp2 = await clientException.put(
ENDPOINT,
routeWithInvalidUpstream,
{
headers: { [HEADER_DIGEST]: mockDigest },
},
);
expect(resp2.status).toEqual(400);
expect(resp2.data).toEqual({
error_msg:
'invalid routes at index 0, err: invalid configuration: failed to match pattern "^((uri|server_name|server_addr|request_uri|remote_port|remote_addr|query_string|host|hostname|mqtt_client_id)|arg_[0-9a-zA-z_-]+)$" with "args_invalid"',
});
});
});
});