Merge branch 'features/api-token-v2' into api-token-v2/permissions-route

This commit is contained in:
Bassel Kanso 2022-08-25 16:59:12 +03:00
commit 4d72da1257
11 changed files with 570 additions and 90 deletions

View File

@ -26,6 +26,7 @@ module.exports = {
minLength: 1,
configurable: false,
required: true,
unique: true,
},
description: {
type: 'string',
@ -60,5 +61,15 @@ module.exports = {
configurable: false,
required: false,
},
expiresAt: {
type: 'datetime',
configurable: false,
required: false,
},
lifespan: {
type: 'integer',
configurable: false,
required: false,
},
},
};

View File

@ -1,6 +1,7 @@
'use strict';
const { ApplicationError } = require('@strapi/utils').errors;
const { omit } = require('lodash/fp');
const createContext = require('../../../../../../test/helpers/create-context');
const apiTokenController = require('../api-token');
@ -63,6 +64,109 @@ describe('API Token Controller', () => {
expect(create).toHaveBeenCalledWith(body);
expect(created).toHaveBeenCalled();
});
test('Create API Token with lifespan', async () => {
const lifespan = 90 * 24 * 60 * 60 * 1000; // 90 days
const createBody = {
...body,
lifespan,
};
const tokenBody = {
...createBody,
expiresAt: Date.now() + lifespan,
permissions: undefined,
};
const create = jest.fn().mockResolvedValue(tokenBody);
const exists = jest.fn(() => false);
const badRequest = jest.fn();
const created = jest.fn();
const ctx = createContext({ body: createBody }, { badRequest, created });
global.strapi = {
admin: {
services: {
'api-token': {
exists,
create,
},
},
},
};
await apiTokenController.create(ctx);
expect(exists).toHaveBeenCalledWith({ name: tokenBody.name });
expect(badRequest).not.toHaveBeenCalled();
expect(create).toHaveBeenCalledWith(createBody);
expect(created).toHaveBeenCalledWith({ data: tokenBody });
});
test('Throws with invalid lifespan', async () => {
const lifespan = -1;
const createBody = {
...body,
lifespan,
};
const create = jest.fn();
const created = jest.fn();
const ctx = createContext({ body: createBody }, { created });
global.strapi = {
admin: {
services: {
'api-token': {
create,
},
},
},
};
expect(async () => {
await apiTokenController.create(ctx);
}).rejects.toThrow('lifespan must be greater than or equal to 1');
expect(create).not.toHaveBeenCalled();
expect(created).not.toHaveBeenCalled();
});
test('Ignores a received expiresAt', async () => {
const lifespan = 90 * 24 * 60 * 60 * 1000; // 90 days
const createBody = {
...body,
expiresAt: 1234,
lifespan,
};
const tokenBody = {
...createBody,
expiresAt: Date.now() + lifespan,
permissions: undefined,
};
const create = jest.fn().mockResolvedValue(tokenBody);
const exists = jest.fn(() => false);
const badRequest = jest.fn();
const created = jest.fn();
const ctx = createContext({ body: createBody }, { badRequest, created });
global.strapi = {
admin: {
services: {
'api-token': {
exists,
create,
},
},
},
};
await apiTokenController.create(ctx);
expect(exists).toHaveBeenCalledWith({ name: tokenBody.name });
expect(badRequest).not.toHaveBeenCalled();
expect(create).toHaveBeenCalledWith(omit(['expiresAt'], createBody));
expect(created).toHaveBeenCalledWith({ data: tokenBody });
});
});
describe('List API tokens', () => {

View File

@ -25,6 +25,7 @@ module.exports = {
description: trim(body.description),
type: body.type,
permissions: body.permissions,
lifespan: body.lifespan,
};
await validateApiTokenCreationInput(attributes);
@ -100,11 +101,6 @@ module.exports = {
attributes.description = trim(body.description);
}
// Don't allow updating lastUsedAt time
if (has(attributes, 'lastUsedAt')) {
throw new ApplicationError('lastUsedAt cannot be updated');
}
await validateApiTokenUpdateInput(attributes);
const apiTokenExists = await apiTokenService.getById(id);

View File

@ -11,28 +11,23 @@ describe('API Token', () => {
hexedString: '6170692d746f6b656e5f746573742d72616e646f6d2d6279746573',
};
const SELECT_FIELDS = [
'id',
'name',
'description',
'lastUsedAt',
'type',
'createdAt',
'updatedAt',
];
const now = Date.now();
beforeAll(() => {
jest
.spyOn(crypto, 'randomBytes')
.mockImplementation(() => Buffer.from(mockedApiToken.randomBytes));
jest.useFakeTimers('modern').setSystemTime(now);
});
afterAll(() => {
jest.clearAllMocks();
jest.useRealTimers();
});
describe('create', () => {
test('Creates a new token', async () => {
test('Creates a new read-only token', async () => {
const create = jest.fn(({ data }) => Promise.resolve(data));
global.strapi = {
@ -53,16 +48,143 @@ describe('API Token', () => {
const res = await apiTokenService.create(attributes);
expect(create).toHaveBeenCalledWith({
select: SELECT_FIELDS,
select: expect.arrayContaining([expect.any(String)]),
data: {
...attributes,
accessKey: apiTokenService.hash(mockedApiToken.hexedString),
expiresAt: null,
lifespan: null,
},
populate: ['permissions'],
});
expect(res).toEqual({
...attributes,
accessKey: mockedApiToken.hexedString,
expiresAt: null,
lifespan: null,
});
});
test('Creates a new token with lifespan', async () => {
const attributes = {
name: 'api-token_tests-name',
description: 'api-token_tests-description',
type: 'read-only',
lifespan: 123456,
};
const expectedExpires = Date.now() + attributes.lifespan;
const create = jest.fn(({ data }) => Promise.resolve(data));
global.strapi = {
query() {
return { create };
},
config: {
get: jest.fn(() => ''),
},
};
const res = await apiTokenService.create(attributes);
expect(create).toHaveBeenCalledWith({
select: expect.arrayContaining([expect.any(String)]),
data: {
...attributes,
accessKey: apiTokenService.hash(mockedApiToken.hexedString),
expiresAt: expectedExpires,
lifespan: attributes.lifespan,
},
populate: ['permissions'],
});
expect(res).toEqual({
...attributes,
accessKey: mockedApiToken.hexedString,
expiresAt: expectedExpires,
lifespan: attributes.lifespan,
});
expect(res.expiresAt).toBe(expectedExpires);
});
test('Creates a custom token', async () => {
const attributes = {
name: 'api-token_tests-name',
description: 'api-token_tests-description',
type: 'custom',
permissions: ['admin::content.content.read'],
};
const createTokenResult = {
...attributes,
lifespan: null,
expiresAt: null,
id: 1,
};
const findOne = jest.fn().mockResolvedValue(omit('permissions', createTokenResult));
const create = jest.fn().mockResolvedValue(createTokenResult);
const load = jest.fn().mockResolvedValueOnce(
Promise.resolve(
attributes.permissions.map((p) => {
return {
action: p,
};
})
)
);
global.strapi = {
query() {
return {
findOne,
create,
};
},
config: {
get: jest.fn(() => ''),
},
entityService: {
load,
},
};
const res = await apiTokenService.create(attributes);
expect(load).toHaveBeenCalledWith(
'admin::api-token',
{
...createTokenResult,
},
'permissions'
);
// call to create token
expect(create).toHaveBeenNthCalledWith(1, {
select: expect.arrayContaining([expect.any(String)]),
data: {
...omit('permissions', attributes),
accessKey: apiTokenService.hash(mockedApiToken.hexedString),
expiresAt: null,
lifespan: null,
},
populate: ['permissions'],
});
// call to create permission
expect(create).toHaveBeenNthCalledWith(2, {
data: {
action: 'admin::content.content.read',
token: {
...createTokenResult,
expiresAt: null,
lifespan: null,
},
},
});
expect(res).toEqual({
...createTokenResult,
accessKey: mockedApiToken.hexedString,
expiresAt: null,
lifespan: null,
});
});
});
@ -149,7 +271,7 @@ describe('API Token', () => {
const res = await apiTokenService.list();
expect(findMany).toHaveBeenCalledWith({
select: SELECT_FIELDS,
select: expect.arrayContaining([expect.any(String)]),
orderBy: { name: 'ASC' },
populate: ['permissions'],
});
@ -177,7 +299,7 @@ describe('API Token', () => {
const res = await apiTokenService.revoke(token.id);
expect(mockedDelete).toHaveBeenCalledWith({
select: SELECT_FIELDS,
select: expect.arrayContaining([expect.any(String)]),
where: { id: token.id },
populate: ['permissions'],
});
@ -196,7 +318,7 @@ describe('API Token', () => {
const res = await apiTokenService.revoke(42);
expect(mockedDelete).toHaveBeenCalledWith({
select: SELECT_FIELDS,
select: expect.arrayContaining([expect.any(String)]),
where: { id: 42 },
populate: ['permissions'],
});
@ -224,7 +346,7 @@ describe('API Token', () => {
const res = await apiTokenService.getById(token.id);
expect(findOne).toHaveBeenCalledWith({
select: SELECT_FIELDS,
select: expect.arrayContaining([expect.any(String)]),
where: { id: token.id },
populate: ['permissions'],
});
@ -243,7 +365,7 @@ describe('API Token', () => {
const res = await apiTokenService.getById(42);
expect(findOne).toHaveBeenCalledWith({
select: SELECT_FIELDS,
select: expect.arrayContaining([expect.any(String)]),
where: { id: 42 },
populate: ['permissions'],
});
@ -349,7 +471,7 @@ describe('API Token', () => {
},
});
expect(update).toHaveBeenCalledWith({
select: SELECT_FIELDS,
select: expect.arrayContaining([expect.any(String)]),
where: { id },
data: attributes,
populate: ['permissions'],
@ -461,7 +583,7 @@ describe('API Token', () => {
});
expect(update).toHaveBeenCalledWith({
select: SELECT_FIELDS,
select: expect.arrayContaining([expect.any(String)]),
where: { id },
data: omit(['permissions'], updatedAttributes),
populate: expect.anything(), // it doesn't matter how this is used
@ -533,7 +655,7 @@ describe('API Token', () => {
const res = await apiTokenService.update(id, updatedAttributes);
expect(update).toHaveBeenCalledWith({
select: SELECT_FIELDS,
select: expect.arrayContaining([expect.any(String)]),
where: { id },
data: omit(['permissions'], updatedAttributes),
populate: expect.anything(), // it doesn't matter how this is used
@ -565,7 +687,7 @@ describe('API Token', () => {
const res = await apiTokenService.getByName(token.name);
expect(findOne).toHaveBeenCalledWith({
select: SELECT_FIELDS,
select: expect.arrayContaining([expect.any(String)]),
where: { name: token.name },
populate: ['permissions'],
});
@ -584,7 +706,7 @@ describe('API Token', () => {
const res = await apiTokenService.getByName('unexistant-name');
expect(findOne).toHaveBeenCalledWith({
select: SELECT_FIELDS,
select: expect.arrayContaining([expect.any(String)]),
where: { name: 'unexistant-name' },
populate: ['permissions'],
});

View File

@ -1,6 +1,7 @@
'use strict';
const crypto = require('crypto');
const { isNil } = require('lodash/fp');
const { omit, difference, isEmpty, map, isArray } = require('lodash/fp');
const { ValidationError, NotFoundError } = require('@strapi/utils').errors;
const constants = require('./constants');
@ -17,6 +18,8 @@ const constants = require('./constants');
* @property {string} description
* @property {string} accessKey
* @property {number} lastUsedAt
* @property {number} lifespan
* @property {number} expiresAt
* @property {TokenType} type
* @property {(number|ApiTokenPermission)[]} permissions
*/
@ -30,11 +33,23 @@ const constants = require('./constants');
*/
/** @constant {Array<string>} */
const SELECT_FIELDS = ['id', 'name', 'description', 'lastUsedAt', 'type', 'createdAt', 'updatedAt'];
const SELECT_FIELDS = [
'id',
'name',
'description',
'lastUsedAt',
'type',
'lifespan',
'expiresAt',
'createdAt',
'updatedAt',
];
/** @constant {Array<string>} */
const POPULATE_FIELDS = ['permissions'];
// TODO: we need to ensure the permissions are actually valid registered permissions!
/**
* Assert that a token's permissions attribute is valid for its type
*
@ -124,12 +139,31 @@ const hash = (accessKey) => {
.digest('hex');
};
/**
* @param {number} lifespan
*
* @returns { { lifespan: null | number, expiresAt: null | number } }
*/
const getExpirationFields = (lifespan) => {
// it must be nil or a finite number >= 0
const isValidNumber = Number.isFinite(lifespan) && lifespan > 0;
if (!isValidNumber && !isNil(lifespan)) {
throw new ValidationError('lifespan must be a positive number or null');
}
return {
lifespan: lifespan || null,
expiresAt: lifespan ? Date.now() + lifespan : null,
};
};
/**
* Create a token and its permissions
*
* @param {Object} attributes
* @param {TokenType} attributes.type
* @param {string} attributes.name
* @param {number} attributes.lifespan
* @param {string[]} attributes.permissions
* @param {string} attributes.description
*
@ -147,6 +181,7 @@ const create = async (attributes) => {
data: {
...omit('permissions', attributes),
accessKey: hash(accessKey),
...getExpirationFields(attributes.lifespan),
},
});
@ -300,8 +335,8 @@ const update = async (id, attributes) => {
}
const changingTypeToCustom =
attributes.type === constants.API_TOKEN_TYPE.custom &&
originalToken.type !== constants.API_TOKEN_TYPE.custom;
attributes.type === constants.API_TOKEN_TYPE.CUSTOM &&
originalToken.type !== constants.API_TOKEN_TYPE.CUSTOM;
// if we're updating the permissions on any token type, or changing from non-custom to custom, ensure they're still valid
// if neither type nor permissions are changing, we don't need to validate again or else we can't allow partial update
@ -341,11 +376,6 @@ const update = async (id, attributes) => {
)
);
// method using deleteMany -- leaves relations in _links table!
// await strapi
// .query('admin::token-permission')
// .deleteMany({ where: { action: map('action', permissionsToDelete), token: id } });
// TODO: improve efficiency here
// using a loop -- works but very inefficient
await Promise.all(
@ -355,25 +385,6 @@ const update = async (id, attributes) => {
})
)
);
// method using createMany -- doesn't create relations in _links table!
// await strapi
// .query('admin::token-permission')
// .createMany({ data: actionsToAdd.map(action => ({ action, token: id })) });
// method attempting to use entityService -- can't create new items in entityservice, permissions need to already exist
// await strapi.entityService.update('admin::api-token', originalToken.id, {
// data: {
// permissions: [
// actionsToAdd.map(action => {
// return { action };
// }),
// ],
// },
// populate: POPULATE_FIELDS,
// });
// method attempting to createMany permissions, then update token with those permissions -- createMany doesn't return the ids, and we can't query for them
}
// if type is not custom, make sure any old permissions get removed
else if (updatedToken.type !== constants.API_TOKEN_TYPE.CUSTOM) {

View File

@ -1,5 +1,6 @@
'use strict';
const { UnauthorizedError } = require('@strapi/utils/lib/errors');
const createContext = require('../../../../../../test/helpers/create-context');
const apiTokenStrategy = require('../api-token');
@ -83,6 +84,37 @@ describe('API Token Auth Strategy', () => {
expect(getBy).toHaveBeenCalledWith({ accessKey: 'api-token_tests-hashed-access-key' });
expect(response).toStrictEqual({ authenticated: false });
});
test('Expired token throws on authorize', async () => {
const pastDate = Date.now() - 1;
const getBy = jest.fn(() => {
return {
...apiToken,
expiresAt: pastDate,
};
});
const update = jest.fn(() => apiToken);
const ctx = createContext({}, { request });
global.strapi = {
admin: {
services: {
'api-token': {
getBy,
hash,
update,
},
},
},
};
expect(async () => {
await apiTokenStrategy.authenticate(ctx);
}).rejects.toThrow(new UnauthorizedError('Token expired'));
expect(getBy).toHaveBeenCalledWith({ accessKey: 'api-token_tests-hashed-access-key' });
});
});
describe('Verify an access key', () => {
@ -160,6 +192,42 @@ describe('API Token Auth Strategy', () => {
).toBeUndefined();
});
test('Verify with expiration in future', () => {
global.strapi = {
container,
};
expect(
apiTokenStrategy.verify(
{
credentials: {
...readOnlyApiToken,
expiresAt: Date.now() + 99999,
},
},
{ scope: ['api::model.model.find'] }
)
).toBeUndefined();
});
test('Throws with expired token', () => {
global.strapi = {
container,
};
expect(() => {
apiTokenStrategy.verify(
{
credentials: {
...readOnlyApiToken,
expiresAt: Date.now() - 1,
},
},
{ scope: ['api::model.model.find'] }
);
}).toThrow(new UnauthorizedError('Token expired'));
});
test('Throws an error if trying to access a `full-access` action with a read only access key', () => {
global.strapi = {
container,

View File

@ -1,6 +1,6 @@
'use strict';
const { castArray } = require('lodash/fp');
const { castArray, isNil } = require('lodash/fp');
const { UnauthorizedError, ForbiddenError } = require('@strapi/utils').errors;
const constants = require('../services/constants');
const { getService } = require('../utils');
@ -21,7 +21,10 @@ const extractToken = (ctx) => {
return null;
};
/** @type {import('.').AuthenticateFunction} */
/**
* Authenticate the validity of the token
*
* @type {import('.').AuthenticateFunction} */
const authenticate = async (ctx) => {
const apiTokenService = getService('api-token');
const token = extractToken(ctx);
@ -34,10 +37,16 @@ const authenticate = async (ctx) => {
accessKey: apiTokenService.hash(token),
});
// token not found
if (!apiToken) {
return { authenticated: false };
}
// token has expired
if (!isNil(apiToken.expiresAt) && apiToken.expiresAt < Date.now()) {
throw new UnauthorizedError('Token expired');
}
// update lastUsedAt
await apiTokenService.update(apiToken.id, {
lastUsedAt: new Date(),
@ -54,12 +63,20 @@ const authenticate = async (ctx) => {
return { authenticated: true, credentials: apiToken };
};
/** @type {import('.').VerifyFunction} */
/**
* Verify the token has the required abilities for the requested scope
*
* @type {import('.').VerifyFunction} */
const verify = (auth, config) => {
const { credentials: apiToken, ability } = auth;
if (!apiToken) {
throw new UnauthorizedError();
throw new UnauthorizedError('Token not found');
}
// token has expired
if (!isNil(apiToken.expiresAt) && apiToken.expiresAt < Date.now()) {
throw new UnauthorizedError('Token expired');
}
// Full access

View File

@ -31,13 +31,14 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
await strapi.destroy();
});
// create a random valid token that we can test with (delete, list, etc)
// create a predictable valid token that we can test with (delete, list, etc)
let currentTokens = 0;
const createValidToken = async (token = {}) => {
currentTokens += 1;
const body = {
type: 'read-only',
// eslint-disable-next-line no-plusplus
name: `token_${String(currentTokens++)}`,
name: `token_${String(currentTokens)}`,
description: 'generic description',
...token,
};
@ -65,7 +66,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
expect(res.body).toStrictEqual({
data: null,
error: {
status: 400,
@ -98,7 +99,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
expect(res.body).toStrictEqual({
data: null,
error: {
status: 400,
@ -117,7 +118,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
});
test('Creates a read-only api token (successfully)', async () => {
test('Creates a read-only api token', async () => {
const body = {
name: 'api-token_tests-readonly',
description: 'api-token_tests-description',
@ -131,16 +132,149 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(201);
expect(res.body.data).toMatchObject({
expect(res.body.data).toStrictEqual({
accessKey: expect.any(String),
name: body.name,
permissions: [],
description: body.description,
type: body.type,
id: expect.any(Number),
createdAt: expect.any(String),
createdAt: expect.toBeISODate(),
lastUsedAt: null,
updatedAt: expect.any(String),
updatedAt: expect.toBeISODate(),
expiresAt: null,
lifespan: null,
});
});
test('Creates a token without a lifespan', async () => {
const body = {
name: 'api-token_tests-no-lifespan',
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).toStrictEqual({
accessKey: expect.any(String),
name: body.name,
permissions: [],
description: body.description,
type: body.type,
id: expect.any(Number),
createdAt: expect.toBeISODate(),
lastUsedAt: null,
updatedAt: expect.toBeISODate(),
expiresAt: null,
lifespan: null,
});
});
test('Creates a token with a lifespan', async () => {
const now = Date.now();
jest.useFakeTimers('modern').setSystemTime(now);
const body = {
name: 'api-token_tests-lifespan',
description: 'api-token_tests-description',
type: 'read-only',
lifespan: 7 * 24 * 60 * 60 * 1000, // 7 days
};
const res = await rq({
url: '/admin/api-tokens',
method: 'POST',
body,
});
expect(res.statusCode).toBe(201);
expect(res.body.data).toStrictEqual({
accessKey: expect.any(String),
name: body.name,
permissions: [],
description: body.description,
type: body.type,
id: expect.any(Number),
createdAt: expect.toBeISODate(),
lastUsedAt: null,
updatedAt: expect.toBeISODate(),
expiresAt: expect.toBeISODate(),
lifespan: body.lifespan,
});
// Datetime stored in some databases may lose ms accuracy, so allow a range of 2 seconds for timing edge cases
expect(Date.parse(res.body.data.expiresAt)).toBeGreaterThan(now + body.lifespan - 2000);
expect(Date.parse(res.body.data.expiresAt)).toBeLessThan(now + body.lifespan + 2000);
jest.useRealTimers();
});
test('Creates a token with a null lifespan', async () => {
const body = {
name: 'api-token_tests-nulllifespan',
description: 'api-token_tests-description',
type: 'read-only',
lifespan: null,
};
const res = await rq({
url: '/admin/api-tokens',
method: 'POST',
body,
});
expect(res.statusCode).toBe(201);
expect(res.body.data).toStrictEqual({
accessKey: expect.any(String),
name: body.name,
permissions: [],
description: body.description,
type: body.type,
id: expect.any(Number),
createdAt: expect.toBeISODate(),
lastUsedAt: null,
updatedAt: expect.toBeISODate(),
expiresAt: null,
lifespan: body.lifespan,
});
});
test('Fails to create a token with invalid lifespan', async () => {
const body = {
name: 'api-token_tests-lifespan',
description: 'api-token_tests-description',
type: 'read-only',
lifespan: -1,
};
const res = await rq({
url: '/admin/api-tokens',
method: 'POST',
body,
});
expect(res.statusCode).toBe(400);
expect(res.body).toStrictEqual({
data: null,
error: {
status: 400,
name: 'ValidationError',
message: 'lifespan must be greater than or equal to 1',
details: {
errors: expect.arrayContaining([
expect.objectContaining({
message: 'lifespan must be greater than or equal to 1',
name: 'ValidationError',
}),
]),
},
},
});
});
@ -159,7 +293,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
expect(res.body).toStrictEqual({
data: null,
error: {
status: 400,
@ -200,10 +334,12 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
createdAt: expect.any(String),
lastUsedAt: null,
updatedAt: expect.any(String),
expiresAt: null,
lifespan: null,
});
});
test('Creates a custom api token (successfully)', async () => {
test('Creates a custom api token', async () => {
const body = {
name: 'api-token_tests-customSuccess',
description: 'api-token_tests-description',
@ -228,6 +364,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
createdAt: expect.any(String),
lastUsedAt: null,
updatedAt: expect.any(String),
expiresAt: null,
lifespan: null,
});
});
@ -280,6 +418,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
createdAt: expect.any(String),
lastUsedAt: null,
updatedAt: expect.any(String),
expiresAt: null,
lifespan: null,
});
});
@ -307,6 +447,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
createdAt: expect.any(String),
lastUsedAt: null,
updatedAt: expect.any(String),
expiresAt: null,
lifespan: null,
});
});
@ -323,6 +465,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
);
tokens.push(await createValidToken({ type: 'full-access' }));
tokens.push(await createValidToken({ type: 'read-only' }));
tokens.push(await createValidToken({ lifespan: 12345 }));
tokens.push(await createValidToken());
const res = await rq({
@ -331,16 +474,15 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(200);
expect(res.body.data.length).toBe(4);
expect(res.body.data.length).toBe(tokens.length);
// check that each token exists in data
tokens.forEach((token) => {
const t = res.body.data.find((t) => t.id === token.id);
if (t.permissions) {
t.permissions = t.permissions.sort();
// eslint-disable-next-line no-param-reassign
token.permissions = token.permissions.sort();
Object.assign(token, { permissions: token.permissions.sort() });
}
expect(t).toMatchObject(omit(token, ['accessKey']));
expect(t).toStrictEqual(omit(token, ['accessKey']));
});
});
@ -362,6 +504,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
createdAt: token.createdAt,
lastUsedAt: null,
updatedAt: expect.any(String),
expiresAt: null,
lifespan: null,
});
});
@ -395,6 +539,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
createdAt: token.createdAt,
lastUsedAt: null,
updatedAt: expect.any(String),
expiresAt: null,
lifespan: null,
});
});
@ -419,6 +565,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
createdAt: token.createdAt,
lastUsedAt: null,
updatedAt: expect.any(String),
expiresAt: null,
lifespan: null,
});
});
@ -477,6 +625,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
createdAt: token.createdAt,
lastUsedAt: null,
updatedAt: expect.any(String),
expiresAt: null,
lifespan: null,
});
// expect(updatedRes.body.data.updated)
});
@ -529,6 +679,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
createdAt: token.createdAt,
lastUsedAt: null,
updatedAt: expect.any(String),
expiresAt: null,
lifespan: null,
});
});
@ -588,6 +740,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
createdAt: token.createdAt,
lastUsedAt: null,
updatedAt: expect.any(String),
expiresAt: null,
lifespan: null,
});
});
@ -614,6 +768,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
createdAt: token.createdAt,
lastUsedAt: null,
updatedAt: expect.any(String),
expiresAt: null,
lifespan: null,
});
});
@ -645,7 +801,5 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
});
test.todo('Regenerated access key works');
test.todo('Tokens access content for which they are authorized');
test.todo('Tokens fail to access content for which they are not authorized');
test.todo('Custom token can only be created with valid permissions that exist');
});

