1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

chore: deprecate username on api-tokens (#3616)

<!-- Thanks for creating a PR! To make it easier for reviewers and
everyone else to understand what your changes relate to, please add some
relevant content to the headings below. Feel free to ignore or delete
sections that you don't think are relevant. Thank you! ❤️ -->

## About the changes
<!-- Describe the changes introduced. What are they and why are they
being introduced? Feel free to also add screenshots or steps to view the
changes if they're visual. -->

This deprecates the `username` properties on api-token schemas, and adds
a `tokenName` property.
DB field `username` has been renamed to `token_name`, migration added
for the rename.
Both `username` and `tokenName` can be used when consuming the service,
but only one of them.

## Discussion points
<!-- Anything about the PR you'd like to discuss before it gets merged?
Got any questions or doubts? -->

There's a couple of things I'd like to get opinions on and discuss:
- Frontend still uses the deprecated `username` property
- ApiTokenSchema is used both for input and output of `Create`
controller endpoints and should be split out into separate schemas. I'll
set up a task for this

---------

Co-authored-by: Thomas Heartman <thomas@getunleash.ai>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
This commit is contained in:
David Leek 2023-05-04 09:56:00 +02:00 committed by GitHub
parent 392e46f43d
commit f35d9390c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 252 additions and 75 deletions

View File

@ -24,7 +24,7 @@ test('should add initApiToken for admin token from options', async () => {
project: '*',
secret: '*:*.some-random-string',
type: ApiTokenType.ADMIN,
username: 'admin',
tokenName: 'admin',
};
const config = createConfig({
db: {
@ -58,7 +58,7 @@ test('should add initApiToken for client token from options', async () => {
project: 'default',
secret: 'default:development.some-random-string',
type: ApiTokenType.CLIENT,
username: 'admin',
tokenName: 'admin',
};
const config = createConfig({
db: {
@ -143,7 +143,7 @@ test('should merge initApiToken from options and env vars', async () => {
project: '*',
secret: '*:*.some-random-string',
type: ApiTokenType.ADMIN,
username: 'admin',
tokenName: 'admin',
};
const config = createConfig({
db: {
@ -204,7 +204,7 @@ test('should handle cases where no env var specified for tokens', async () => {
project: '*',
secret: '*:*.some-random-string',
type: ApiTokenType.ADMIN,
username: 'admin',
tokenName: 'admin',
};
const config = createConfig({
db: {

View File

@ -264,7 +264,7 @@ const loadTokensFromString = (
environment,
secret,
type: tokenType,
username: 'admin',
tokenName: 'admin',
};
validateApiToken(mapLegacyToken(token));
return token;

View File

@ -27,6 +27,7 @@ interface ITokenInsert {
created_at: Date;
seen_at?: Date;
environment: string;
tokenName?: string;
}
interface ITokenRow extends ITokenInsert {
@ -38,7 +39,7 @@ const tokenRowReducer = (acc, tokenRow) => {
if (!acc[tokenRow.secret]) {
acc[tokenRow.secret] = {
secret: token.secret,
username: token.username,
tokenName: token.token_name,
type: token.type,
project: ALL,
projects: [ALL],
@ -47,6 +48,7 @@ const tokenRowReducer = (acc, tokenRow) => {
createdAt: token.created_at,
alias: token.alias,
seenAt: token.seen_at,
username: token.token_name,
};
}
const currentToken = acc[tokenRow.secret];
@ -61,7 +63,7 @@ const tokenRowReducer = (acc, tokenRow) => {
};
const toRow = (newToken: IApiTokenCreate) => ({
username: newToken.username,
token_name: newToken.tokenName ?? newToken.username,
secret: newToken.secret,
type: newToken.type,
environment:
@ -123,7 +125,7 @@ export class ApiTokenStore implements IApiTokenStore {
)
.select(
'tokens.secret',
'username',
'token_name',
'type',
'expires_at',
'created_at',
@ -154,6 +156,7 @@ export class ApiTokenStore implements IApiTokenStore {
await Promise.all(updateProjectTasks);
return {
...newToken,
username: newToken.tokenName,
alias: newToken.alias || null,
project: newToken.projects?.join(',') || '*',
createdAt: row.created_at,

View File

@ -82,7 +82,7 @@ test('should not make database query when provided PAT format', async () => {
test('should add user if known token', async () => {
const apiUser = new ApiUser({
username: 'default',
tokenName: 'default',
permissions: [CLIENT],
project: ALL,
environment: ALL,
@ -114,7 +114,7 @@ test('should not add user if not /api/client', async () => {
expect.assertions(5);
const apiUser = new ApiUser({
username: 'default',
tokenName: 'default',
permissions: [CLIENT],
project: ALL,
environment: ALL,
@ -153,7 +153,7 @@ test('should not add user if not /api/client', async () => {
test('should not add user if disabled', async () => {
const apiUser = new ApiUser({
username: 'default',
tokenName: 'default',
permissions: [CLIENT],
project: ALL,
environment: ALL,

View File

@ -42,7 +42,7 @@ function demoAuthentication(
if (!authentication.enableApiToken && !req.user) {
// @ts-expect-error
req.user = new ApiUser({
username: 'unauthed-default-client',
tokenName: 'unauthed-default-client',
permissions: [],
environment: 'default',
type: ApiTokenType.CLIENT,

View File

@ -45,7 +45,7 @@ test('should give api-user ADMIN permission', async () => {
const cb = jest.fn();
const req: any = {
user: new ApiUser({
username: 'api',
tokenName: 'api',
permissions: [perms.ADMIN],
project: '*',
environment: '*',
@ -71,7 +71,7 @@ test('should not give api-user ADMIN permission', async () => {
const cb = jest.fn();
const req: any = {
user: new ApiUser({
username: 'api',
tokenName: 'api',
permissions: [perms.CLIENT],
project: '*',
environment: '*',

View File

@ -10,7 +10,23 @@ exports[`apiTokenSchema empty 1`] = `
"params": {
"missingProperty": "username",
},
"schemaPath": "#/required",
"schemaPath": "#/anyOf/0/required",
},
{
"instancePath": "",
"keyword": "required",
"message": "must have required property 'tokenName'",
"params": {
"missingProperty": "tokenName",
},
"schemaPath": "#/anyOf/1/required",
},
{
"instancePath": "",
"keyword": "anyOf",
"message": "must match a schema in anyOf",
"params": {},
"schemaPath": "#/anyOf",
},
{
"instancePath": "",

View File

@ -5,13 +5,21 @@ export const apiTokenSchema = {
$id: '#/components/schemas/apiTokenSchema',
type: 'object',
additionalProperties: false,
required: ['username', 'type'],
required: ['type'],
properties: {
secret: {
type: 'string',
},
username: {
type: 'string',
deprecated: true,
description:
'This property was deprecated in Unleash v5. Prefer the `tokenName` property instead.',
},
tokenName: {
type: 'string',
description: 'A unique name for this particular token',
example: 'some-user',
},
type: {
type: 'string',
@ -49,6 +57,24 @@ export const apiTokenSchema = {
nullable: true,
},
},
anyOf: [
{
properties: {
username: {
type: 'string',
},
},
required: ['username'],
},
{
properties: {
tokenName: {
type: 'string',
},
},
required: ['tokenName'],
},
],
components: {},
} as const;

View File

@ -20,14 +20,11 @@ import { ApiTokenType } from '../../types/models/api-token';
export const createApiTokenSchema = {
$id: '#/components/schemas/createApiTokenSchema',
type: 'object',
required: ['username', 'type'],
required: ['type'],
properties: {
secret: {
type: 'string',
},
username: {
type: 'string',
},
type: {
type: 'string',
description: `One of ${Object.values(ApiTokenType).join(', ')}`,
@ -50,6 +47,24 @@ export const createApiTokenSchema = {
nullable: true,
},
},
anyOf: [
{
properties: {
username: {
type: 'string',
},
},
required: ['username'],
},
{
properties: {
tokenName: {
type: 'string',
},
},
required: ['tokenName'],
},
],
components: {},
} as const;

View File

@ -5,7 +5,8 @@ import { DEFAULT_ENV } from '../util/constants';
export const createApiToken = joi
.object()
.keys({
username: joi.string().required(),
username: joi.string().optional(),
tokenName: joi.string().optional(),
type: joi
.string()
.lowercase()
@ -27,5 +28,6 @@ export const createApiToken = joi
otherwise: joi.string().optional().default(ALL),
}),
})
.nand('username', 'tokenName')
.nand('project', 'projects')
.options({ stripUnknown: true, allowUnknown: false, abortEarly: false });

View File

@ -18,7 +18,7 @@ test('Should init api token', async () => {
project: '*',
secret: '*:*:some-random-string',
type: ApiTokenType.ADMIN,
username: 'admin',
tokenName: 'admin',
};
const config: IUnleashConfig = createTestConfig({
@ -51,7 +51,7 @@ test("Shouldn't return frontend token when secret is undefined", async () => {
projects: ['*'],
secret: '*:*:some-random-string',
type: ApiTokenType.FRONTEND,
username: 'front',
tokenName: 'front',
expiresAt: null,
};
@ -86,7 +86,7 @@ test('Api token operations should all have events attached', async () => {
projects: ['*'],
secret: '*:*:some-random-string',
type: ApiTokenType.FRONTEND,
username: 'front',
tokenName: 'front',
expiresAt: null,
};

View File

@ -144,7 +144,7 @@ export class ApiTokenService {
this.lastSeenSecrets.add(token.secret);
return new ApiUser({
username: token.username,
tokenName: token.tokenName,
permissions: resolveTokenPermissions(token.type),
projects: token.projects,
environment: token.environment,
@ -202,7 +202,6 @@ export class ApiTokenService {
createdBy: string = 'unleash-system',
): Promise<IApiToken> {
validateApiToken(newToken);
const environments = await this.environmentStore.getAll();
validateApiTokenEnvironment(newToken, environments);

View File

@ -4,20 +4,18 @@ import { ValidationError } from 'joi';
import { CLIENT } from './permissions';
interface IApiUserData {
username: string;
permissions?: string[];
projects?: string[];
project?: string;
environment: string;
type: ApiTokenType;
secret: string;
tokenName: string;
}
export default class ApiUser {
readonly isAPI: boolean = true;
readonly username: string;
readonly permissions: string[];
readonly projects: string[];
@ -29,18 +27,17 @@ export default class ApiUser {
readonly secret: string;
constructor({
username,
permissions = [CLIENT],
projects,
project,
environment,
type,
secret,
tokenName,
}: IApiUserData) {
if (!username) {
throw new ValidationError('username is required', [], undefined);
if (!tokenName) {
throw new ValidationError('tokenName is required', [], undefined);
}
this.username = username;
this.permissions = permissions;
this.environment = environment;
this.type = type;

View File

@ -11,22 +11,30 @@ export enum ApiTokenType {
export interface ILegacyApiTokenCreate {
secret: string;
username: string;
/**
* @deprecated Use tokenName instead
*/
username?: string;
type: ApiTokenType;
environment: string;
project?: string;
projects?: string[];
expiresAt?: Date;
tokenName?: string;
}
export interface IApiTokenCreate {
secret: string;
username: string;
tokenName: string;
alias?: string;
type: ApiTokenType;
environment: string;
projects: string[];
expiresAt?: Date;
/**
* @deprecated Use tokenName instead
*/
username?: string;
}
export interface IApiToken extends Omit<IApiTokenCreate, 'alias'> {
@ -66,7 +74,7 @@ export const mapLegacyToken = (
): Omit<IApiTokenCreate, 'secret'> => {
const cleanedProjects = mapLegacyProjects(token.project, token.projects);
return {
username: token.username,
tokenName: token.username ?? token.tokenName!,
type: token.type,
environment: token.environment,
projects: cleanedProjects,

View File

@ -0,0 +1,20 @@
/* eslint camelcase: "off" */
'use strict';
exports.up = function (db, cb) {
db.runSql(
`
ALTER TABLE api_tokens RENAME COLUMN username TO token_name;
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
ALTER TABLE api_tokens RENAME COLUMN token_name TO username;
`,
cb,
);
};

View File

@ -49,7 +49,7 @@ process.nextTick(async () => {
project: '*',
secret: '*:*.964a287e1b728cb5f4f3e0120df92cb5',
type: ApiTokenType.ADMIN,
username: 'some-user',
tokenName: 'some-user',
},
],
},

View File

@ -44,6 +44,7 @@ test('creates new client token', async () => {
.expect(201)
.expect((res) => {
expect(res.body.username).toBe('default-client');
expect(res.body.tokenName).toBe(res.body.username);
expect(res.body.type).toBe('client');
expect(res.body.createdAt).toBeTruthy();
expect(res.body.secret.length > 16).toBe(true);
@ -61,6 +62,7 @@ test('creates new admin token', async () => {
.expect(201)
.expect((res) => {
expect(res.body.username).toBe('default-admin');
expect(res.body.tokenName).toBe(res.body.username);
expect(res.body.type).toBe('admin');
expect(res.body.environment).toBe(ALL);
expect(res.body.createdAt).toBeTruthy();
@ -80,6 +82,7 @@ test('creates new ADMIN token should fix casing', async () => {
.expect(201)
.expect((res) => {
expect(res.body.username).toBe('default-admin');
expect(res.body.tokenName).toBe(res.body.username);
expect(res.body.type).toBe('admin');
expect(res.body.createdAt).toBeTruthy();
expect(res.body.expiresAt).toBeFalsy();
@ -307,6 +310,48 @@ test('admin token only supports ALL projects', async () => {
.expect(400);
});
test('needs one of the username and tokenName properties set', async () => {
return app.request
.post('/api/admin/api-tokens')
.send({
type: 'admin',
environment: '*',
})
.set('Content-Type', 'application/json')
.expect(400);
});
test('can create with tokenName only', async () => {
return app.request
.post('/api/admin/api-tokens')
.send({
tokenName: 'default-admin',
type: 'admin',
environment: '*',
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.type).toBe('admin');
expect(res.body.secret.length > 16).toBe(true);
expect(res.body.username).toBe('default-admin');
expect(res.body.tokenName).toBe('default-admin');
});
});
test('only one of tokenName and username can be set', async () => {
return app.request
.post('/api/admin/api-tokens')
.send({
username: 'default-client-name',
tokenName: 'default-token-name',
type: 'admin',
environment: '*',
})
.set('Content-Type', 'application/json')
.expect(400);
});
test('admin token only supports ALL environments', async () => {
return app.request
.post('/api/admin/api-tokens')

View File

@ -25,7 +25,7 @@ beforeAll(async () => {
const { apiTokenService } = app.services;
token = await apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.ADMIN,
username: 'tester',
tokenName: 'tester',
environment: ALL,
projects: [ALL],
});

View File

@ -2000,7 +2000,7 @@ test('Should not allow changing project to target project without the same enabl
.send({})
.expect(200);
const user = new ApiUser({
username: 'project-changer',
tokenName: 'project-changer',
permissions: ['ADMIN'],
project: '*',
type: ApiTokenType.ADMIN,
@ -2080,7 +2080,7 @@ test('Should allow changing project to target project with the same enabled envi
.send({})
.expect(200);
const user = new ApiUser({
username: 'project-changer',
tokenName: 'project-changer',
permissions: ['ADMIN'],
project: '*',
type: ApiTokenType.ADMIN,

View File

@ -398,7 +398,7 @@ test(`should not delete api_tokens on import when drop-flag is set`, async () =>
userName,
);
await app.services.apiTokenService.createApiTokenWithProjects({
username: apiTokenName,
tokenName: apiTokenName,
type: ApiTokenType.CLIENT,
environment: environment,
projects: [projectId],

View File

@ -13,7 +13,7 @@ let apiTokenService: ApiTokenService;
const environment = 'testing';
const project = 'default';
const project2 = 'some';
const username = 'test';
const tokenName = 'test';
const feature1 = 'f1.token.access';
const feature2 = 'f2.token.access';
const feature3 = 'f3.p2.token.access';
@ -47,7 +47,7 @@ beforeAll(async () => {
name: feature1,
description: 'the #1 feature',
},
username,
tokenName,
);
await featureToggleServiceV2.createStrategy(
@ -57,7 +57,7 @@ beforeAll(async () => {
parameters: {},
},
{ projectId: project, featureName: feature1, environment: DEFAULT_ENV },
username,
tokenName,
);
await featureToggleServiceV2.createStrategy(
{
@ -66,7 +66,7 @@ beforeAll(async () => {
parameters: {},
},
{ projectId: project, featureName: feature1, environment },
username,
tokenName,
);
// create feature 2
@ -75,7 +75,7 @@ beforeAll(async () => {
{
name: feature2,
},
username,
tokenName,
);
await featureToggleServiceV2.createStrategy(
{
@ -84,7 +84,7 @@ beforeAll(async () => {
parameters: {},
},
{ projectId: project, featureName: feature2, environment },
username,
tokenName,
);
// create feature 3
@ -93,7 +93,7 @@ beforeAll(async () => {
{
name: feature3,
},
username,
tokenName,
);
await featureToggleServiceV2.createStrategy(
{
@ -102,7 +102,7 @@ beforeAll(async () => {
parameters: {},
},
{ projectId: project2, featureName: feature3, environment },
username,
tokenName,
);
});
@ -114,7 +114,7 @@ afterAll(async () => {
test('returns feature toggle with "default" config', async () => {
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
username,
tokenName,
environment: DEFAULT_ENV,
project,
});
@ -136,7 +136,7 @@ test('returns feature toggle with "default" config', async () => {
test('returns feature toggle with testing environment config', async () => {
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
username,
tokenName: tokenName,
environment,
project,
});
@ -162,7 +162,7 @@ test('returns feature toggle with testing environment config', async () => {
test('returns feature toggle for project2', async () => {
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
username,
tokenName: tokenName,
environment,
project: project2,
});
@ -182,7 +182,7 @@ test('returns feature toggle for project2', async () => {
test('returns feature toggle for all projects', async () => {
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
username,
tokenName: tokenName,
environment,
project: '*',
});

View File

@ -28,7 +28,7 @@ test('should enrich metrics with environment from api-token', async () => {
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
username: 'test',
tokenName: 'test',
environment: 'some',
project: '*',
});

View File

@ -16,7 +16,7 @@ beforeAll(async () => {
type: ApiTokenType.CLIENT,
project: 'default',
environment: 'default',
username: 'tester',
tokenName: 'tester',
});
});
@ -70,7 +70,7 @@ test('should pick up environment from token', async () => {
type: ApiTokenType.CLIENT,
project: 'default',
environment,
username: 'tester',
tokenName: 'tester',
});
await app.request
@ -114,7 +114,7 @@ test('should set lastSeen for toggles with metrics', async () => {
type: ApiTokenType.CLIENT,
project: 'default',
environment: 'default',
username: 'tester',
tokenName: 'tester',
});
await app.request

View File

@ -632,6 +632,28 @@ The provider you choose for your addon dictates what properties the \`parameters
},
"apiTokenSchema": {
"additionalProperties": false,
"anyOf": [
{
"properties": {
"username": {
"type": "string",
},
},
"required": [
"username",
],
},
{
"properties": {
"tokenName": {
"type": "string",
},
},
"required": [
"tokenName",
],
},
],
"properties": {
"alias": {
"nullable": true,
@ -667,6 +689,11 @@ The provider you choose for your addon dictates what properties the \`parameters
"nullable": true,
"type": "string",
},
"tokenName": {
"description": "A unique name for this particular token",
"example": "some-user",
"type": "string",
},
"type": {
"enum": [
"client",
@ -676,11 +703,12 @@ The provider you choose for your addon dictates what properties the \`parameters
"type": "string",
},
"username": {
"deprecated": true,
"description": "This property was deprecated in Unleash v5. Prefer the \`tokenName\` property instead.",
"type": "string",
},
},
"required": [
"username",
"type",
],
"type": "object",
@ -1341,6 +1369,28 @@ The provider you choose for your addon dictates what properties the \`parameters
"type": "array",
},
"createApiTokenSchema": {
"anyOf": [
{
"properties": {
"username": {
"type": "string",
},
},
"required": [
"username",
],
},
{
"properties": {
"tokenName": {
"type": "string",
},
},
"required": [
"tokenName",
],
},
],
"properties": {
"environment": {
"type": "string",
@ -1366,12 +1416,8 @@ The provider you choose for your addon dictates what properties the \`parameters
"description": "One of client, admin, frontend",
"type": "string",
},
"username": {
"type": "string",
},
},
"required": [
"username",
"type",
],
"type": "object",

View File

@ -48,7 +48,7 @@ test('multiple parallel calls to api/frontend should not create multiple instanc
type: ApiTokenType.FRONTEND,
projects: ['default'],
environment: 'default',
username: `test-token-${randomId()}`,
tokenName: `test-token-${randomId()}`,
});
await Promise.all(

View File

@ -51,7 +51,7 @@ export const createApiToken = (
type,
projects: ['*'],
environment: 'default',
username: `${type}-token-${randomId()}`,
tokenName: `${type}-token-${randomId()}`,
...overrides,
});
};

View File

@ -86,7 +86,7 @@ test('should have empty list of tokens', async () => {
test('should create client token', async () => {
const token = await apiTokenService.createApiToken({
username: 'default-client',
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
project: '*',
environment: DEFAULT_ENV,
@ -102,7 +102,7 @@ test('should create client token', async () => {
test('should create admin token', async () => {
const token = await apiTokenService.createApiToken({
username: 'admin',
tokenName: 'admin',
type: ApiTokenType.ADMIN,
project: '*',
environment: '*',
@ -115,7 +115,7 @@ test('should create admin token', async () => {
test('should set expiry of token', async () => {
const time = new Date('2022-01-01');
await apiTokenService.createApiToken({
username: 'default-client',
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
expiresAt: time,
project: '*',
@ -133,7 +133,7 @@ test('should update expiry of token', async () => {
const token = await apiTokenService.createApiToken(
{
username: 'default-client',
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
expiresAt: time,
project: '*',
@ -155,7 +155,7 @@ test('should only return valid tokens', async () => {
const tomorrow = addDays(now, 1);
await apiTokenService.createApiToken({
username: 'default-expired',
tokenName: 'default-expired',
type: ApiTokenType.CLIENT,
expiresAt: yesterday,
project: '*',
@ -163,7 +163,7 @@ test('should only return valid tokens', async () => {
});
const activeToken = await apiTokenService.createApiToken({
username: 'default-valid',
tokenName: 'default-valid',
type: ApiTokenType.CLIENT,
expiresAt: tomorrow,
project: '*',
@ -178,7 +178,7 @@ test('should only return valid tokens', async () => {
test('should create client token with project list', async () => {
const token = await apiTokenService.createApiToken({
username: 'default-client',
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
projects: ['default', 'test-project'],
environment: DEFAULT_ENV,
@ -190,7 +190,7 @@ test('should create client token with project list', async () => {
test('should strip all other projects if ALL_PROJECTS is present', async () => {
const token = await apiTokenService.createApiToken({
username: 'default-client',
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
projects: ['*', 'default'],
environment: DEFAULT_ENV,
@ -204,7 +204,7 @@ test('should return user with multiple projects', async () => {
const tomorrow = addDays(now, 1);
await apiTokenService.createApiToken({
username: 'default-valid',
tokenName: 'default-valid',
type: ApiTokenType.CLIENT,
expiresAt: tomorrow,
projects: ['test-project', 'default'],
@ -212,7 +212,7 @@ test('should return user with multiple projects', async () => {
});
await apiTokenService.createApiToken({
username: 'default-also-valid',
tokenName: 'default-also-valid',
type: ApiTokenType.CLIENT,
expiresAt: tomorrow,
projects: ['test-project'],
@ -237,7 +237,7 @@ test('should return user with multiple projects', async () => {
test('should not partially create token if projects are invalid', async () => {
try {
await apiTokenService.createApiTokenWithProjects({
username: 'default-client',
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
projects: ['non-existent-project'],
environment: DEFAULT_ENV,