1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00

Fix/oas response (#2068)

* bug fix

* bug fix

* remove doc file

* store fixes

* bug fix

* rollback deleted file

* fix test

* add url to token

* return all tokens not just active

* add url fix

* PR comment

* PR comment

* PR comment

* add the flag to the experimental options

* fix env var name
This commit is contained in:
andreas-unleash 2022-09-26 13:06:30 +03:00 committed by GitHub
parent 1426d5be33
commit aa589b5ff5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 342 additions and 34 deletions

View File

@ -30,22 +30,32 @@ interface ITokenUserRow {
user_id: number;
created_at: Date;
}
const tokenRowReducer = (acc, tokenRow) => {
const { userId, name, ...token } = tokenRow;
const { userId, userName, userUsername, roleId, roleName, ...token } =
tokenRow;
if (!acc[tokenRow.secret]) {
acc[tokenRow.secret] = {
secret: token.secret,
name: token.name,
url: token.url,
expiresAt: token.expires_at,
createdAt: token.created_at,
createdBy: token.created_by,
roleId: token.role_id,
role: {
id: roleId,
name: roleName,
},
users: [],
};
}
const currentToken = acc[tokenRow.secret];
if (userId) {
currentToken.users.push({ userId, name });
currentToken.users.push({
id: userId,
name: userName,
username: userUsername,
});
}
return acc;
};
@ -58,6 +68,7 @@ const toRow = (newToken: IPublicSignupTokenCreate) => {
expires_at: newToken.expiresAt,
created_by: newToken.createdBy || null,
role_id: newToken.roleId,
url: newToken.url,
};
};
@ -97,15 +108,19 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
'token_project_users.secret',
)
.leftJoin(`users`, 'token_project_users.user_id', 'users.id')
.leftJoin(`roles`, 'tokens.role_id', 'roles.id')
.select(
'tokens.secret',
'tokens.name',
'tokens.expires_at',
'tokens.created_at',
'tokens.created_by',
'tokens.role_id',
'token_project_users.user_id',
'users.name',
'tokens.url',
'token_project_users.user_id as userId',
'users.name as userName',
'users.username as userUsername',
'roles.id as roleId',
'roles.name as roleName',
);
}
@ -137,9 +152,9 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
): Promise<PublicSignupTokenSchema> {
const response = await this.db<ITokenRow>(TABLE).insert(
toRow(newToken),
['created_at'],
['*'],
);
return toTokens([response])[0];
return toTokens(response)[0];
}
async isValid(secret: string): Promise<boolean> {
@ -163,14 +178,15 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
}
async get(key: string): Promise<PublicSignupTokenSchema> {
const row = await this.makeTokenUsersQuery()
.where('secret', key)
.first();
const rows = await this.makeTokenUsersQuery().where(
'tokens.secret',
key,
);
if (!row)
throw new NotFoundError('Could not find a token with that key');
return toTokens([row])[0];
if (rows.length > 0) {
return toTokens(rows)[0];
}
throw new NotFoundError('Could not find public signup token.');
}
async delete(secret: string): Promise<void> {

View File

@ -5,6 +5,7 @@ test('publicSignupTokenSchema', () => {
const data: PublicSignupTokenSchema = {
name: 'Default',
secret: 'some-secret',
url: 'http://localhost:4242/invite-link/some-secret',
expiresAt: new Date().toISOString(),
users: [],
role: { name: 'Viewer ', type: 'type', id: 1 },

View File

@ -6,11 +6,22 @@ export const publicSignupTokenSchema = {
$id: '#/components/schemas/publicSignupTokenSchema',
type: 'object',
additionalProperties: false,
required: ['secret', 'name', 'expiresAt', 'createdAt', 'createdBy', 'role'],
required: [
'secret',
'url',
'name',
'expiresAt',
'createdAt',
'createdBy',
'role',
],
properties: {
secret: {
type: 'string',
},
url: {
type: 'string',
},
name: {
type: 'string',
},

View File

@ -92,13 +92,14 @@ describe('Public Signup API', () => {
});
test('should get All', async () => {
expect.assertions(1);
expect.assertions(2);
const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName });
stores.publicSignupTokenStore.create({
stores.publicSignupTokenStore.insert({
name: 'some-name',
expiresAt: expireAt(),
createdBy: 'johnDoe',
});
return request
@ -107,6 +108,7 @@ describe('Public Signup API', () => {
.expect((res) => {
const { tokens } = res.body;
expect(tokens[0].name).toBe('some-name');
expect(tokens[0].createdBy).toBe('johnDoe');
});
});

View File

@ -13,7 +13,10 @@ import {
resourceCreatedResponseSchema,
} from '../../openapi/util/create-response-schema';
import { serializeDates } from '../../types/serialize-dates';
import { emptyResponse } from '../../openapi/util/standard-responses';
import {
emptyResponse,
getStandardResponses,
} from '../../openapi/util/standard-responses';
import { PublicSignupTokenService } from '../../services/public-signup-token-service';
import UserService from '../../services/user-service';
import {
@ -28,6 +31,7 @@ import { PublicSignupTokenCreateSchema } from '../../openapi/spec/public-signup-
import { PublicSignupTokenUpdateSchema } from '../../openapi/spec/public-signup-token-update-schema';
import { CreateUserSchema } from '../../openapi/spec/create-user-schema';
import { UserSchema, userSchema } from '../../openapi/spec/user-schema';
import { extractUsername } from '../../util/extract-user';
interface TokenParam {
token: string;
@ -76,7 +80,7 @@ export class PublicSignupController extends Controller {
tags: ['Public signup tokens'],
operationId: 'getAllPublicSignupTokens',
responses: {
200: createResponseSchema('publicSignupTokenSchema'),
200: createResponseSchema('publicSignupTokensSchema'),
},
}),
],
@ -115,6 +119,7 @@ export class PublicSignupController extends Controller {
requestBody: createRequestSchema('createUserSchema'),
responses: {
200: createResponseSchema('userSchema'),
...getStandardResponses(409),
},
}),
],
@ -195,7 +200,7 @@ export class PublicSignupController extends Controller {
req: IAuthRequest,
res: Response<PublicSignupTokensSchema>,
): Promise<void> {
const tokens = await this.publicSignupTokenService.getAllActiveTokens();
const tokens = await this.publicSignupTokenService.getAllTokens();
this.openApiService.respondWithValidation(
200,
res,
@ -237,7 +242,6 @@ export class PublicSignupController extends Controller {
token,
req.body,
);
this.openApiService.respondWithValidation(
201,
res,
@ -250,15 +254,16 @@ export class PublicSignupController extends Controller {
req: IAuthRequest<void, void, PublicSignupTokenCreateSchema>,
res: Response<PublicSignupTokenSchema>,
): Promise<void> {
const username = extractUsername(req);
const token =
await this.publicSignupTokenService.createNewPublicSignupToken(
req.body,
req.user.name,
username,
);
this.openApiService.respondWithValidation(
201,
res,
publicSignupTokensSchema.$id,
publicSignupTokenSchema.$id,
serializeDates(token),
{ location: `tokens/${token.secret}` },
);
@ -288,8 +293,9 @@ export class PublicSignupController extends Controller {
res: Response,
): Promise<void> {
const { token } = req.params;
const username = extractUsername(req);
await this.publicSignupTokenService.delete(token, req.user.name);
await this.publicSignupTokenService.delete(token, username);
res.status(200).end();
}
}

View File

@ -15,6 +15,7 @@ import {
} from '../types/events';
import UserService, { ICreateUser } from './user-service';
import { IUser } from '../types/user';
import { URL } from 'url';
export class PublicSignupTokenService {
private store: IPublicSignupTokenStore;
@ -29,6 +30,8 @@ export class PublicSignupTokenService {
private timer: NodeJS.Timeout;
private readonly unleashBase: string;
constructor(
{
publicSignupTokenStore,
@ -38,7 +41,7 @@ export class PublicSignupTokenService {
IUnleashStores,
'publicSignupTokenStore' | 'roleStore' | 'eventStore'
>,
config: Pick<IUnleashConfig, 'getLogger' | 'authentication'>,
config: Pick<IUnleashConfig, 'getLogger' | 'authentication' | 'server'>,
userService: UserService,
) {
this.store = publicSignupTokenStore;
@ -48,6 +51,13 @@ export class PublicSignupTokenService {
this.logger = config.getLogger(
'/services/public-signup-token-service.ts',
);
this.unleashBase = config.server.unleashUrl;
}
private getUrl(secret: string): string {
return new URL(
`${this.unleashBase}/invite-link/${secret}/signup`,
).toString();
}
public async get(secret: string): Promise<PublicSignupTokenSchema> {
@ -77,11 +87,13 @@ export class PublicSignupTokenService {
secret: string,
createUser: ICreateUser,
): Promise<IUser> {
const token = await this.get(secret);
createUser.rootRole = token.role.id;
const user = await this.userService.createUser(createUser);
await this.store.addTokenUser(secret, user.id);
await this.eventStore.store(
new PublicSignupTokenUserAddedEvent({
createdBy: 'userId',
createdBy: 'System',
data: { secret, userId: user.id },
}),
);
@ -109,26 +121,29 @@ export class PublicSignupTokenService {
createdBy: string,
): Promise<PublicSignupTokenSchema> {
const viewerRole = await this.roleStore.getRoleByName(RoleName.VIEWER);
const secret = this.generateSecretKey();
const url = this.getUrl(secret);
const newToken: IPublicSignupTokenCreate = {
name: tokenCreate.name,
expiresAt: new Date(tokenCreate.expiresAt),
secret: this.generateSecretKey(),
secret: secret,
roleId: viewerRole ? viewerRole.id : -1,
createdBy: createdBy,
url: url,
};
const token = await this.store.insert(newToken);
await this.eventStore.store(
new PublicSignupTokenCreatedEvent({
createdBy: createdBy,
data: newToken,
data: token,
}),
);
return token;
}
private generateSecretKey(): string {
return crypto.randomBytes(32).toString('hex');
return crypto.randomBytes(16).toString('hex');
}
destroy(): void {

View File

@ -18,6 +18,10 @@ export const defaultExperimentalOptions = {
process.env.UNLEASH_EXPERIMENTAL_BATCH_METRICS,
false,
),
publicSignup: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_PUBLIC_SIGNUP,
false,
),
},
externalResolver: { isEnabled: (): boolean => false },
};

View File

@ -6,6 +6,7 @@ export interface IPublicSignupTokenCreate {
roleId: number;
secret: string;
createdBy: string;
url: string;
}
export interface IPublicSignupToken extends IPublicSignupTokenCreate {

View File

@ -0,0 +1,21 @@
'use strict';
exports.up = function (db, callback) {
db.runSql(
`
ALTER table public_signup_tokens
ADD COLUMN IF NOT EXISTS url text
`,
callback,
);
};
exports.down = function (db, callback) {
db.runSql(
`
ALTER table public_signup_tokens
DROP COLUMN url
`,
callback,
);
};

View File

@ -0,0 +1,223 @@
import { setupAppWithCustomAuth } from '../../helpers/test-helper';
import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { RoleName } from '../../../../lib/types/model';
import { PublicSignupTokenCreateSchema } from '../../../../lib/openapi/spec/public-signup-token-create-schema';
import { CreateUserSchema } from '../../../../lib/openapi/spec/create-user-schema';
let stores;
let db;
jest.mock('../../../../lib/util/flag-resolver', () => {
return jest.fn().mockImplementation(() => {
return {
getAll: jest.fn(),
isEnabled: jest.fn().mockResolvedValue(true),
};
});
});
beforeEach(async () => {
db = await dbInit('test', getLogger);
stores = db.stores;
});
afterEach(async () => {
await stores.publicSignupTokenStore.deleteAll();
await stores.eventStore.deleteAll();
await stores.userStore.deleteAll();
});
afterAll(async () => {
if (db) {
await db.destroy();
}
});
const expireAt = (addDays: number = 7): Date => {
let now = new Date();
now.setDate(now.getDate() + addDays);
return now;
};
test('admin users should be able to create a token', async () => {
expect.assertions(3);
const preHook = (app, config, { userService, accessService }) => {
app.use('/api/admin/', async (req, res, next) => {
const role = await accessService.getRootRole(RoleName.ADMIN);
const user = await userService.createUser({
email: 'admin@example.com',
rootRole: role.id,
});
req.user = user;
next();
});
};
const { request, destroy } = await setupAppWithCustomAuth(stores, preHook);
const tokenCreate: PublicSignupTokenCreateSchema = {
name: 'some-name',
expiresAt: expireAt().toISOString(),
};
await request
.post('/api/admin/invite-link/tokens')
.send(tokenCreate)
.expect('Content-Type', /json/)
.expect(201)
.expect((res) => {
expect(res.body.name).toBe('some-name');
expect(res.body.secret).not.toBeNull();
expect(res.body.url).not.toBeNull();
});
await destroy();
});
test('no permission to validate a token', async () => {
const preHook = (app, config, { userService, accessService }) => {
app.use('/api/admin/', async (req, res, next) => {
const admin = await accessService.getRootRole(RoleName.ADMIN);
await userService.createUser({
email: 'admin@example.com',
username: 'admin@example.com',
rootRole: admin.id,
});
next();
});
};
const { request, destroy } = await setupAppWithCustomAuth(stores, preHook);
await stores.publicSignupTokenStore.insert({
name: 'some-name',
expiresAt: expireAt(),
secret: 'some-secret',
createAt: new Date(),
createdBy: 'admin@example.com',
roleId: 3,
});
await request
.post('/api/admin/invite-link/tokens/some-secret/validate')
.expect(200);
await destroy();
});
test('should return 401 if token can not be validate', async () => {
const preHook = (app, config, { userService, accessService }) => {
app.use('/api/admin/', async (req, res, next) => {
const admin = await accessService.getRootRole(RoleName.ADMIN);
await userService.createUser({
email: 'admin@example.com',
username: 'admin@example.com',
rootRole: admin.id,
});
next();
});
};
const { request, destroy } = await setupAppWithCustomAuth(stores, preHook);
await request
.post('/api/admin/invite-link/tokens/some-invalid-secret/validate')
.expect(401);
await destroy();
});
test('users can signup with invite-link', async () => {
expect.assertions(1);
const preHook = (app, config, { userService, accessService }) => {
app.use('/api/admin/', async (req, res, next) => {
const admin = await accessService.getRootRole(RoleName.ADMIN);
await userService.createUser({
email: 'admin@example.com',
username: 'admin@example.com',
rootRole: admin.id,
});
next();
});
};
const { request, destroy } = await setupAppWithCustomAuth(stores, preHook);
await stores.publicSignupTokenStore.insert({
name: 'some-name',
expiresAt: expireAt(),
secret: 'some-secret',
url: 'http://localhost:4242/invite-lint/some-secret/signup',
createAt: new Date(),
createdBy: 'admin@example.com',
roleId: 3,
});
const createUser: CreateUserSchema = {
username: 'some-username',
email: 'some@example.com',
password: 'eweggwEG',
sendEmail: false,
rootRole: 1,
};
await request
.post('/api/admin/invite-link/tokens/some-secret/signup')
.send(createUser)
.expect(201)
.expect((res) => {
const user = res.body;
expect(user.username).toBe('some-username');
});
await destroy();
});
test('can get a token with users', async () => {
expect.assertions(1);
const preHook = (app, config, { userService, accessService }) => {
app.use('/api/admin/', async (req, res, next) => {
const role = await accessService.getRootRole(RoleName.ADMIN);
const user = await userService.createUser({
email: 'admin@example.com',
rootRole: role.id,
});
req.user = user;
next();
});
};
const { request, destroy } = await setupAppWithCustomAuth(stores, preHook);
await stores.publicSignupTokenStore.insert({
name: 'some-name',
expiresAt: expireAt(),
secret: 'some-secret',
createAt: new Date(),
createdBy: 'admin@example.com',
roleId: 3,
});
const user = await stores.userStore.insert({
username: 'some-username',
email: 'some@example.com',
password: 'eweggwEG',
sendEmail: false,
rootRole: 3,
});
await stores.publicSignupTokenStore.addTokenUser('some-secret', user.id);
await request
.get('/api/admin/invite-link/tokens/some-secret')
.expect(200)
.expect((res) => {
const token = res.body;
expect(token.users.length).toEqual(1);
});
await destroy();
});

View File

@ -2432,6 +2432,9 @@ exports[`should serve the OpenAPI spec 1`] = `
"secret": {
"type": "string",
},
"url": {
"type": "string",
},
"users": {
"items": {
"$ref": "#/components/schemas/userSchema",
@ -2442,6 +2445,7 @@ exports[`should serve the OpenAPI spec 1`] = `
},
"required": [
"secret",
"url",
"name",
"expiresAt",
"createdAt",
@ -4512,11 +4516,11 @@ If the provided project does not exist, the list of events will be empty.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/publicSignupTokenSchema",
"$ref": "#/components/schemas/publicSignupTokensSchema",
},
},
},
"description": "publicSignupTokenSchema",
"description": "publicSignupTokensSchema",
},
},
"tags": [
@ -4680,6 +4684,9 @@ If the provided project does not exist, the list of events will be empty.",
},
"description": "userSchema",
},
"409": {
"description": "The provided resource can not be created or updated because it would conflict with the current state of the resource or with an already existing resource, respectively.",
},
},
"tags": [
"Public signup tokens",

View File

@ -47,13 +47,14 @@ export default class FakePublicSignupStore implements IPublicSignupTokenStore {
expiresAt: newToken.expiresAt.toISOString(),
createdAt: new Date().toISOString(),
users: [],
url: 'some=url',
name: newToken.name,
role: {
name: 'Viewer',
type: '',
id: 1,
},
createdBy: null,
createdBy: newToken.createdBy,
};
this.tokens.push(token);
return Promise.resolve(token);
@ -80,6 +81,6 @@ export default class FakePublicSignupStore implements IPublicSignupTokenStore {
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
async getAll(query?: Object): Promise<PublicSignupTokenSchema[]> {
return Promise.resolve([]);
return Promise.resolve(this.tokens);
}
}