diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index b9282b2735..d6c8665f77 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -30,6 +30,7 @@ import UserSplashStore from './user-splash-store'; import RoleStore from './role-store'; import SegmentStore from './segment-store'; import GroupStore from './group-store'; +import { PublicSignupTokenStore } from './public-signup-token-store'; export const createStores = ( config: IUnleashConfig, @@ -84,6 +85,11 @@ export const createStores = ( roleStore: new RoleStore(db, eventBus, getLogger), segmentStore: new SegmentStore(db, eventBus, getLogger), groupStore: new GroupStore(db), + publicSignupTokenStore: new PublicSignupTokenStore( + db, + eventBus, + getLogger, + ), }; }; diff --git a/src/lib/db/public-signup-token-store.ts b/src/lib/db/public-signup-token-store.ts new file mode 100644 index 0000000000..ca4478d127 --- /dev/null +++ b/src/lib/db/public-signup-token-store.ts @@ -0,0 +1,197 @@ +import { EventEmitter } from 'events'; +import { Knex } from 'knex'; +import metricsHelper from '../util/metrics-helper'; +import { DB_TIME } from '../metric-events'; +import { Logger, LogProvider } from '../logger'; +import NotFoundError from '../error/notfound-error'; +import { PublicSignupTokenSchema } from '../openapi/spec/public-signup-token-schema'; +import { IPublicSignupTokenStore } from '../types/stores/public-signup-token-store'; +import { UserSchema } from '../openapi/spec/user-schema'; +import { IPublicSignupTokenCreate } from '../types/models/public-signup-token'; + +const TABLE = 'public_signup_tokens'; +const TOKEN_USERS_TABLE = 'public_signup_tokens_user'; + +interface ITokenInsert { + secret: string; + name: string; + expires_at: Date; + created_at: Date; + created_by?: string; + role_id: number; +} + +interface ITokenRow extends ITokenInsert { + users: UserSchema[]; +} + +interface ITokenUserRow { + secret: string; + user_id: number; + created_at: Date; +} +const tokenRowReducer = (acc, tokenRow) => { + const { userId, name, ...token } = tokenRow; + if (!acc[tokenRow.secret]) { + acc[tokenRow.secret] = { + secret: token.secret, + name: token.name, + expiresAt: token.expires_at, + createdAt: token.created_at, + createdBy: token.created_by, + roleId: token.role_id, + users: [], + }; + } + const currentToken = acc[tokenRow.secret]; + if (userId) { + currentToken.users.push({ userId, name }); + } + return acc; +}; + +const toRow = (newToken: IPublicSignupTokenCreate) => { + if (!newToken) return; + return { + secret: newToken.secret, + name: newToken.name, + expires_at: newToken.expiresAt, + created_by: newToken.createdBy || null, + role_id: newToken.roleId, + }; +}; + +const toTokens = (rows: any[]): PublicSignupTokenSchema[] => { + const tokens = rows.reduce(tokenRowReducer, {}); + return Object.values(tokens); +}; + +export class PublicSignupTokenStore implements IPublicSignupTokenStore { + private logger: Logger; + + private timer: Function; + + private db: Knex; + + constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { + this.db = db; + this.logger = getLogger('public-signup-tokens.js'); + this.timer = (action: string) => + metricsHelper.wrapTimer(eventBus, DB_TIME, { + store: 'public-signup-tokens', + action, + }); + } + + count(): Promise { + return this.db(TABLE) + .count('*') + .then((res) => Number(res[0].count)); + } + + private makeTokenUsersQuery() { + return this.db(`${TABLE} as tokens`) + .leftJoin( + `${TOKEN_USERS_TABLE} as token_project_users`, + 'tokens.secret', + 'token_project_users.secret', + ) + .leftJoin(`users`, 'token_project_users.user_id', 'users.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', + ); + } + + async getAll(): Promise { + const stopTimer = this.timer('getAll'); + const rows = await this.makeTokenUsersQuery(); + stopTimer(); + return toTokens(rows); + } + + async getAllActive(): Promise { + const stopTimer = this.timer('getAllActive'); + const rows = await this.makeTokenUsersQuery() + .where('expires_at', 'IS', null) + .orWhere('expires_at', '>', 'now()'); + stopTimer(); + return toTokens(rows); + } + + async addTokenUser(secret: string, userId: number): Promise { + await this.db(TOKEN_USERS_TABLE).insert( + { user_id: userId, secret }, + ['created_at'], + ); + } + + async insert( + newToken: IPublicSignupTokenCreate, + ): Promise { + const response = await this.db(TABLE).insert( + toRow(newToken), + ['created_at'], + ); + return toTokens([response])[0]; + } + + async isValid(secret: string): Promise { + const result = await this.db.raw( + `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE secret = ? AND expires_at::date > ?) AS valid`, + [secret, new Date()], + ); + const { valid } = result.rows[0]; + return valid; + } + + destroy(): void {} + + async exists(secret: string): Promise { + const result = await this.db.raw( + `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE secret = ?) AS present`, + [secret], + ); + const { present } = result.rows[0]; + return present; + } + + async get(key: string): Promise { + const row = await this.makeTokenUsersQuery() + .where('secret', key) + .first(); + + if (!row) + throw new NotFoundError('Could not find a token with that key'); + + return toTokens([row])[0]; + } + + async delete(secret: string): Promise { + return this.db(TABLE).where({ secret }).del(); + } + + async deleteAll(): Promise { + return this.db(TABLE).del(); + } + + async setExpiry( + secret: string, + expiresAt: Date, + ): Promise { + const rows = await this.makeTokenUsersQuery() + .update({ expires_at: expiresAt }) + .where('secret', secret) + .returning('*'); + if (rows.length > 0) { + return toTokens(rows)[0]; + } + throw new NotFoundError('Could not find public signup token.'); + } +} diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index b13142a4a5..ec792e660b 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -113,6 +113,10 @@ import { proxyMetricsSchema } from './spec/proxy-metrics-schema'; import { setUiConfigSchema } from './spec/set-ui-config-schema'; import { edgeTokenSchema } from './spec/edge-token-schema'; import { validateEdgeTokensSchema } from './spec/validate-edge-tokens-schema'; +import { publicSignupTokenCreateSchema } from './spec/public-signup-token-create-schema'; +import { publicSignupTokenSchema } from './spec/public-signup-token-schema'; +import { publicSignupTokensSchema } from './spec/public-signup-tokens-schema'; +import { publicSignupTokenUpdateSchema } from './spec/public-signup-token-update-schema'; // All schemas in `openapi/spec` should be listed here. export const schemas = { @@ -140,6 +144,7 @@ export const schemas = { createUserSchema, dateSchema, emailSchema, + edgeTokenSchema, environmentSchema, environmentsSchema, eventSchema, @@ -182,6 +187,14 @@ export const schemas = { playgroundRequestSchema, playgroundResponseSchema, projectEnvironmentSchema, + publicSignupTokenCreateSchema, + publicSignupTokenUpdateSchema, + publicSignupTokensSchema, + publicSignupTokenSchema, + proxyClientSchema, + proxyFeaturesSchema, + proxyFeatureSchema, + proxyMetricsSchema, projectSchema, projectsSchema, resetPasswordSchema, @@ -219,11 +232,6 @@ export const schemas = { variantSchema, variantsSchema, versionSchema, - proxyClientSchema, - proxyFeaturesSchema, - proxyFeatureSchema, - proxyMetricsSchema, - edgeTokenSchema, validateEdgeTokensSchema, }; diff --git a/src/lib/openapi/spec/public-signup-schema.test.ts b/src/lib/openapi/spec/public-signup-schema.test.ts new file mode 100644 index 0000000000..f7544445b1 --- /dev/null +++ b/src/lib/openapi/spec/public-signup-schema.test.ts @@ -0,0 +1,22 @@ +import { validateSchema } from '../validate'; +import { PublicSignupTokenSchema } from './public-signup-token-schema'; + +test('publicSignupTokenSchema', () => { + const data: PublicSignupTokenSchema = { + name: 'Default', + secret: 'some-secret', + expiresAt: new Date().toISOString(), + users: [], + role: { name: 'Viewer ', type: 'type', id: 1 }, + createdAt: new Date().toISOString(), + createdBy: 'someone', + }; + + expect( + validateSchema('#/components/schemas/publicSignupTokenSchema', {}), + ).not.toBeUndefined(); + + expect( + validateSchema('#/components/schemas/publicSignupTokenSchema', data), + ).toBeUndefined(); +}); diff --git a/src/lib/openapi/spec/public-signup-token-create-schema.ts b/src/lib/openapi/spec/public-signup-token-create-schema.ts new file mode 100644 index 0000000000..95a47b550b --- /dev/null +++ b/src/lib/openapi/spec/public-signup-token-create-schema.ts @@ -0,0 +1,22 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const publicSignupTokenCreateSchema = { + $id: '#/components/schemas/publicSignupTokenCreateSchema', + type: 'object', + additionalProperties: false, + required: ['name', 'expiresAt'], + properties: { + name: { + type: 'string', + }, + expiresAt: { + type: 'string', + format: 'date-time', + }, + }, + components: {}, +} as const; + +export type PublicSignupTokenCreateSchema = FromSchema< + typeof publicSignupTokenCreateSchema +>; diff --git a/src/lib/openapi/spec/public-signup-token-schema.ts b/src/lib/openapi/spec/public-signup-token-schema.ts new file mode 100644 index 0000000000..2e2dc2fee1 --- /dev/null +++ b/src/lib/openapi/spec/public-signup-token-schema.ts @@ -0,0 +1,50 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { userSchema } from './user-schema'; +import { roleSchema } from './role-schema'; + +export const publicSignupTokenSchema = { + $id: '#/components/schemas/publicSignupTokenSchema', + type: 'object', + additionalProperties: false, + required: ['secret', 'name', 'expiresAt', 'createdAt', 'createdBy', 'role'], + properties: { + secret: { + type: 'string', + }, + name: { + type: 'string', + }, + expiresAt: { + type: 'string', + format: 'date-time', + }, + createdAt: { + type: 'string', + format: 'date-time', + }, + createdBy: { + type: 'string', + nullable: true, + }, + users: { + type: 'array', + items: { + $ref: '#/components/schemas/userSchema', + }, + nullable: true, + }, + role: { + $ref: '#/components/schemas/roleSchema', + }, + }, + components: { + schemas: { + userSchema, + roleSchema, + }, + }, +} as const; + +export type PublicSignupTokenSchema = FromSchema< + typeof publicSignupTokenSchema +>; diff --git a/src/lib/openapi/spec/public-signup-token-update-schema.ts b/src/lib/openapi/spec/public-signup-token-update-schema.ts new file mode 100644 index 0000000000..65b59f0f58 --- /dev/null +++ b/src/lib/openapi/spec/public-signup-token-update-schema.ts @@ -0,0 +1,19 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const publicSignupTokenUpdateSchema = { + $id: '#/components/schemas/publicSignupTokenUpdateSchema', + type: 'object', + additionalProperties: false, + required: ['expiresAt'], + properties: { + expiresAt: { + type: 'string', + format: 'date-time', + }, + }, + components: {}, +} as const; + +export type PublicSignupTokenUpdateSchema = FromSchema< + typeof publicSignupTokenUpdateSchema +>; diff --git a/src/lib/openapi/spec/public-signup-tokens-schema.ts b/src/lib/openapi/spec/public-signup-tokens-schema.ts new file mode 100644 index 0000000000..14a940b460 --- /dev/null +++ b/src/lib/openapi/spec/public-signup-tokens-schema.ts @@ -0,0 +1,30 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { userSchema } from './user-schema'; +import { roleSchema } from './role-schema'; +import { publicSignupTokenSchema } from './public-signup-token-schema'; + +export const publicSignupTokensSchema = { + $id: '#/components/schemas/publicSignupTokensSchema', + type: 'object', + additionalProperties: false, + required: ['tokens'], + properties: { + tokens: { + type: 'array', + items: { + $ref: '#/components/schemas/publicSignupTokenSchema', + }, + }, + }, + components: { + schemas: { + publicSignupTokenSchema, + userSchema, + roleSchema, + }, + }, +} as const; + +export type PublicSignupTokensSchema = FromSchema< + typeof publicSignupTokensSchema +>; diff --git a/src/lib/openapi/util/openapi-tags.ts b/src/lib/openapi/util/openapi-tags.ts index dd4b5a6584..0647955969 100644 --- a/src/lib/openapi/util/openapi-tags.ts +++ b/src/lib/openapi/util/openapi-tags.ts @@ -61,6 +61,11 @@ const OPENAPI_TAGS = [ description: 'Create, update, and delete [Unleash projects](https://docs.getunleash.io/user_guide/projects).', }, + { + name: 'Public signup tokens', + description: + 'Create, update, and delete [Unleash Public Signup tokens](https://docs.getunleash.io/reference/public-signup-tokens).', + }, { name: 'Strategies', description: diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 2bfcc85693..db401be5a1 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -1,6 +1,5 @@ import Controller from '../controller'; -import { IUnleashServices } from '../../types/services'; -import { IUnleashConfig } from '../../types/option'; +import { IUnleashServices, IUnleashConfig } from '../../types'; import FeatureController from './feature'; import { FeatureTypeController } from './feature-type'; import ArchiveController from './archive'; @@ -24,6 +23,8 @@ import UserSplashController from './user-splash'; import ProjectApi from './project'; import { EnvironmentsController } from './environments'; import ConstraintsController from './constraints'; +import { PublicSignupController } from './public-signup'; +import { conditionalMiddleware } from '../../middleware/conditional-middleware'; class AdminApi extends Controller { constructor(config: IUnleashConfig, services: IUnleashServices) { @@ -101,6 +102,13 @@ class AdminApi extends Controller { '/constraints', new ConstraintsController(config, services).router, ); + this.app.use( + '/invite-link', + conditionalMiddleware( + () => config.flagResolver.isEnabled('publicSignup'), + new PublicSignupController(config, services).router, + ), + ); } } diff --git a/src/lib/routes/admin-api/public-signup.test.ts b/src/lib/routes/admin-api/public-signup.test.ts new file mode 100644 index 0000000000..5b3b930814 --- /dev/null +++ b/src/lib/routes/admin-api/public-signup.test.ts @@ -0,0 +1,211 @@ +import createStores from '../../../test/fixtures/store'; +import { createTestConfig } from '../../../test/config/test-config'; +import { createServices } from '../../services'; +import getApp from '../../app'; +import supertest from 'supertest'; +import permissions from '../../../test/fixtures/permissions'; +import { RoleName, RoleType } from '../../types/model'; +import { CreateUserSchema } from '../../openapi/spec/create-user-schema'; + +describe('Public Signup API', () => { + async function getSetup() { + const stores = createStores(); + const perms = permissions(); + const config = createTestConfig({ + preRouterHook: perms.hook, + }); + + config.flagResolver = { + isEnabled: jest.fn().mockResolvedValue(true), + getAll: jest.fn(), + }; + + stores.accessStore = { + ...stores.accessStore, + addUserToRole: jest.fn(), + removeRolesOfTypeForUser: jest.fn(), + }; + + const services = createServices(stores, config); + const app = await getApp(config, stores, services); + + await stores.roleStore.create({ + name: RoleName.VIEWER, + roleType: RoleType.ROOT, + description: '', + }); + + return { + request: supertest(app), + stores, + perms, + destroy: () => { + services.versionService.destroy(); + services.clientInstanceService.destroy(); + services.publicSignupTokenService.destroy(); + }, + }; + } + + let stores; + let request; + let destroy; + + beforeEach(async () => { + const setup = await getSetup(); + stores = setup.stores; + request = setup.request; + destroy = setup.destroy; + }); + + afterEach(() => { + destroy(); + }); + const expireAt = (addDays: number = 7): Date => { + let now = new Date(); + now.setDate(now.getDate() + addDays); + return now; + }; + + const createBody = () => ({ + name: 'some-name', + expiresAt: expireAt(), + }); + + test('should create a token', async () => { + expect.assertions(4); + const appName = '123!23'; + + stores.clientApplicationsStore.upsert({ appName }); + stores.roleStore.create({ name: RoleName.VIEWER }); + const bodyCreate = createBody(); + + return request + .post('/api/admin/invite-link/tokens') + .send(bodyCreate) + .expect(201) + .expect(async (res) => { + const token = res.body; + expect(token.name).toBe(bodyCreate.name); + expect(token.secret).not.toBeNull(); + expect(token.expiresAt).toBe( + bodyCreate.expiresAt.toISOString(), + ); + const eventCount = await stores.eventStore.count(); + expect(eventCount).toBe(1); //PUBLIC_SIGNUP_TOKEN_CREATED + }); + }); + + test('should get All', async () => { + expect.assertions(1); + const appName = '123!23'; + + stores.clientApplicationsStore.upsert({ appName }); + stores.publicSignupTokenStore.create({ + name: 'some-name', + expiresAt: expireAt(), + }); + + return request + .get('/api/admin/invite-link/tokens') + .expect(200) + .expect((res) => { + const { tokens } = res.body; + expect(tokens[0].name).toBe('some-name'); + }); + }); + + test('should get token', async () => { + expect.assertions(1); + const appName = '123!23'; + + stores.clientApplicationsStore.upsert({ appName }); + stores.publicSignupTokenStore.create({ + name: 'some-name', + expiresAt: expireAt(), + }); + + return request + .get('/api/admin/invite-link/tokens/some-secret') + .expect(200) + .expect((res) => { + const token = res.body; + expect(token.name).toBe('some-name'); + }); + }); + + test('should expire token', async () => { + expect.assertions(1); + const appName = '123!23'; + + stores.clientApplicationsStore.upsert({ appName }); + stores.publicSignupTokenStore.create({ + name: 'some-name', + expiresAt: expireAt(), + }); + + return request + .delete('/api/admin/invite-link/tokens/some-secret') + .expect(200) + .expect(async () => { + const eventCount = await stores.eventStore.count(); + expect(eventCount).toBe(1); // PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED + }); + }); + + test('should create user and add to token', async () => { + expect.assertions(3); + const appName = '123!23'; + + stores.clientApplicationsStore.upsert({ appName }); + stores.publicSignupTokenStore.create({ + name: 'some-name', + expiresAt: expireAt(), + }); + + const user: CreateUserSchema = { + username: 'some-username', + email: 'someEmail@example.com', + name: 'some-name', + password: null, + rootRole: 1, + sendEmail: false, + }; + + return request + .post('/api/admin/invite-link/tokens/some-secret/signup') + .send(user) + .expect(201) + .expect(async (res) => { + const count = await stores.userStore.count(); + expect(count).toBe(1); + const eventCount = await stores.eventStore.count(); + expect(eventCount).toBe(2); //USER_CREATED && PUBLIC_SIGNUP_TOKEN_USER_ADDED + expect(res.body.username).toBe(user.username); + }); + }); + + test('should return 200 if token is valid', async () => { + const appName = '123!23'; + + stores.clientApplicationsStore.upsert({ appName }); + stores.publicSignupTokenStore.create({ + name: 'some-name', + expiresAt: expireAt(), + }); + + return request + .post('/api/admin/invite-link/tokens/some-secret/validate') + .expect(200); + }); + + test('should return 401 if token is invalid', async () => { + const appName = '123!23'; + + stores.clientApplicationsStore.upsert({ appName }); + + return request + .post('/api/admin/invite-link/tokens/some-invalid-secret/validate') + .expect(401); + }); +}); diff --git a/src/lib/routes/admin-api/public-signup.ts b/src/lib/routes/admin-api/public-signup.ts new file mode 100644 index 0000000000..469b6e21b6 --- /dev/null +++ b/src/lib/routes/admin-api/public-signup.ts @@ -0,0 +1,289 @@ +import { Response } from 'express'; + +import Controller from '../controller'; +import { ADMIN, NONE } from '../../types/permissions'; +import { Logger } from '../../logger'; +import { AccessService } from '../../services/access-service'; +import { IAuthRequest } from '../unleash-types'; +import { IUnleashConfig, IUnleashServices } from '../../types'; +import { OpenApiService } from '../../services/openapi-service'; +import { createRequestSchema } from '../../openapi/util/create-request-schema'; +import { createResponseSchema } from '../../openapi/util/create-response-schema'; +import { serializeDates } from '../../types/serialize-dates'; +import { emptyResponse } from '../../openapi/util/standard-responses'; +import { PublicSignupTokenService } from '../../services/public-signup-token-service'; +import UserService from '../../services/user-service'; +import { + publicSignupTokenSchema, + PublicSignupTokenSchema, +} from '../../openapi/spec/public-signup-token-schema'; +import { + publicSignupTokensSchema, + PublicSignupTokensSchema, +} from '../../openapi/spec/public-signup-tokens-schema'; +import { PublicSignupTokenCreateSchema } from '../../openapi/spec/public-signup-token-create-schema'; +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'; + +interface TokenParam { + token: string; +} + +export class PublicSignupController extends Controller { + private publicSignupTokenService: PublicSignupTokenService; + + private userService: UserService; + + private accessService: AccessService; + + private openApiService: OpenApiService; + + private logger: Logger; + + constructor( + config: IUnleashConfig, + { + publicSignupTokenService, + accessService, + userService, + openApiService, + }: Pick< + IUnleashServices, + | 'publicSignupTokenService' + | 'accessService' + | 'userService' + | 'openApiService' + >, + ) { + super(config); + this.publicSignupTokenService = publicSignupTokenService; + this.accessService = accessService; + this.userService = userService; + this.openApiService = openApiService; + this.logger = config.getLogger('public-signup-controller.js'); + + this.route({ + method: 'get', + path: '/tokens', + handler: this.getAllPublicSignupTokens, + permission: ADMIN, + middleware: [ + openApiService.validPath({ + tags: ['Public signup tokens'], + operationId: 'getAllPublicSignupTokens', + responses: { + 200: createResponseSchema('publicSignupTokenSchema'), + }, + }), + ], + }); + + this.route({ + method: 'post', + path: '/tokens', + handler: this.createPublicSignupToken, + permission: ADMIN, + middleware: [ + openApiService.validPath({ + tags: ['Public signup tokens'], + operationId: 'createApiToken', + requestBody: createRequestSchema( + 'publicSignupTokenCreateSchema', + ), + responses: { + 201: createResponseSchema('publicSignupTokenSchema'), + }, + }), + ], + }); + + this.route({ + method: 'post', + path: '/tokens/:token/signup', + handler: this.addTokenUser, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Public signup tokens'], + operationId: 'addPublicSignupTokenUser', + requestBody: createRequestSchema('createUserSchema'), + responses: { + 200: createResponseSchema('userSchema'), + }, + }), + ], + }); + + this.route({ + method: 'get', + path: '/tokens/:token', + handler: this.getPublicSignupToken, + permission: ADMIN, + middleware: [ + openApiService.validPath({ + tags: ['Public signup tokens'], + operationId: 'getPublicSignupToken', + responses: { + 200: createResponseSchema('publicSignupTokenSchema'), + }, + }), + ], + }); + + this.route({ + method: 'put', + path: '/tokens/:token', + handler: this.updatePublicSignupToken, + permission: ADMIN, + middleware: [ + openApiService.validPath({ + tags: ['Public signup tokens'], + operationId: 'updatePublicSignupToken', + requestBody: createRequestSchema( + 'publicSignupTokenUpdateSchema', + ), + responses: { + 200: emptyResponse, + }, + }), + ], + }); + + this.route({ + method: 'delete', + path: '/tokens/:token', + handler: this.deletePublicSignupToken, + acceptAnyContentType: true, + permission: ADMIN, + middleware: [ + openApiService.validPath({ + tags: ['API tokens'], + operationId: 'deletePublicSignupToken', + responses: { + 200: emptyResponse, + }, + }), + ], + }); + + this.route({ + method: 'post', + path: '/tokens/:token/validate', + handler: this.validate, + acceptAnyContentType: true, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['API tokens'], + operationId: 'validateSignupToken', + responses: { + 200: emptyResponse, + 401: emptyResponse, + }, + }), + ], + }); + } + + async getAllPublicSignupTokens( + req: IAuthRequest, + res: Response, + ): Promise { + const tokens = await this.publicSignupTokenService.getAllActiveTokens(); + this.openApiService.respondWithValidation( + 200, + res, + publicSignupTokensSchema.$id, + { tokens: serializeDates(tokens) }, + ); + } + + async getPublicSignupToken( + req: IAuthRequest, + res: Response, + ): Promise { + const { token } = req.params; + const result = await this.publicSignupTokenService.get(token); + this.openApiService.respondWithValidation( + 200, + res, + publicSignupTokenSchema.$id, + serializeDates(result), + ); + } + + async validate( + req: IAuthRequest, + res: Response, + ): Promise { + const { token } = req.params; + const valid = await this.publicSignupTokenService.validate(token); + if (valid) return res.status(200).end(); + else return res.status(401).end(); + } + + async addTokenUser( + req: IAuthRequest, + res: Response, + ): Promise { + const { token } = req.params; + const user = await this.publicSignupTokenService.addTokenUser( + token, + req.body, + ); + + this.openApiService.respondWithValidation( + 201, + res, + userSchema.$id, + serializeDates(user), + ); + } + + async createPublicSignupToken( + req: IAuthRequest, + res: Response, + ): Promise { + const token = + await this.publicSignupTokenService.createNewPublicSignupToken( + req.body, + req.user.name, + ); + this.openApiService.respondWithValidation( + 201, + res, + publicSignupTokensSchema.$id, + serializeDates(token), + ); + } + + async updatePublicSignupToken( + req: IAuthRequest, + res: Response, + ): Promise { + const { token } = req.params; + const { expiresAt } = req.body; + + if (!expiresAt) { + this.logger.error(req.body); + return res.status(400).send(); + } + + await this.publicSignupTokenService.setExpiry( + token, + new Date(expiresAt), + ); + return res.status(200).end(); + } + + async deletePublicSignupToken( + req: IAuthRequest, + res: Response, + ): Promise { + const { token } = req.params; + + await this.publicSignupTokenService.delete(token, req.user.name); + res.status(200).end(); + } +} diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index b200ec96f5..48f3a76f3f 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -35,6 +35,7 @@ import { PlaygroundService } from './playground-service'; import { GroupService } from './group-service'; import { ProxyService } from './proxy-service'; import EdgeService from './edge-service'; +import { PublicSignupTokenService } from './public-signup-token-service'; export const createServices = ( stores: IUnleashStores, config: IUnleashConfig, @@ -101,6 +102,12 @@ export const createServices = ( const edgeService = new EdgeService(stores, config); + const publicSignupTokenService = new PublicSignupTokenService( + stores, + config, + userService, + ); + return { accessService, addonService, @@ -136,6 +143,7 @@ export const createServices = ( groupService, proxyService, edgeService, + publicSignupTokenService, }; }; diff --git a/src/lib/services/public-signup-token-service.ts b/src/lib/services/public-signup-token-service.ts new file mode 100644 index 0000000000..a7e54829b2 --- /dev/null +++ b/src/lib/services/public-signup-token-service.ts @@ -0,0 +1,151 @@ +import crypto from 'crypto'; +import { Logger } from '../logger'; +import { IUnleashConfig, IUnleashStores } from '../types'; +import { minutesToMilliseconds } from 'date-fns'; +import { IPublicSignupTokenStore } from '../types/stores/public-signup-token-store'; +import { PublicSignupTokenSchema } from '../openapi/spec/public-signup-token-schema'; +import { IRoleStore } from '../types/stores/role-store'; +import { IPublicSignupTokenCreate } from '../types/models/public-signup-token'; +import { PublicSignupTokenCreateSchema } from '../openapi/spec/public-signup-token-create-schema'; +import { RoleName } from '../types/model'; +import { IEventStore } from '../types/stores/event-store'; +import { + PublicSignupTokenCreatedEvent, + PublicSignupTokenManuallyExpiredEvent, + PublicSignupTokenUserAddedEvent, +} from '../types/events'; +import UserService, { ICreateUser } from './user-service'; +import { IUser } from '../types/user'; + +export class PublicSignupTokenService { + private store: IPublicSignupTokenStore; + + private roleStore: IRoleStore; + + private eventStore: IEventStore; + + private userService: UserService; + + private logger: Logger; + + private timer: NodeJS.Timeout; + + private activeTokens: PublicSignupTokenSchema[] = []; + + constructor( + { + publicSignupTokenStore, + roleStore, + eventStore, + }: Pick< + IUnleashStores, + 'publicSignupTokenStore' | 'roleStore' | 'eventStore' + >, + config: Pick, + userService: UserService, + ) { + this.store = publicSignupTokenStore; + this.userService = userService; + this.roleStore = roleStore; + this.eventStore = eventStore; + this.logger = config.getLogger( + '/services/public-signup-token-service.ts', + ); + this.fetchActiveTokens(); + this.timer = setInterval( + () => this.fetchActiveTokens(), + minutesToMilliseconds(1), + ).unref(); + } + + async fetchActiveTokens(): Promise { + this.activeTokens = await this.getAllActiveTokens(); + } + + public async get(secret: string): Promise { + return this.store.get(secret); + } + + public async getAllTokens(): Promise { + return this.store.getAll(); + } + + public async getAllActiveTokens(): Promise { + return this.store.getAllActive(); + } + + public async validate(secret: string): Promise { + return this.store.isValid(secret); + } + + public async setExpiry( + secret: string, + expireAt: Date, + ): Promise { + return this.store.setExpiry(secret, expireAt); + } + + public async addTokenUser( + secret: string, + createUser: ICreateUser, + ): Promise { + const user = await this.userService.createUser(createUser); + await this.store.addTokenUser(secret, user.id); + await this.eventStore.store( + new PublicSignupTokenUserAddedEvent({ + createdBy: 'userId', + data: { secret, userId: user.id }, + }), + ); + return user; + } + + public async delete(secret: string, expiredBy: string): Promise { + await this.expireToken(secret); + await this.eventStore.store( + new PublicSignupTokenManuallyExpiredEvent({ + createdBy: expiredBy, + data: { secret }, + }), + ); + } + + private async expireToken( + secret: string, + ): Promise { + return this.store.setExpiry(secret, new Date()); + } + + public async createNewPublicSignupToken( + tokenCreate: PublicSignupTokenCreateSchema, + createdBy: string, + ): Promise { + const viewerRole = await this.roleStore.getRoleByName(RoleName.VIEWER); + const newToken: IPublicSignupTokenCreate = { + name: tokenCreate.name, + expiresAt: new Date(tokenCreate.expiresAt), + secret: this.generateSecretKey(), + roleId: viewerRole ? viewerRole.id : -1, + createdBy: createdBy, + }; + const token = await this.store.insert(newToken); + this.activeTokens.push(token); + + await this.eventStore.store( + new PublicSignupTokenCreatedEvent({ + createdBy: createdBy, + data: newToken, + }), + ); + return token; + } + + private generateSecretKey(): string { + return crypto.randomBytes(32).toString('hex'); + } + + destroy(): void { + clearInterval(this.timer); + this.timer = null; + } +} diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index b01b505b1e..4f3a10de26 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -76,6 +76,11 @@ export const SETTING_DELETED = 'setting-deleted'; export const CLIENT_METRICS = 'client-metrics'; export const CLIENT_REGISTER = 'client-register'; +export const PUBLIC_SIGNUP_TOKEN_CREATED = 'public-signup-token-created'; +export const PUBLIC_SIGNUP_TOKEN_USER_ADDED = 'public-signup-token-user-added'; +export const PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED = + 'public-signup-token-manually-expired'; + export interface IBaseEvent { type: string; createdBy: string; @@ -531,3 +536,30 @@ export class SettingUpdatedEvent extends BaseEvent { this.data = eventData.data; } } + +export class PublicSignupTokenCreatedEvent extends BaseEvent { + readonly data: any; + + constructor(eventData: { createdBy: string; data: any }) { + super(PUBLIC_SIGNUP_TOKEN_CREATED, eventData.createdBy); + this.data = eventData.data; + } +} + +export class PublicSignupTokenManuallyExpiredEvent extends BaseEvent { + readonly data: any; + + constructor(eventData: { createdBy: string; data: any }) { + super(PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED, eventData.createdBy); + this.data = eventData.data; + } +} + +export class PublicSignupTokenUserAddedEvent extends BaseEvent { + readonly data: any; + + constructor(eventData: { createdBy: string; data: any }) { + super(PUBLIC_SIGNUP_TOKEN_USER_ADDED, eventData.createdBy); + this.data = eventData.data; + } +} diff --git a/src/lib/types/models/public-signup-token.ts b/src/lib/types/models/public-signup-token.ts new file mode 100644 index 0000000000..4e32cff1aa --- /dev/null +++ b/src/lib/types/models/public-signup-token.ts @@ -0,0 +1,13 @@ +import User from '../user'; + +export interface IPublicSignupTokenCreate { + name: string; + expiresAt: Date; + roleId: number; + secret: string; + createdBy: string; +} + +export interface IPublicSignupToken extends IPublicSignupTokenCreate { + users: User[]; +} diff --git a/src/lib/types/option.ts b/src/lib/types/option.ts index da8013afeb..17e2599f20 100644 --- a/src/lib/types/option.ts +++ b/src/lib/types/option.ts @@ -115,6 +115,7 @@ export interface IUnleashOptions { disableLegacyFeaturesApi?: boolean; inlineSegmentConstraints?: boolean; clientFeatureCaching?: Partial; + flagResolver?: IFlagResolver; } export interface IEmailOption { diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 2bbe32ea49..fdc3fe2176 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -31,6 +31,7 @@ import { PlaygroundService } from 'lib/services/playground-service'; import { GroupService } from '../services/group-service'; import { ProxyService } from '../services/proxy-service'; import EdgeService from '../services/edge-service'; +import { PublicSignupTokenService } from '../services/public-signup-token-service'; export interface IUnleashServices { accessService: AccessService; @@ -42,6 +43,7 @@ export interface IUnleashServices { emailService: EmailService; environmentService: EnvironmentService; eventService: EventService; + edgeService: EdgeService; featureTagService: FeatureTagService; featureToggleService: FeatureToggleService; featureToggleServiceV2: FeatureToggleService; // deprecated @@ -50,6 +52,9 @@ export interface IUnleashServices { healthService: HealthService; projectHealthService: ProjectHealthService; projectService: ProjectService; + playgroundService: PlaygroundService; + proxyService: ProxyService; + publicSignupTokenService: PublicSignupTokenService; resetTokenService: ResetTokenService; sessionService: SessionService; settingService: SettingService; @@ -64,7 +69,4 @@ export interface IUnleashServices { segmentService: SegmentService; openApiService: OpenApiService; clientSpecService: ClientSpecService; - playgroundService: PlaygroundService; - proxyService: ProxyService; - edgeService: EdgeService; } diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 7e92c17118..7e6b244fe7 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -26,6 +26,7 @@ import { IUserSplashStore } from './stores/user-splash-store'; import { IRoleStore } from './stores/role-store'; import { ISegmentStore } from './stores/segment-store'; import { IGroupStore } from './stores/group-store'; +import { IPublicSignupTokenStore } from './stores/public-signup-token-store'; export interface IUnleashStores { accessStore: IAccessStore; @@ -56,4 +57,5 @@ export interface IUnleashStores { userSplashStore: IUserSplashStore; roleStore: IRoleStore; segmentStore: ISegmentStore; + publicSignupTokenStore: IPublicSignupTokenStore; } diff --git a/src/lib/types/stores/public-signup-token-store.ts b/src/lib/types/stores/public-signup-token-store.ts new file mode 100644 index 0000000000..d72a4007e9 --- /dev/null +++ b/src/lib/types/stores/public-signup-token-store.ts @@ -0,0 +1,19 @@ +import { Store } from './store'; +import { PublicSignupTokenSchema } from '../../openapi/spec/public-signup-token-schema'; +import { IPublicSignupTokenCreate } from '../models/public-signup-token'; + +export interface IPublicSignupTokenStore + extends Store { + getAllActive(): Promise; + insert( + newToken: IPublicSignupTokenCreate, + ): Promise; + addTokenUser(secret: string, userId: number): Promise; + isValid(secret): Promise; + setExpiry( + secret: string, + expiresAt: Date, + ): Promise; + delete(secret: string): Promise; + count(): Promise; +} diff --git a/src/migrations/20220908093515-add-public-signup-tokens.js b/src/migrations/20220908093515-add-public-signup-tokens.js new file mode 100644 index 0000000000..64e309f11e --- /dev/null +++ b/src/migrations/20220908093515-add-public-signup-tokens.js @@ -0,0 +1,37 @@ +'use strict'; + +exports.up = function (db, callback) { + db.runSql( + ` + create table IF NOT EXISTS public_signup_tokens + ( + secret text primary key, + name text, + expires_at timestamp with time zone not null, + created_at timestamp with time zone not null default now(), + created_by text, + role_id integer not null references roles (id) ON DELETE CASCADE + ); + + create table IF NOT EXISTS public_signup_tokens_user + ( + secret text not null references public_signup_tokens (secret) on DELETE CASCADE, + user_id integer not null references users (id) ON DELETE CASCADE, + created_at timestamp with time zone not null default now(), + primary key (secret, user_id) + ); + `, + callback, + ); +}; + +exports.down = function (db, callback) { + db.runSql( + ` +DROP TABLE public_signup_tokens; +DROP TABLE public_signup_tokens_user; +DROP TABLE public_signup_tokens_role; + `, + callback, + ); +}; diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index ed1dacfc77..45ccd76e8f 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -2353,6 +2353,93 @@ exports[`should serve the OpenAPI spec 1`] = ` ], "type": "object", }, + "publicSignupTokenCreateSchema": { + "additionalProperties": false, + "properties": { + "expiresAt": { + "format": "date-time", + "type": "string", + }, + "name": { + "type": "string", + }, + }, + "required": [ + "name", + "expiresAt", + ], + "type": "object", + }, + "publicSignupTokenSchema": { + "additionalProperties": false, + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + }, + "createdBy": { + "nullable": true, + "type": "string", + }, + "expiresAt": { + "format": "date-time", + "type": "string", + }, + "name": { + "type": "string", + }, + "role": { + "$ref": "#/components/schemas/roleSchema", + }, + "secret": { + "type": "string", + }, + "users": { + "items": { + "$ref": "#/components/schemas/userSchema", + }, + "nullable": true, + "type": "array", + }, + }, + "required": [ + "secret", + "name", + "expiresAt", + "createdAt", + "createdBy", + "role", + ], + "type": "object", + }, + "publicSignupTokenUpdateSchema": { + "additionalProperties": false, + "properties": { + "expiresAt": { + "format": "date-time", + "type": "string", + }, + }, + "required": [ + "expiresAt", + ], + "type": "object", + }, + "publicSignupTokensSchema": { + "additionalProperties": false, + "properties": { + "tokens": { + "items": { + "$ref": "#/components/schemas/publicSignupTokenSchema", + }, + "type": "array", + }, + }, + "required": [ + "tokens", + ], + "type": "object", + }, "resetPasswordSchema": { "additionalProperties": false, "properties": { @@ -4346,6 +4433,205 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/invite-link/tokens": { + "get": { + "operationId": "getAllPublicSignupTokens", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/publicSignupTokenSchema", + }, + }, + }, + "description": "publicSignupTokenSchema", + }, + }, + "tags": [ + "Public signup tokens", + ], + }, + "post": { + "operationId": "createApiToken", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/publicSignupTokenCreateSchema", + }, + }, + }, + "description": "publicSignupTokenCreateSchema", + "required": true, + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/publicSignupTokenSchema", + }, + }, + }, + "description": "publicSignupTokenSchema", + }, + }, + "tags": [ + "Public signup tokens", + ], + }, + }, + "/api/admin/invite-link/tokens/{token}": { + "delete": { + "operationId": "deletePublicSignupToken", + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "This response has no body.", + }, + }, + "tags": [ + "API tokens", + ], + }, + "get": { + "operationId": "getPublicSignupToken", + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/publicSignupTokenSchema", + }, + }, + }, + "description": "publicSignupTokenSchema", + }, + }, + "tags": [ + "Public signup tokens", + ], + }, + "put": { + "operationId": "updatePublicSignupToken", + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/publicSignupTokenUpdateSchema", + }, + }, + }, + "description": "publicSignupTokenUpdateSchema", + "required": true, + }, + "responses": { + "200": { + "description": "This response has no body.", + }, + }, + "tags": [ + "Public signup tokens", + ], + }, + }, + "/api/admin/invite-link/tokens/{token}/signup": { + "post": { + "operationId": "addPublicSignupTokenUser", + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/createUserSchema", + }, + }, + }, + "description": "createUserSchema", + "required": true, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/userSchema", + }, + }, + }, + "description": "userSchema", + }, + }, + "tags": [ + "Public signup tokens", + ], + }, + }, + "/api/admin/invite-link/tokens/{token}/validate": { + "post": { + "operationId": "validateSignupToken", + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "This response has no body.", + }, + "404": { + "description": "This response has no body.", + }, + }, + "tags": [ + "API tokens", + ], + }, + }, "/api/admin/metrics/applications": { "get": { "operationId": "getApplications", @@ -7028,6 +7314,10 @@ If the provided project does not exist, the list of events will be empty.", "description": "Create, update, and delete [Unleash projects](https://docs.getunleash.io/user_guide/projects).", "name": "Projects", }, + { + "description": "Create, update, and delete [Unleash Public Signup tokens](https://docs.getunleash.io/reference/public-signup-tokens).", + "name": "Public signup tokens", + }, { "description": "Create, update, delete, manage [custom strategies](https://docs.getunleash.io/advanced/custom_activation_strategy).", "name": "Strategies", diff --git a/src/test/fixtures/fake-public-signup-store.ts b/src/test/fixtures/fake-public-signup-store.ts new file mode 100644 index 0000000000..24d61722df --- /dev/null +++ b/src/test/fixtures/fake-public-signup-store.ts @@ -0,0 +1,85 @@ +import { IPublicSignupTokenStore } from '../../lib/types/stores/public-signup-token-store'; +import { PublicSignupTokenSchema } from '../../lib/openapi/spec/public-signup-token-schema'; +import { IPublicSignupTokenCreate } from '../../lib/types/models/public-signup-token'; + +export default class FakePublicSignupStore implements IPublicSignupTokenStore { + tokens: PublicSignupTokenSchema[] = []; + + async addTokenUser(secret: string, userId: number): Promise { + this.get(secret).then((token) => token.users.push({ id: userId })); + return Promise.resolve(); + } + + async get(secret: string): Promise { + const token = this.tokens.find((t) => t.secret === secret); + return Promise.resolve(token); + } + + async isValid(secret: string): Promise { + const token = this.tokens.find((t) => t.secret === secret); + return Promise.resolve(token && new Date(token.expiresAt) > new Date()); + } + + async count(): Promise { + return Promise.resolve(0); + } + + // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars + async delete(secret: string): Promise { + return Promise.resolve(undefined); + } + + async getAllActive(): Promise { + return Promise.resolve(this.tokens); + } + + async create( + newToken: IPublicSignupTokenCreate, + ): Promise { + return this.insert(newToken); + } + + async insert( + newToken: IPublicSignupTokenCreate, + ): Promise { + const token = { + secret: 'some-secret', + expiresAt: newToken.expiresAt.toISOString(), + createdAt: new Date().toISOString(), + users: [], + name: newToken.name, + role: { + name: 'Viewer', + type: '', + id: 1, + }, + createdBy: null, + }; + this.tokens.push(token); + return Promise.resolve(token); + } + + async setExpiry( + secret: string, + expiresAt: Date, + ): Promise { + const token = await this.get(secret); + token.expiresAt = expiresAt.toISOString(); + return Promise.resolve(token); + } + + async deleteAll(): Promise { + return Promise.resolve(undefined); + } + + destroy(): void {} + + async exists(key: string): Promise { + return this.tokens.some((t) => t.secret === key); + } + + // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars + async getAll(query?: Object): Promise { + return Promise.resolve([]); + } +} diff --git a/src/test/fixtures/fake-role-store.ts b/src/test/fixtures/fake-role-store.ts index 4f4b4b1fba..fc5fbe245e 100644 --- a/src/test/fixtures/fake-role-store.ts +++ b/src/test/fixtures/fake-role-store.ts @@ -8,6 +8,8 @@ import { } from 'lib/types/stores/role-store'; export default class FakeRoleStore implements IRoleStore { + roles: ICustomRole[] = []; + getGroupRolesForProject(projectId: string): Promise { throw new Error('Method not implemented.'); } @@ -16,12 +18,14 @@ export default class FakeRoleStore implements IRoleStore { throw new Error('Method not implemented.'); } - getAll(): Promise { - throw new Error('Method not implemented.'); + async getAll(): Promise { + return this.roles; } - create(role: ICustomRoleInsert): Promise { - throw new Error('Method not implemented.'); + async create(role: ICustomRoleInsert): Promise { + const roleCreated = { ...role, id: 1, type: 'some-type' }; + this.roles.push(roleCreated); + return Promise.resolve(roleCreated); } update(role: ICustomRoleUpdate): Promise { @@ -36,8 +40,8 @@ export default class FakeRoleStore implements IRoleStore { throw new Error('Method not implemented.'); } - getRoleByName(name: string): Promise { - throw new Error('Method not implemented.'); + async getRoleByName(name: string): Promise { + return this.roles.find((r) => (r.name = name)); } getRolesForProject(projectId: string): Promise { @@ -52,8 +56,8 @@ export default class FakeRoleStore implements IRoleStore { throw new Error('Method not implemented.'); } - getRootRoles(): Promise { - throw new Error('Method not implemented.'); + async getRootRoles(): Promise { + return this.roles; } getRootRoleForAllUsers(): Promise { diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 76c0dd02a9..9970163b94 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -27,6 +27,7 @@ import FakeUserSplashStore from './fake-user-splash-store'; import FakeRoleStore from './fake-role-store'; import FakeSegmentStore from './fake-segment-store'; import FakeGroupStore from './fake-group-store'; +import FakePublicSignupStore from './fake-public-signup-store'; const createStores: () => IUnleashStores = () => { const db = { @@ -65,6 +66,7 @@ const createStores: () => IUnleashStores = () => { roleStore: new FakeRoleStore(), segmentStore: new FakeSegmentStore(), groupStore: new FakeGroupStore(), + publicSignupTokenStore: new FakePublicSignupStore(), }; };