/* * 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; // 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' }); }); });