View File

@ -10,8 +10,10 @@ const apiTokenCreationSchema = yup
description: yup.string().optional(),
type: yup.string().oneOf(Object.values(constants.API_TOKEN_TYPE)).required(),
permissions: yup.array().of(yup.string()).nullable(),
lifespan: yup.number().integer().min(1).nullable(),
})
.noUnknown();
.noUnknown()
.strict();
const apiTokenUpdateSchema = yup
.object()
@ -20,8 +22,10 @@ const apiTokenUpdateSchema = yup
description: yup.string().nullable(),
type: yup.string().oneOf(Object.values(constants.API_TOKEN_TYPE)).notNull(),
permissions: yup.array().of(yup.string()).nullable(),
lifespan: yup.number().integer().min(1).nullable(),
})
.noUnknown();
.noUnknown()
.strict();
module.exports = {
validateApiTokenCreationInput: validateYupSchema(apiTokenCreationSchema),

View File

@ -78,7 +78,7 @@
"test:unit": "jest --verbose"
},
"dependencies": {
"@koa/cors": "3.1.0",
"@koa/cors": "3.4.1",
"@koa/router": "10.1.1",
"@strapi/admin": "4.3.6",
"@strapi/database": "4.3.6",

View File

@ -2325,17 +2325,10 @@
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@koa/cors@3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.1.0.tgz#618bb073438cfdbd3ebd0e648a76e33b84f3a3b2"
integrity sha512-7ulRC1da/rBa6kj6P4g2aJfnET3z8Uf3SWu60cjbtxTA5g8lxRdX/Bd2P92EagGwwAhANeNw8T8if99rJliR6Q==
dependencies:
vary "^1.1.2"
"@koa/cors@^3.1.0":
version "3.3.0"
resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.3.0.tgz#b4c1c7ee303b7c968c8727f2a638a74675b50bb2"
integrity sha512-lzlkqLlL5Ond8jb6JLnVVDmD2OPym0r5kvZlMgAWiS9xle+Q5ulw1T358oW+RVguxUkANquZQz82i/STIRmsqQ==
"@koa/cors@3.4.1", "@koa/cors@^3.1.0":
version "3.4.1"
resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.4.1.tgz#ddd5c6ff07a1e60831e1281411a3b9fdb95a5b26"
integrity sha512-/sG9NlpGZ/aBpnRamIlGs+wX+C/IJ5DodNK7iPQIVCG4eUQdGeshGhWQ6JCi7tpnD9sCtFXcS04iTimuaJfh4Q==
dependencies:
vary "^1.1.2"