diff --git a/packages/backend/src/controllers/api/v1/admin/api-tokens/create-api-token.ee.js b/packages/backend/src/controllers/api/v1/admin/api-tokens/create-api-token.ee.js new file mode 100644 index 00000000..1a763c78 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/api-tokens/create-api-token.ee.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import ApiToken from '../../../../../models/api-token.ee.js'; + +export default async (request, response) => { + const apiToken = await ApiToken.query().insertAndFetch({}); + + renderObject(response, apiToken, { + serializer: 'AdminApiToken', + status: 201, + }); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/api-tokens/create-api-token.ee.test.js b/packages/backend/src/controllers/api/v1/admin/api-tokens/create-api-token.ee.test.js new file mode 100644 index 00000000..7f2a735e --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/api-tokens/create-api-token.ee.test.js @@ -0,0 +1,37 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import ApiToken from '../../../../../models/api-token.ee.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import createApiTokenMock from '../../../../../../test/mocks/rest/api/v1/admin/api-tokens/create-api-token.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('POST /api/v1/admin/api-tokens', () => { + let currentUser, token, role; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + role = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: role.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the created api token', async () => { + const response = await request(app) + .post('/api/v1/admin/api-tokens') + .set('Authorization', token) + .expect(201); + + const refetchedToken = await ApiToken.query().findById( + response.body.data.id + ); + + const expectedPayload = await createApiTokenMock(refetchedToken); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/models/__snapshots__/api-token.test.js.snap b/packages/backend/src/models/__snapshots__/api-token.ee.test.js.snap similarity index 76% rename from packages/backend/src/models/__snapshots__/api-token.test.js.snap rename to packages/backend/src/models/__snapshots__/api-token.ee.test.js.snap index 6f52bfc8..19860d3b 100644 --- a/packages/backend/src/models/__snapshots__/api-token.test.js.snap +++ b/packages/backend/src/models/__snapshots__/api-token.ee.test.js.snap @@ -3,6 +3,9 @@ exports[`ApiToken model > jsonSchema should have correct validations 1`] = ` { "properties": { + "createdAt": { + "type": "string", + }, "id": { "format": "uuid", "type": "string", @@ -11,10 +14,10 @@ exports[`ApiToken model > jsonSchema should have correct validations 1`] = ` "minLength": 32, "type": "string", }, + "updatedAt": { + "type": "string", + }, }, - "required": [ - "token", - ], "type": "object", } `; diff --git a/packages/backend/src/models/api-token.ee.js b/packages/backend/src/models/api-token.ee.js new file mode 100644 index 00000000..9a38cdcb --- /dev/null +++ b/packages/backend/src/models/api-token.ee.js @@ -0,0 +1,27 @@ +import Base from './base.js'; +import crypto from 'crypto'; +class ApiToken extends Base { + static tableName = 'api_tokens'; + + static jsonSchema = { + type: 'object', + + properties: { + id: { type: 'string', format: 'uuid' }, + token: { type: 'string', minLength: 32 }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + async assignToken() { + this.token = crypto.randomBytes(48).toString('hex'); + } + + async $beforeInsert(queryContext) { + await super.$beforeInsert(queryContext); + await this.assignToken(); + } +} + +export default ApiToken; diff --git a/packages/backend/src/models/api-token.ee.test.js b/packages/backend/src/models/api-token.ee.test.js new file mode 100644 index 00000000..c28549f9 --- /dev/null +++ b/packages/backend/src/models/api-token.ee.test.js @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from 'vitest'; +import ApiToken from './api-token.ee.js'; + +describe('ApiToken model', () => { + it('tableName should return correct name', () => { + expect(ApiToken.tableName).toBe('api_tokens'); + }); + + it('jsonSchema should have correct validations', () => { + expect(ApiToken.jsonSchema).toMatchSnapshot(); + }); + + describe('assignToken', () => { + it('should assign a new token', async () => { + const apiToken = new ApiToken(); + await apiToken.assignToken(); + + expect(apiToken.token).toBeDefined(); + }); + }); + + describe('beforeInsert', () => { + it('should call assignToken method', async () => { + const apiToken = new ApiToken(); + const assignTokenSpy = vi.spyOn(apiToken, 'assignToken'); + + await apiToken.$beforeInsert(); + expect(assignTokenSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/backend/src/models/api-token.js b/packages/backend/src/models/api-token.js deleted file mode 100644 index f5a520d1..00000000 --- a/packages/backend/src/models/api-token.js +++ /dev/null @@ -1,17 +0,0 @@ -import Base from './base.js'; - -class ApiToken extends Base { - static tableName = 'api_tokens'; - - static jsonSchema = { - type: 'object', - required: ['token'], - - properties: { - id: { type: 'string', format: 'uuid' }, - token: { type: 'string', minLength: 32 }, - }, - }; -} - -export default ApiToken; diff --git a/packages/backend/src/models/api-token.test.js b/packages/backend/src/models/api-token.test.js deleted file mode 100644 index 0fcbb806..00000000 --- a/packages/backend/src/models/api-token.test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import ApiToken from './api-token.js'; - -describe('ApiToken model', () => { - it('tableName should return correct name', () => { - expect(ApiToken.tableName).toBe('api_tokens'); - }); - - it('jsonSchema should have correct validations', () => { - expect(ApiToken.jsonSchema).toMatchSnapshot(); - }); -}); diff --git a/packages/backend/src/routes/api/v1/admin/api-tokens.ee.js b/packages/backend/src/routes/api/v1/admin/api-tokens.ee.js new file mode 100644 index 00000000..9c2e9276 --- /dev/null +++ b/packages/backend/src/routes/api/v1/admin/api-tokens.ee.js @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../../helpers/authentication.js'; +import { authorizeAdmin } from '../../../../helpers/authorization.js'; +import { checkIsEnterprise } from '../../../../helpers/check-is-enterprise.js'; +import createApiTokenAction from '../../../../controllers/api/v1/admin/api-tokens/create-api-token.ee.js'; + +const router = Router(); + +router.post( + '/', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + createApiTokenAction +); + +export default router; diff --git a/packages/backend/src/routes/index.js b/packages/backend/src/routes/index.js index 13634f77..5cfa3b6e 100644 --- a/packages/backend/src/routes/index.js +++ b/packages/backend/src/routes/index.js @@ -16,6 +16,7 @@ import adminAppsRouter from './api/v1/admin/apps.ee.js'; import adminConfigRouter from './api/v1/admin/config.ee.js'; import adminSamlAuthProvidersRouter from './api/v1/admin/saml-auth-providers.ee.js'; import adminTemplatesRouter from './api/v1/admin/templates.ee.js'; +import adminApiTokensRouter from './api/v1/admin/api-tokens.ee.js'; import templatesRouter from './api/v1/templates.ee.js'; import rolesRouter from './api/v1/admin/roles.ee.js'; import permissionsRouter from './api/v1/admin/permissions.ee.js'; @@ -45,6 +46,7 @@ router.use('/api/v1/admin/roles', rolesRouter); router.use('/api/v1/admin/permissions', permissionsRouter); router.use('/api/v1/admin/saml-auth-providers', adminSamlAuthProvidersRouter); router.use('/api/v1/admin/templates', adminTemplatesRouter); +router.use('/api/v1/admin/api-tokens', adminApiTokensRouter); router.use('/api/v1/templates', templatesRouter); router.use('/api/v1/installation/users', installationUsersRouter); router.use('/api/v1/folders', foldersRouter); diff --git a/packages/backend/src/serializers/admin/api-token.ee.js b/packages/backend/src/serializers/admin/api-token.ee.js new file mode 100644 index 00000000..40c4c52b --- /dev/null +++ b/packages/backend/src/serializers/admin/api-token.ee.js @@ -0,0 +1,10 @@ +const adminApiTokenSerializer = (apiToken) => { + return { + id: apiToken.id, + token: apiToken.token, + createdAt: apiToken.createdAt.getTime(), + updatedAt: apiToken.updatedAt.getTime(), + }; +}; + +export default adminApiTokenSerializer; diff --git a/packages/backend/src/serializers/admin/api-token.ee.test.js b/packages/backend/src/serializers/admin/api-token.ee.test.js new file mode 100644 index 00000000..9ca7fd40 --- /dev/null +++ b/packages/backend/src/serializers/admin/api-token.ee.test.js @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import adminApiTokenSerializer from './api-token.ee.js'; +import { createApiToken } from '../../../test/factories/api-token.js'; + +describe('adminApiTokenSerializer', () => { + let apiToken; + + beforeEach(async () => { + apiToken = await createApiToken(); + }); + + it('should return api token data', async () => { + const expectedPayload = { + id: apiToken.id, + token: apiToken.token, + createdAt: apiToken.createdAt.getTime(), + updatedAt: apiToken.updatedAt.getTime(), + }; + + expect(adminApiTokenSerializer(apiToken)).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/index.js b/packages/backend/src/serializers/index.js index 4cd59010..96dbebeb 100644 --- a/packages/backend/src/serializers/index.js +++ b/packages/backend/src/serializers/index.js @@ -3,6 +3,7 @@ import roleSerializer from './role.js'; import permissionSerializer from './permission.js'; import adminSamlAuthProviderSerializer from './admin-saml-auth-provider.ee.js'; import adminTemplateSerializer from './admin/template.ee.js'; +import adminApiTokenSerializer from './admin/api-token.ee.js'; import templateSerializer from './template.ee.js'; import samlAuthProviderSerializer from './saml-auth-provider.ee.js'; import samlAuthProviderRoleMappingSerializer from './role-mapping.ee.js'; @@ -30,6 +31,7 @@ const serializers = { Permission: permissionSerializer, AdminSamlAuthProvider: adminSamlAuthProviderSerializer, AdminTemplate: adminTemplateSerializer, + AdminApiToken: adminApiTokenSerializer, Template: templateSerializer, SamlAuthProvider: samlAuthProviderSerializer, RoleMapping: samlAuthProviderRoleMappingSerializer, diff --git a/packages/backend/test/factories/api-token.js b/packages/backend/test/factories/api-token.js index 7eaac70c..f283e0a3 100644 --- a/packages/backend/test/factories/api-token.js +++ b/packages/backend/test/factories/api-token.js @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import ApiToken from '../../src/models/api-token.js'; +import ApiToken from '../../src/models/api-token.ee.js'; export const createApiToken = async (params = {}) => { params.token = params.token || crypto.randomBytes(48).toString('hex'); diff --git a/packages/backend/test/mocks/rest/api/v1/admin/api-tokens/create-api-token.js b/packages/backend/test/mocks/rest/api/v1/admin/api-tokens/create-api-token.js new file mode 100644 index 00000000..1bd0f310 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/api-tokens/create-api-token.js @@ -0,0 +1,21 @@ +const createApiTokenMock = async (apiToken) => { + const data = { + id: apiToken.id, + token: apiToken.token, + createdAt: apiToken.createdAt.getTime(), + updatedAt: apiToken.updatedAt.getTime(), + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'ApiToken', + }, + }; +}; + +export default createApiTokenMock;