- Implements Apache APISIX packaging for Cloudron platform. - Includes Dockerfile, CloudronManifest.json, and start.sh. - Configured to use Cloudron's etcd addon. 🤖 Generated with Gemini CLI Co-Authored-By: Gemini <noreply@google.com>
353 lines
10 KiB
TypeScript
353 lines
10 KiB
TypeScript
/*
|
|
* 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 { generateKeyPair } from 'node:crypto';
|
|
import { existsSync } from 'node:fs';
|
|
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { promisify } from 'node:util';
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
|
|
import axios from 'axios';
|
|
import * as compose from 'docker-compose';
|
|
import { gql, request } from 'graphql-request';
|
|
import { Api as LagoApi, Client as LagoClient } from 'lago-javascript-client';
|
|
import simpleGit from 'simple-git';
|
|
import * as YAML from 'yaml';
|
|
|
|
import { request as requestAdminAPI } from '../ts/admin_api';
|
|
import { wait } from '../ts/utils';
|
|
|
|
const LAGO_VERSION = 'v1.27.0';
|
|
const LAGO_PATH = '/tmp/lago';
|
|
const LAGO_FRONT_PORT = 59999;
|
|
const LAGO_API_PORT = 30699;
|
|
const LAGO_API_URL = `http://127.0.0.1:${LAGO_API_PORT}`;
|
|
const LAGO_API_BASEURL = `http://127.0.0.1:${LAGO_API_PORT}/api/v1`;
|
|
const LAGO_API_GRAPHQL_ENDPOINT = `${LAGO_API_URL}/graphql`;
|
|
const LAGO_BILLABLE_METRIC_CODE = 'test';
|
|
const LAGO_EXTERNAL_SUBSCRIPTION_ID = 'jack_test';
|
|
|
|
// The project uses AGPLv3, so we can't store the docker compose file it uses in our repository and download it during testing.
|
|
const downloadComposeFile = async () =>
|
|
simpleGit().clone('https://github.com/getlago/lago', LAGO_PATH, {
|
|
'--depth': '1',
|
|
'--branch': LAGO_VERSION,
|
|
});
|
|
|
|
const launchLago = async () => {
|
|
// patch docker-compose.yml to disable useless port
|
|
const composeFilePath = `${LAGO_PATH}/docker-compose.yml`;
|
|
const composeFile = YAML.parse(await readFile(composeFilePath, 'utf8'));
|
|
delete composeFile.services.front; // front-end is not needed for tests
|
|
delete composeFile.services['api-clock']; // clock is not needed for tests
|
|
delete composeFile.services['api-worker']; // worker is not needed for tests
|
|
delete composeFile.services['pdf']; // pdf is not needed for tests
|
|
delete composeFile.services.redis.ports; // prevent port conflict
|
|
delete composeFile.services.db.ports; // prevent port conflict
|
|
await writeFile(composeFilePath, YAML.stringify(composeFile), 'utf8');
|
|
|
|
// launch services
|
|
const { privateKey } = await promisify(generateKeyPair)('rsa', {
|
|
modulusLength: 2048,
|
|
publicKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
|
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
|
});
|
|
const composeOpts: compose.IDockerComposeOptions = {
|
|
cwd: LAGO_PATH,
|
|
log: true,
|
|
env: {
|
|
LAGO_RSA_PRIVATE_KEY: Buffer.from(privateKey).toString('base64'),
|
|
FRONT_PORT: `${LAGO_FRONT_PORT}`, // avoiding conflicts, tests do not require a front-end
|
|
API_PORT: `${LAGO_API_PORT}`,
|
|
LAGO_FRONT_URL: `http://127.0.0.1:${LAGO_FRONT_PORT}`,
|
|
LAGO_API_URL,
|
|
},
|
|
};
|
|
|
|
await compose.createAll(composeOpts);
|
|
await compose.upOne('api', composeOpts);
|
|
await compose.exec('api', 'rails db:create', composeOpts);
|
|
await compose.exec('api', 'rails db:migrate', composeOpts);
|
|
await compose.upAll(composeOpts);
|
|
};
|
|
|
|
const provisionLago = async () => {
|
|
// sign up
|
|
const { registerUser } = await request<{
|
|
registerUser: { token: string; user: { organizations: { id: string } } };
|
|
}>(
|
|
LAGO_API_GRAPHQL_ENDPOINT,
|
|
gql`
|
|
mutation signup($input: RegisterUserInput!) {
|
|
registerUser(input: $input) {
|
|
token
|
|
user {
|
|
id
|
|
organizations {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
input: {
|
|
email: 'test@test.com',
|
|
password: 'Admin000!',
|
|
organizationName: 'test',
|
|
},
|
|
},
|
|
);
|
|
|
|
const webToken = registerUser.token;
|
|
const organizationId = registerUser.user.organizations[0].id;
|
|
const requestHeaders = {
|
|
Authorization: `Bearer ${webToken}`,
|
|
'X-Lago-Organization': organizationId,
|
|
};
|
|
|
|
// list api keys
|
|
const { apiKeys } = await request<{
|
|
apiKeys: { collection: { id: string }[] };
|
|
}>(
|
|
LAGO_API_GRAPHQL_ENDPOINT,
|
|
gql`
|
|
query getApiKeys {
|
|
apiKeys(page: 1, limit: 20) {
|
|
collection {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
{},
|
|
requestHeaders,
|
|
);
|
|
|
|
// get first api key
|
|
const { apiKey } = await request<{ apiKey: { value: string } }>(
|
|
LAGO_API_GRAPHQL_ENDPOINT,
|
|
gql`
|
|
query getApiKeyValue($id: ID!) {
|
|
apiKey(id: $id) {
|
|
id
|
|
value
|
|
}
|
|
}
|
|
`,
|
|
{ id: apiKeys.collection[0].id },
|
|
requestHeaders,
|
|
);
|
|
|
|
const lagoClient = LagoClient(apiKey.value, { baseUrl: LAGO_API_BASEURL });
|
|
|
|
// create billable metric
|
|
const { data: billableMetric } =
|
|
await lagoClient.billableMetrics.createBillableMetric({
|
|
billable_metric: {
|
|
name: LAGO_BILLABLE_METRIC_CODE,
|
|
code: LAGO_BILLABLE_METRIC_CODE,
|
|
aggregation_type: 'count_agg',
|
|
filters: [
|
|
{
|
|
key: 'tier',
|
|
values: ['normal', 'expensive'],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
// create plan
|
|
const { data: plan } = await lagoClient.plans.createPlan({
|
|
plan: {
|
|
name: 'test',
|
|
code: 'test',
|
|
interval: 'monthly',
|
|
amount_cents: 0,
|
|
amount_currency: 'USD',
|
|
pay_in_advance: false,
|
|
charges: [
|
|
{
|
|
billable_metric_id: billableMetric.billable_metric.lago_id,
|
|
charge_model: 'standard',
|
|
pay_in_advance: false,
|
|
properties: { amount: '1' },
|
|
filters: [
|
|
{
|
|
properties: { amount: '10' },
|
|
values: { tier: ['expensive'] },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
// create customer
|
|
const external_customer_id = 'jack';
|
|
const { data: customer } = await lagoClient.customers.createCustomer({
|
|
customer: {
|
|
external_id: external_customer_id,
|
|
name: 'Jack',
|
|
currency: 'USD',
|
|
},
|
|
});
|
|
|
|
// assign plan to customer
|
|
await lagoClient.subscriptions.createSubscription({
|
|
subscription: {
|
|
external_customer_id: customer.customer.external_id,
|
|
plan_code: plan.plan.code,
|
|
external_id: LAGO_EXTERNAL_SUBSCRIPTION_ID,
|
|
},
|
|
});
|
|
|
|
return { apiKey: apiKey.value, client: lagoClient };
|
|
};
|
|
|
|
describe('Plugin - Lago', () => {
|
|
const JACK_USERNAME = 'jack_test';
|
|
const client = axios.create({ baseURL: 'http://127.0.0.1:1984' });
|
|
|
|
let restAPIKey: string;
|
|
let lagoClient: LagoApi<unknown>; // prettier-ignore
|
|
|
|
// set up
|
|
beforeAll(async () => {
|
|
if (existsSync(LAGO_PATH)) await rm(LAGO_PATH, { recursive: true });
|
|
await downloadComposeFile();
|
|
await launchLago();
|
|
let res = await provisionLago();
|
|
restAPIKey = res.apiKey;
|
|
lagoClient = res.client;
|
|
}, 120 * 1000);
|
|
|
|
// clean up
|
|
afterAll(async () => {
|
|
await compose.downAll({
|
|
cwd: LAGO_PATH,
|
|
commandOptions: ['--volumes'],
|
|
});
|
|
await rm(LAGO_PATH, { recursive: true });
|
|
}, 30 * 1000);
|
|
|
|
it('should create route', async () => {
|
|
await expect(
|
|
requestAdminAPI('/apisix/admin/routes/1', 'PUT', {
|
|
uri: '/hello',
|
|
upstream: {
|
|
nodes: {
|
|
'127.0.0.1:1980': 1,
|
|
},
|
|
type: 'roundrobin',
|
|
},
|
|
plugins: {
|
|
'request-id': { include_in_response: true }, // for transaction_id
|
|
'key-auth': {}, // for subscription_id
|
|
lago: {
|
|
endpoint_addrs: [LAGO_API_URL],
|
|
token: restAPIKey,
|
|
event_transaction_id: '${http_x_request_id}',
|
|
event_subscription_id: '${http_x_consumer_username}',
|
|
event_code: 'test',
|
|
batch_max_size: 1, // does not buffered usage reports
|
|
},
|
|
},
|
|
}),
|
|
).resolves.not.toThrow();
|
|
|
|
await expect(
|
|
requestAdminAPI('/apisix/admin/routes/2', 'PUT', {
|
|
uri: '/hello1',
|
|
upstream: {
|
|
nodes: {
|
|
'127.0.0.1:1980': 1,
|
|
},
|
|
type: 'roundrobin',
|
|
},
|
|
plugins: {
|
|
'request-id': { include_in_response: true },
|
|
'key-auth': {},
|
|
lago: {
|
|
endpoint_addrs: [LAGO_API_URL],
|
|
token: restAPIKey,
|
|
event_transaction_id: '${http_x_request_id}',
|
|
event_subscription_id: '${http_x_consumer_username}',
|
|
event_code: 'test',
|
|
event_properties: { tier: 'expensive' },
|
|
batch_max_size: 1,
|
|
},
|
|
},
|
|
}),
|
|
).resolves.not.toThrow();
|
|
});
|
|
|
|
it('should create consumer', async () =>
|
|
expect(
|
|
requestAdminAPI(`/apisix/admin/consumers/${JACK_USERNAME}`, 'PUT', {
|
|
username: JACK_USERNAME,
|
|
plugins: {
|
|
'key-auth': { key: JACK_USERNAME },
|
|
},
|
|
}),
|
|
).resolves.not.toThrow());
|
|
|
|
it('call API (without key)', async () => {
|
|
const res = await client.get('/hello', { validateStatus: () => true });
|
|
expect(res.status).toEqual(401);
|
|
});
|
|
|
|
it('call normal API', async () => {
|
|
for (let i = 0; i < 3; i++) {
|
|
await expect(
|
|
client.get('/hello', { headers: { apikey: JACK_USERNAME } }),
|
|
).resolves.not.toThrow();
|
|
}
|
|
await wait(500);
|
|
});
|
|
|
|
it('check Lago events (normal API)', async () => {
|
|
const { data } = await lagoClient.events.findAllEvents({
|
|
external_subscription_id: LAGO_EXTERNAL_SUBSCRIPTION_ID,
|
|
});
|
|
|
|
expect(data.events).toHaveLength(3);
|
|
expect(data.events[0].code).toEqual(LAGO_BILLABLE_METRIC_CODE);
|
|
});
|
|
|
|
let expensiveStartAt: Date;
|
|
it('call expensive API', async () => {
|
|
expensiveStartAt = new Date();
|
|
for (let i = 0; i < 3; i++) {
|
|
await expect(
|
|
client.get('/hello1', { headers: { apikey: JACK_USERNAME } }),
|
|
).resolves.not.toThrow();
|
|
}
|
|
await wait(500);
|
|
});
|
|
|
|
it('check Lago events (expensive API)', async () => {
|
|
const { data } = await lagoClient.events.findAllEvents({
|
|
external_subscription_id: LAGO_EXTERNAL_SUBSCRIPTION_ID,
|
|
timestamp_from: expensiveStartAt.toISOString(),
|
|
});
|
|
|
|
expect(data.events).toHaveLength(3);
|
|
expect(data.events[0].code).toEqual(LAGO_BILLABLE_METRIC_CODE);
|
|
expect(data.events[1].properties).toEqual({ tier: 'expensive' });
|
|
});
|
|
});
|