diff --git a/.eslintignore b/.eslintignore index a5d4f44329..655b1ab945 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,11 +3,12 @@ docker bundle.js website/blog website/build +website/core +website/docs website/node_modules website/i18n/*.js -website/translated_docs -website/core website/pages +website/translated_docs website setupJest.js frontend diff --git a/.prettierignore b/.prettierignore index 83b694704b..50fa9364be 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ -CHANGELOG.md \ No newline at end of file +CHANGELOG.md +website/docs diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index d6c8665f77..693b77a071 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 PatStore from './pat-store'; import { PublicSignupTokenStore } from './public-signup-token-store'; export const createStores = ( @@ -90,6 +91,7 @@ export const createStores = ( eventBus, getLogger, ), + patStore: new PatStore(db, getLogger), }; }; diff --git a/src/lib/db/pat-store.ts b/src/lib/db/pat-store.ts new file mode 100644 index 0000000000..04ab6b1336 --- /dev/null +++ b/src/lib/db/pat-store.ts @@ -0,0 +1,87 @@ +import { Knex } from 'knex'; +import { Logger, LogProvider } from '../logger'; +import { IPatStore } from '../types/stores/pat-store'; +import Pat, { IPat } from '../types/models/pat'; +import NotFoundError from '../error/notfound-error'; + +const TABLE = 'personal_access_tokens'; + +const PAT_COLUMNS = [ + 'secret', + 'user_id', + 'expires_at', + 'created_at', + 'seen_at', +]; + +const fromRow = (row) => { + if (!row) { + throw new NotFoundError('No PAT found'); + } + return new Pat({ + secret: row.secret, + userId: row.user_id, + createdAt: row.created_at, + seenAt: row.seen_at, + expiresAt: row.expires_at, + }); +}; + +const toRow = (user: IPat) => ({ + secret: user.secret, + user_id: user.userId, + expires_at: user.expiresAt, +}); + +export default class PatStore implements IPatStore { + private db: Knex; + + private logger: Logger; + + constructor(db: Knex, getLogger: LogProvider) { + this.db = db; + this.logger = getLogger('pat-store.ts'); + } + + async create(token: IPat): Promise { + const row = await this.db(TABLE).insert(toRow(token)).returning('*'); + return fromRow(row[0]); + } + + async delete(secret: string): Promise { + return this.db(TABLE).where({ secret: secret }).del(); + } + + async deleteAll(): Promise { + await this.db(TABLE).del(); + } + + 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(secret: string): Promise { + const row = await this.db(TABLE).where({ secret }).first(); + return fromRow(row); + } + + async getAll(): Promise { + const groups = await this.db.select(PAT_COLUMNS).from(TABLE); + return groups.map(fromRow); + } + + async getAllByUser(userId: number): Promise { + const groups = await this.db + .select(PAT_COLUMNS) + .from(TABLE) + .where('user_id', userId); + return groups.map(fromRow); + } +} diff --git a/src/lib/db/segment-store.ts b/src/lib/db/segment-store.ts index 8cab85b51a..20eca85979 100644 --- a/src/lib/db/segment-store.ts +++ b/src/lib/db/segment-store.ts @@ -4,8 +4,8 @@ import { Logger, LogProvider } from '../logger'; import { Knex } from 'knex'; import EventEmitter from 'events'; import NotFoundError from '../error/notfound-error'; -import User from '../types/user'; import { PartialSome } from '../types/partial'; +import User from '../types/user'; const T = { segments: 'segments', diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 482d4f9163..f888bf0ab5 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -113,6 +113,8 @@ 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 { patsSchema } from './spec/pats-schema'; +import { patSchema } from './spec/pat-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'; @@ -178,6 +180,8 @@ export const schemas = { overrideSchema, parametersSchema, passwordSchema, + patSchema, + patsSchema, patchesSchema, patchSchema, permissionSchema, diff --git a/src/lib/openapi/spec/pat-schema.ts b/src/lib/openapi/spec/pat-schema.ts new file mode 100644 index 0000000000..bcd8a2b141 --- /dev/null +++ b/src/lib/openapi/spec/pat-schema.ts @@ -0,0 +1,31 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const patSchema = { + $id: '#/components/schemas/patSchema', + type: 'object', + properties: { + secret: { + type: 'string', + }, + expiresAt: { + type: 'string', + format: 'date-time', + nullable: true, + }, + createdAt: { + type: 'string', + format: 'date-time', + nullable: true, + }, + seenAt: { + type: 'string', + format: 'date-time', + nullable: true, + }, + }, + components: { + schemas: {}, + }, +} as const; + +export type PatSchema = FromSchema; diff --git a/src/lib/openapi/spec/pats-schema.ts b/src/lib/openapi/spec/pats-schema.ts new file mode 100644 index 0000000000..076472c09b --- /dev/null +++ b/src/lib/openapi/spec/pats-schema.ts @@ -0,0 +1,22 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { patSchema } from './pat-schema'; + +export const patsSchema = { + $id: '#/components/schemas/patsSchema', + type: 'object', + properties: { + pats: { + type: 'array', + items: { + $ref: '#/components/schemas/patSchema', + }, + }, + }, + components: { + schemas: { + patSchema, + }, + }, +} as const; + +export type PatsSchema = FromSchema; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index db401be5a1..dce4d343be 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -7,7 +7,7 @@ import StrategyController from './strategy'; import EventController from './event'; import PlaygroundController from './playground'; import MetricsController from './metrics'; -import UserController from './user'; +import UserController from './user/user'; import ConfigController from './config'; import { ContextController } from './context'; import ClientMetricsController from './client-metrics'; @@ -23,6 +23,7 @@ import UserSplashController from './user-splash'; import ProjectApi from './project'; import { EnvironmentsController } from './environments'; import ConstraintsController from './constraints'; +import PatController from './user/pat'; import { PublicSignupController } from './public-signup'; import { conditionalMiddleware } from '../../middleware/conditional-middleware'; @@ -61,6 +62,10 @@ class AdminApi extends Controller { new ClientMetricsController(config, services).router, ); this.app.use('/user', new UserController(config, services).router); + this.app.use( + '/user/tokens', + new PatController(config, services).router, + ); this.app.use( '/ui-config', new ConfigController(config, services).router, diff --git a/src/lib/routes/admin-api/user/pat.ts b/src/lib/routes/admin-api/user/pat.ts new file mode 100644 index 0000000000..bb3dde1999 --- /dev/null +++ b/src/lib/routes/admin-api/user/pat.ts @@ -0,0 +1,105 @@ +import { Response } from 'express'; +import Controller from '../../controller'; +import { Logger } from '../../../logger'; +import { IUnleashConfig, IUnleashServices } from '../../../types'; +import { createRequestSchema } from '../../../openapi/util/create-request-schema'; +import { createResponseSchema } from '../../../openapi/util/create-response-schema'; +import { OpenApiService } from '../../../services/openapi-service'; +import { emptyResponse } from '../../../openapi/util/standard-responses'; + +import PatService from '../../../services/pat-service'; +import { NONE } from '../../../types/permissions'; +import { IAuthRequest } from '../../unleash-types'; +import { serializeDates } from '../../../types/serialize-dates'; +import { PatSchema, patSchema } from '../../../openapi/spec/pat-schema'; +import { patsSchema } from '../../../openapi/spec/pats-schema'; + +export default class PatController extends Controller { + private patService: PatService; + + private openApiService: OpenApiService; + + private logger: Logger; + + constructor( + config: IUnleashConfig, + { + openApiService, + patService, + }: Pick, + ) { + super(config); + this.logger = config.getLogger('lib/routes/auth/pat-controller.ts'); + this.openApiService = openApiService; + this.patService = patService; + this.route({ + method: 'get', + path: '', + handler: this.getPats, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['admin'], + operationId: 'getPats', + responses: { 200: createResponseSchema('patsSchema') }, + }), + ], + }); + this.route({ + method: 'post', + path: '', + handler: this.createPat, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['admin'], + operationId: 'createPat', + requestBody: createRequestSchema('patSchema'), + responses: { 200: createResponseSchema('patSchema') }, + }), + ], + }); + + this.route({ + method: 'delete', + path: '/:secret', + acceptAnyContentType: true, + handler: this.deletePat, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['admin'], + operationId: 'deletePat', + responses: { 200: emptyResponse }, + }), + ], + }); + } + + async createPat(req: IAuthRequest, res: Response): Promise { + const pat = req.body; + const createdPat = await this.patService.createPat(pat, req.user); + this.openApiService.respondWithValidation( + 201, + res, + patSchema.$id, + serializeDates(createdPat), + ); + } + + async getPats(req: IAuthRequest, res: Response): Promise { + const pats = await this.patService.getAll(req.user); + this.openApiService.respondWithValidation(200, res, patsSchema.$id, { + pats: serializeDates(pats), + }); + } + + async deletePat( + req: IAuthRequest<{ secret: string }>, + res: Response, + ): Promise { + const { secret } = req.params; + await this.patService.deletePat(secret); + res.status(200).end(); + } +} diff --git a/src/lib/routes/admin-api/user.test.ts b/src/lib/routes/admin-api/user/user.test.ts similarity index 90% rename from src/lib/routes/admin-api/user.test.ts rename to src/lib/routes/admin-api/user/user.test.ts index aa9ca689a8..313448c361 100644 --- a/src/lib/routes/admin-api/user.test.ts +++ b/src/lib/routes/admin-api/user/user.test.ts @@ -1,10 +1,10 @@ import supertest from 'supertest'; -import { createServices } from '../../services'; -import { createTestConfig } from '../../../test/config/test-config'; +import { createServices } from '../../../services'; +import { createTestConfig } from '../../../../test/config/test-config'; -import createStores from '../../../test/fixtures/store'; -import getApp from '../../app'; -import User from '../../types/user'; +import createStores from '../../../../test/fixtures/store'; +import getApp from '../../../app'; +import User from '../../../types/user'; const currentUser = new User({ id: 1337, email: 'test@mail.com' }); diff --git a/src/lib/routes/admin-api/user.ts b/src/lib/routes/admin-api/user/user.ts similarity index 75% rename from src/lib/routes/admin-api/user.ts rename to src/lib/routes/admin-api/user/user.ts index 9ba9568aef..34dd70492b 100644 --- a/src/lib/routes/admin-api/user.ts +++ b/src/lib/routes/admin-api/user/user.ts @@ -1,21 +1,21 @@ import { Response } from 'express'; -import { IAuthRequest } from '../unleash-types'; -import Controller from '../controller'; -import { AccessService } from '../../services/access-service'; -import { IAuthType, IUnleashConfig } from '../../types/option'; -import { IUnleashServices } from '../../types/services'; -import UserService from '../../services/user-service'; -import UserFeedbackService from '../../services/user-feedback-service'; -import UserSplashService from '../../services/user-splash-service'; -import { ADMIN, NONE } from '../../types/permissions'; -import { OpenApiService } from '../../services/openapi-service'; -import { createRequestSchema } from '../../openapi/util/create-request-schema'; -import { createResponseSchema } from '../../openapi/util/create-response-schema'; -import { meSchema, MeSchema } from '../../openapi/spec/me-schema'; -import { serializeDates } from '../../types/serialize-dates'; -import { IUserPermission } from '../../types/stores/access-store'; -import { PasswordSchema } from '../../openapi/spec/password-schema'; -import { emptyResponse } from '../../openapi/util/standard-responses'; +import { IAuthRequest } from '../../unleash-types'; +import Controller from '../../controller'; +import { AccessService } from '../../../services/access-service'; +import { IAuthType, IUnleashConfig } from '../../../types/option'; +import { IUnleashServices } from '../../../types/services'; +import UserService from '../../../services/user-service'; +import UserFeedbackService from '../../../services/user-feedback-service'; +import UserSplashService from '../../../services/user-splash-service'; +import { ADMIN, NONE } from '../../../types/permissions'; +import { OpenApiService } from '../../../services/openapi-service'; +import { createRequestSchema } from '../../../openapi/util/create-request-schema'; +import { createResponseSchema } from '../../../openapi/util/create-response-schema'; +import { meSchema, MeSchema } from '../../../openapi/spec/me-schema'; +import { serializeDates } from '../../../types/serialize-dates'; +import { IUserPermission } from '../../../types/stores/access-store'; +import { PasswordSchema } from '../../../openapi/spec/password-schema'; +import { emptyResponse } from '../../../openapi/util/standard-responses'; class UserController extends Controller { private accessService: AccessService; diff --git a/src/lib/routes/client-api/index.ts b/src/lib/routes/client-api/index.ts index 4eff426ae5..de34084bf6 100644 --- a/src/lib/routes/client-api/index.ts +++ b/src/lib/routes/client-api/index.ts @@ -2,8 +2,7 @@ import Controller from '../controller'; import FeatureController from './feature'; import MetricsController from './metrics'; import RegisterController from './register'; -import { IUnleashConfig } from '../../types/option'; -import { IUnleashServices } from '../../types'; +import { IUnleashConfig, IUnleashServices } from '../../types'; export default class ClientApi extends Controller { constructor(config: IUnleashConfig, services: IUnleashServices) { diff --git a/src/lib/routes/index.ts b/src/lib/routes/index.ts index 076ee8fc33..71b98693d4 100644 --- a/src/lib/routes/index.ts +++ b/src/lib/routes/index.ts @@ -1,8 +1,7 @@ import { BackstageController } from './backstage'; import ResetPasswordController from './auth/reset-password-controller'; import { SimplePasswordProvider } from './auth/simple-password-provider'; -import { IUnleashConfig } from '../types/option'; -import { IUnleashServices } from '../types/services'; +import { IUnleashConfig, IUnleashServices } from '../types'; import LogoutController from './logout'; const AdminApi = require('./admin-api'); @@ -28,6 +27,7 @@ class IndexRouter extends Controller { '/auth/reset', new ResetPasswordController(config, services).router, ); + this.use('/api/admin', new AdminApi(config, services).router); this.use('/api/client', new ClientApi(config, services).router); diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 48f3a76f3f..87722810de 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -1,6 +1,4 @@ -import { IUnleashConfig } from '../types/option'; -import { IUnleashStores } from '../types/stores'; -import { IUnleashServices } from '../types/services'; +import { IUnleashConfig, IUnleashStores, IUnleashServices } from '../types'; import FeatureTypeService from './feature-type-service'; import EventService from './event-service'; import HealthService from './health-service'; @@ -35,6 +33,7 @@ import { PlaygroundService } from './playground-service'; import { GroupService } from './group-service'; import { ProxyService } from './proxy-service'; import EdgeService from './edge-service'; +import PatService from './pat-service'; import { PublicSignupTokenService } from './public-signup-token-service'; export const createServices = ( stores: IUnleashStores, @@ -102,6 +101,8 @@ export const createServices = ( const edgeService = new EdgeService(stores, config); + const patService = new PatService(stores, config); + const publicSignupTokenService = new PublicSignupTokenService( stores, config, @@ -143,6 +144,7 @@ export const createServices = ( groupService, proxyService, edgeService, + patService, publicSignupTokenService, }; }; diff --git a/src/lib/services/pat-service.ts b/src/lib/services/pat-service.ts new file mode 100644 index 0000000000..6f8a316cae --- /dev/null +++ b/src/lib/services/pat-service.ts @@ -0,0 +1,63 @@ +import { IUnleashConfig, IUnleashStores } from '../types'; +import { Logger } from '../logger'; +import { IPatStore } from '../types/stores/pat-store'; +import { IEventStore } from '../types/stores/event-store'; +import { PAT_CREATED } from '../types/events'; +import { IPat } from '../types/models/pat'; +import crypto from 'crypto'; +import User from '../types/user'; + +export default class PatService { + private config: IUnleashConfig; + + private logger: Logger; + + private patStore: IPatStore; + + private eventStore: IEventStore; + + constructor( + { + patStore, + eventStore, + }: Pick, + config: IUnleashConfig, + ) { + this.config = config; + this.logger = config.getLogger('services/pat-service.ts'); + this.patStore = patStore; + this.eventStore = eventStore; + } + + async createPat(pat: IPat, user: User): Promise { + if (new Date(pat.expiresAt) < new Date()) { + throw new Error('The expiry date should be in future.'); + } + pat.secret = this.generateSecretKey(); + pat.userId = user.id; + const newPat = await this.patStore.create(pat); + + await this.eventStore.store({ + type: PAT_CREATED, + createdBy: user.email || user.username, + data: pat, + }); + + return newPat; + } + + async getAll(user: User): Promise { + return this.patStore.getAllByUser(user.id); + } + + async deletePat(secret: string): Promise { + return this.patStore.delete(secret); + } + + private generateSecretKey() { + const randomStr = crypto.randomBytes(28).toString('hex'); + return `user:${randomStr}`; + } +} + +module.exports = PatService; diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index 4f3a10de26..d9a54f4870 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -76,6 +76,8 @@ export const SETTING_DELETED = 'setting-deleted'; export const CLIENT_METRICS = 'client-metrics'; export const CLIENT_REGISTER = 'client-register'; +export const PAT_CREATED = 'pat-created'; + 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 = diff --git a/src/lib/types/models/pat.ts b/src/lib/types/models/pat.ts new file mode 100644 index 0000000000..0458404223 --- /dev/null +++ b/src/lib/types/models/pat.ts @@ -0,0 +1,27 @@ +export interface IPat { + secret: string; + userId: number; + expiresAt?: Date; + createdAt?: Date; + seenAt?: Date; +} + +export default class Pat implements IPat { + secret: string; + + userId: number; + + expiresAt: Date; + + seenAt: Date; + + createdAt: Date; + + constructor({ secret, userId, expiresAt, seenAt, createdAt }: IPat) { + this.secret = secret; + this.userId = userId; + this.expiresAt = expiresAt; + this.seenAt = seenAt; + this.createdAt = createdAt; + } +} diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index fdc3fe2176..61b39c379e 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 PatService from '../services/pat-service'; import { PublicSignupTokenService } from '../services/public-signup-token-service'; export interface IUnleashServices { @@ -69,4 +70,5 @@ export interface IUnleashServices { segmentService: SegmentService; openApiService: OpenApiService; clientSpecService: ClientSpecService; + patService: PatService; } diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 7e6b244fe7..0d49d40af6 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 { IPatStore } from './stores/pat-store'; import { IPublicSignupTokenStore } from './stores/public-signup-token-store'; export interface IUnleashStores { @@ -57,5 +58,6 @@ export interface IUnleashStores { userSplashStore: IUserSplashStore; roleStore: IRoleStore; segmentStore: ISegmentStore; + patStore: IPatStore; publicSignupTokenStore: IPublicSignupTokenStore; } diff --git a/src/lib/types/stores/pat-store.ts b/src/lib/types/stores/pat-store.ts new file mode 100644 index 0000000000..a4cf48b7b0 --- /dev/null +++ b/src/lib/types/stores/pat-store.ts @@ -0,0 +1,7 @@ +import { Store } from './store'; +import { IPat } from '../models/pat'; + +export interface IPatStore extends Store { + create(group: IPat): Promise; + getAllByUser(userId: number): Promise; +} diff --git a/src/migrations/20220912165344-pat-tokens.js b/src/migrations/20220912165344-pat-tokens.js new file mode 100644 index 0000000000..4cd6602a68 --- /dev/null +++ b/src/migrations/20220912165344-pat-tokens.js @@ -0,0 +1,19 @@ +'use strict'; + +exports.up = function (db, cb) { + db.runSql( + ` + CREATE TABLE personal_access_tokens ( + secret text not null primary key, + user_id integer not null references users (id) ON DELETE CASCADE, + expires_at timestamp with time zone NOT NULL, + seen_at timestamp with time zone, + created_at timestamp with time zone not null DEFAULT now() + );`, + cb, + ); +}; + +exports.down = function (db, cb) { + db.runSql(`drop table personal_access_tokens`, cb); +}; 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 3df2e2ec94..4027c1f8b4 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 @@ -1649,6 +1649,29 @@ exports[`should serve the OpenAPI spec 1`] = ` ], "type": "object", }, + "patSchema": { + "properties": { + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "expiresAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "secret": { + "type": "string", + }, + "seenAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + }, + "type": "object", + }, "patchSchema": { "properties": { "from": { @@ -1681,6 +1704,17 @@ exports[`should serve the OpenAPI spec 1`] = ` }, "type": "array", }, + "patsSchema": { + "properties": { + "pats": { + "items": { + "$ref": "#/components/schemas/patSchema", + }, + "type": "array", + }, + }, + "type": "object", + }, "permissionSchema": { "additionalProperties": false, "properties": { @@ -6886,6 +6920,78 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/user/tokens": { + "get": { + "operationId": "getPats", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/patsSchema", + }, + }, + }, + "description": "patsSchema", + }, + }, + "tags": [ + "admin", + ], + }, + "post": { + "operationId": "createPat", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/patSchema", + }, + }, + }, + "description": "patSchema", + "required": true, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/patSchema", + }, + }, + }, + "description": "patSchema", + }, + }, + "tags": [ + "admin", + ], + }, + }, + "/api/admin/user/tokens/{secret}": { + "delete": { + "operationId": "deletePat", + "parameters": [ + { + "in": "path", + "name": "secret", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "This response has no body.", + }, + }, + "tags": [ + "admin", + ], + }, + }, "/api/client/features": { "get": { "operationId": "getAllClientFeatures", diff --git a/src/test/e2e/api/user/pat.e2e.test.ts b/src/test/e2e/api/user/pat.e2e.test.ts new file mode 100644 index 0000000000..966467386c --- /dev/null +++ b/src/test/e2e/api/user/pat.e2e.test.ts @@ -0,0 +1,107 @@ +import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper'; +import dbInit, { ITestDb } from '../../helpers/database-init'; +import getLogger from '../../../fixtures/no-logger'; +import { IPat } from '../../../../lib/types/models/pat'; + +let app: IUnleashTest; +let db: ITestDb; + +let tomorrow = new Date(); +tomorrow.setDate(tomorrow.getDate() + 1); + +beforeAll(async () => { + db = await dbInit('user_pat', getLogger); + app = await setupAppWithAuth(db.stores); + + await app.request + .post(`/auth/demo/login`) + .send({ + email: 'user@getunleash.io', + }) + .expect(200); +}); + +afterAll(async () => { + await app.destroy(); +}); + +test('should create a PAT', async () => { + const { request } = app; + + const { body } = await request + .post('/api/admin/user/tokens') + .send({ + expiresAt: tomorrow, + } as IPat) + .set('Content-Type', 'application/json') + .expect(201); + + expect(new Date(body.expiresAt)).toEqual(tomorrow); +}); + +test('should delete the PAT', async () => { + const { request } = app; + + const response = await request + .post('/api/admin/user/tokens') + .send({ + expiresAt: tomorrow, + } as IPat) + .set('Content-Type', 'application/json') + .expect(201); + + const createdSecret = response.body.secret; + + await request.delete(`/api/admin/user/tokens/${createdSecret}`).expect(200); +}); + +test('should get all PATs', async () => { + const { request } = app; + + const { body } = await request + .get('/api/admin/user/tokens') + .expect('Content-Type', /json/) + .expect(200); + + expect(body.pats).toHaveLength(1); +}); + +test('should get only current user PATs', async () => { + const { request } = app; + + await app.request + .post(`/auth/demo/login`) + .send({ + email: 'user-second@getunleash.io', + }) + .expect(200); + + await request + .post('/api/admin/user/tokens') + .send({ + expiresAt: tomorrow, + } as IPat) + .set('Content-Type', 'application/json') + .expect(201); + + const { body } = await request + .get('/api/admin/user/tokens') + .expect('Content-Type', /json/) + .expect(200); + + expect(body.pats).toHaveLength(1); +}); + +test('should fail creation of PAT with passed expiry', async () => { + const { request } = app; + + let yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + await request + .post('/api/admin/user/tokens') + .send({ + expiresAt: yesterday, + } as IPat) + .set('Content-Type', 'application/json') + .expect(500); +}); diff --git a/src/test/fixtures/fake-pat-store.ts b/src/test/fixtures/fake-pat-store.ts new file mode 100644 index 0000000000..73280995e0 --- /dev/null +++ b/src/test/fixtures/fake-pat-store.ts @@ -0,0 +1,34 @@ +import { IPatStore } from '../../lib/types/stores/pat-store'; +import { IPat } from '../../lib/types/models/pat'; +/* eslint-disable @typescript-eslint/no-unused-vars */ +export default class FakePatStore implements IPatStore { + create(group: IPat): Promise { + throw new Error('Method not implemented.'); + } + + delete(key: string): Promise { + throw new Error('Method not implemented.'); + } + + deleteAll(): Promise { + throw new Error('Method not implemented.'); + } + + destroy(): void {} + + exists(key: string): Promise { + throw new Error('Method not implemented.'); + } + + get(key: string): Promise { + throw new Error('Method not implemented.'); + } + + getAll(query?: Object): Promise { + throw new Error('Method not implemented.'); + } + + getAllByUser(userId: number): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 9970163b94..e5506cabdf 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 FakePatStore from './fake-pat-store'; import FakePublicSignupStore from './fake-public-signup-store'; const createStores: () => IUnleashStores = () => { @@ -66,6 +67,7 @@ const createStores: () => IUnleashStores = () => { roleStore: new FakeRoleStore(), segmentStore: new FakeSegmentStore(), groupStore: new FakeGroupStore(), + patStore: new FakePatStore(), publicSignupTokenStore: new FakePublicSignupStore(), }; };