implement POST endpoint to create api tokens

This commit is contained in:
Dieter Stinglhamber 2021-08-26 14:37:55 +02:00
parent 36af3134be
commit 3fb6b57808
12 changed files with 374 additions and 0 deletions

View File

@ -0,0 +1,49 @@
'use strict';
/**
* Lifecycle callbacks for the `ApiToken` model.
*/
module.exports = {
collectionName: 'strapi_api_token',
info: {
name: 'Api Token',
description: '',
},
options: {},
pluginOptions: {
'content-manager': {
visible: false,
},
'content-type-builder': {
visible: false,
},
},
attributes: {
name: {
type: 'string',
minLength: 1,
configurable: false,
required: true,
},
description: {
type: 'string',
minLength: 1,
configurable: false,
required: false,
default: '',
},
type: {
type: 'string',
minLength: 1,
configurable: false,
required: false,
default: 'read-only',
},
accessKey: {
type: 'string',
minLength: 1,
configurable: false,
required: true,
},
},
};

View File

@ -4,4 +4,5 @@ module.exports = {
permission: { schema: require('./Permission') },
user: { schema: require('./User') },
role: { schema: require('./Role') },
['api-token']: { schema: require('./api-token') },
};

View File

@ -0,0 +1,61 @@
'use strict';
const createContext = require('../../../../../../test/helpers/create-context');
const apiTokenController = require('../api-token');
describe('API Token Controller', () => {
describe('Create API Token', () => {
const body = {
name: 'api-token_tests-name',
description: 'api-token_tests-description',
type: 'read-only',
};
test('Fails if API Token already exist', async () => {
const exists = jest.fn(() => true);
const badRequest = jest.fn();
const ctx = createContext({ body }, { badRequest });
global.strapi = {
admin: {
services: {
['api-token']: {
exists,
},
},
},
};
await apiTokenController.create(ctx);
expect(exists).toHaveBeenCalledWith({ name: body.name });
expect(badRequest).toHaveBeenCalledWith('Name already taken');
});
test('Create API Token Successfully', async () => {
const create = jest.fn().mockResolvedValue(body);
const exists = jest.fn(() => false);
const badRequest = jest.fn();
const created = jest.fn();
const ctx = createContext({ body }, { badRequest, created });
global.strapi = {
admin: {
services: {
['api-token']: {
exists,
create,
},
},
},
};
await apiTokenController.create(ctx);
expect(exists).toHaveBeenCalledWith({ name: body.name });
expect(badRequest).not.toHaveBeenCalled();
expect(create).toHaveBeenCalledWith(body);
expect(created).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,29 @@
'use strict';
const pick = require('lodash/pick');
const { getService } = require('../utils');
const { validateApiTokenCreationInput } = require('../validation/api-tokens');
module.exports = {
async create(ctx) {
const { body } = ctx.request;
const apiTokenService = getService('api-token');
try {
await validateApiTokenCreationInput(body);
} catch (err) {
return ctx.badRequest('ValidationError', err);
}
const attributes = pick(body, ['name', 'description', 'type']);
if (apiTokenService.exists({ name: attributes.name })) {
return ctx.badRequest('Name already taken');
}
const apiToken = await apiTokenService.create(attributes);
ctx.created({ data: apiToken });
},
};

View File

@ -2,6 +2,7 @@
module.exports = {
admin: require('./admin'),
'api-token': require('./api-token'),
'authenticated-user': require('./authenticated-user'),
authentication: require('./authentication'),
permission: require('./permission'),

View File

@ -0,0 +1,15 @@
'use strict';
module.exports = [
{
method: 'POST',
path: '/api-tokens',
handler: 'api-token.create',
config: {
policies: [
'admin::isAuthenticatedAdmin',
{ name: 'admin::hasPermissions', options: { actions: ['admin::api-tokens.create'] } },
],
},
},
];

View File

@ -0,0 +1,48 @@
'use strict';
const crypto = require('crypto');
const apiTokenService = require('../api-token');
describe('API Token', () => {
afterAll(() => {
jest.clearAllMocks();
});
describe('create', () => {
test('Creates a new token', async () => {
const create = jest.fn(({ data }) => Promise.resolve(data));
const mockedApiToken = {
randomBytes: 'api-token_test-random-bytes',
hexedString: '6170692d746f6b656e5f746573742d72616e646f6d2d6279746573',
};
crypto.randomBytes = jest.fn(() => Buffer.from(mockedApiToken.randomBytes));
global.strapi = {
query() {
return { create };
},
};
const attributes = {
name: 'api-token_tests-name',
description: 'api-token_tests-description',
type: 'read-only',
};
const res = await apiTokenService.create(attributes);
expect(create).toHaveBeenCalledWith({
data: {
...attributes,
accessKey: mockedApiToken.hexedString,
},
});
expect(res).toEqual({
...attributes,
accessKey: mockedApiToken.hexedString,
});
});
});
});

View File

@ -0,0 +1,38 @@
'use strict';
const crypto = require('crypto');
/**
* @param {Object} attributes
* @param {string} attributes.name
* @param {string} [attributes.description]
*
* @returns boolean
*/
const exists = (attributes = {}) => {
return strapi.query('strapi::api-token').count({ where: attributes }) > 0;
};
/**
* @param {Object} attributes
* @param {'read-only'|'full-access'} attributes.type
* @param {string} attributes.name
* @param {string} [attributes.description]
*
* @returns {Promise<Record<'id'|'name'|'description'|'type'|'accessKey', string>>}
*/
const create = async attributes => {
const accessKey = crypto.randomBytes(128).toString('hex');
return strapi.query('strapi::api-token').create({
data: {
...attributes,
accessKey,
},
});
};
module.exports = {
create,
exists,
};

View File

@ -12,4 +12,5 @@ module.exports = {
condition: require('./condition'),
auth: require('./auth'),
action: require('./action'),
['api-token']: require('./api-token'),
};

View File

@ -0,0 +1,95 @@
'use strict';
const { createStrapiInstance } = require('../../../../../test/helpers/strapi');
const { createAuthRequest } = require('../../../../../test/helpers/request');
/**
* == Test Suite Overview ==
*
* N° Description
* -------------------------------------------
* 1. Creates an api token (wrong body)
* 2. Creates an api token (successfully)
*/
describe('Admin API Token CRUD (e2e)', () => {
let rq;
let strapi;
// Initialization Actions
beforeAll(async () => {
strapi = await createStrapiInstance();
rq = await createAuthRequest({ strapi });
});
// Cleanup actions
afterAll(async () => {
await strapi.destroy();
});
test('1. Creates an api token (wrong body)', async () => {
const body = {
name: 'api-token_tests-name',
description: 'api-token_tests-description',
};
const res = await rq({
url: '/admin/api-tokens',
method: 'POST',
body,
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
statusCode: 400,
error: 'Bad Request',
message: 'ValidationError',
data: {
type: ['type is a required field'],
},
});
});
test('2. Creates an api token (successfully)', async () => {
const body = {
name: 'api-token_tests-name',
description: 'api-token_tests-description',
type: 'read-only',
};
const res = await rq({
url: '/admin/api-tokens',
method: 'POST',
body,
});
expect(res.statusCode).toBe(201);
expect(res.body.data).not.toBeNull();
expect(res.body.data.id).toBeDefined();
expect(res.body.data.accessKey).toBeDefined();
expect(res.body.data.name).toBe(body.name);
expect(res.body.data.description).toBe(body.description);
expect(res.body.data.type).toBe(body.type);
});
test('3. Creates an api token without a description (successfully)', async () => {
const body = {
name: 'api-token_tests-name',
type: 'read-only',
};
const res = await rq({
url: '/admin/api-tokens',
method: 'POST',
body,
});
expect(res.statusCode).toBe(201);
expect(res.body.data).not.toBeNull();
expect(res.body.data.id).toBeDefined();
expect(res.body.data.accessKey).toBeDefined();
expect(res.body.data.name).toBe(body.name);
expect(res.body.data.description).toBe('');
expect(res.body.data.type).toBe(body.type);
});
});

View File

@ -5,6 +5,7 @@ import * as contentType from '../services/content-type';
import * as metrics from '../services/metrics';
import * as token from '../services/token';
import * as auth from '../services/auth';
import * as apiToken from '../services/api-token';
type S = {
role: typeof role;
@ -14,6 +15,7 @@ type S = {
token: typeof token;
auth: typeof auth;
metrics: typeof metrics;
['api-token']: typeof apiToken;
};
export function getService<T extends keyof S>(name: T): ReturnType<S[T]>;

View File

@ -0,0 +1,34 @@
'use strict';
const { yup, formatYupErrors } = require('@strapi/utils');
const handleReject = error => Promise.reject(formatYupErrors(error));
const apiTokenCreationSchema = yup
.object()
.shape({
name: yup
.string()
.min(1)
.required(),
description: yup.string().optional(),
type: yup
.string()
.oneOf(['read-only', 'full-access'])
.required(),
})
.noUnknown();
const validateApiTokenCreationInput = data => {
return apiTokenCreationSchema
.validate(data, { strict: true, abortEarly: false })
.catch(handleReject);
};
module.exports = {
validateApiTokenCreationInput,
schemas: {
apiTokenCreationSchema,
},
};