diff --git a/packages/api/.env.example b/packages/api/.env.example index 6a9950594..6e90843b6 100644 --- a/packages/api/.env.example +++ b/packages/api/.env.example @@ -24,4 +24,7 @@ GCS_UPLOAD_SA_KEY_FILE_PATH= TWITTER_BEARER_TOKEN= PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper' REMINDER_TASK_HANDLER_URL= -ELASTIC_URL=http://localhost:9200 \ No newline at end of file +ELASTIC_URL=http://localhost:9200 +SENDER_MESSAGE=msgs@sender.domain +SENDER_FEEDBACK=feedback@sender.domain +SENDER_GENERAL=no-reply@sender.domain diff --git a/packages/api/.nycrc b/packages/api/.nycrc index da90d3922..4a0a0dfb4 100644 --- a/packages/api/.nycrc +++ b/packages/api/.nycrc @@ -8,7 +8,7 @@ "reporter": [ "text-summary" ], - "branches": 0, + "branches": 40, "lines": 0, "functions": 0, "statements": 60 diff --git a/packages/api/package.json b/packages/api/package.json index d6445d3a8..ff1b46c8a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -107,6 +107,8 @@ "@types/oauth": "^0.9.1", "@types/private-ip": "^1.0.0", "@types/sanitize-html": "^1.27.1", + "@types/sinon": "^10.0.13", + "@types/sinon-chai": "^3.2.8", "@types/supertest": "^2.0.11", "@types/urlsafe-base64": "^1.0.28", "@types/uuid": "^8.3.0", @@ -120,6 +122,8 @@ "nock": "^13.2.4", "nyc": "^15.1.0", "postgrator": "^4.2.0", + "sinon": "^14.0.0", + "sinon-chai": "^3.7.0", "ts-node-dev": "^1.1.8" }, "engines": { diff --git a/packages/api/src/apollo.ts b/packages/api/src/apollo.ts index c55ee5ecf..075245092 100644 --- a/packages/api/src/apollo.ts +++ b/packages/api/src/apollo.ts @@ -24,7 +24,7 @@ import ScalarResolvers from './scalars' import * as Sentry from '@sentry/node' import { createPubSubClient } from './datalayer/pubsub' import { initModels } from './server' -import { getClaimsByToken } from './utils/auth' +import { getClaimsByToken, setAuthInCookie } from './utils/auth' const signToken = promisify(jwt.sign) const logger = buildLogger('app.dispatch') @@ -76,14 +76,7 @@ const contextFunc: ContextFunction = async ({ setAuth: async ( claims: ClaimsToSet, secret: string = env.server.jwtSecret - ) => { - const token = await signToken(claims, secret) - - res.cookie('auth', token, { - httpOnly: true, - expires: new Date(new Date().getTime() + 365 * 24 * 60 * 60 * 1000), - }) - }, + ) => await setAuthInCookie(claims, res, secret), setClaims, authTrx: ( cb: (tx: Knex.Transaction) => TResult, diff --git a/packages/api/src/datalayer/user/model.ts b/packages/api/src/datalayer/user/model.ts index a538f0dcf..f8b182cf6 100644 --- a/packages/api/src/datalayer/user/model.ts +++ b/packages/api/src/datalayer/user/model.ts @@ -12,7 +12,6 @@ import { exclude, Partialize, PickTuple } from '../../util' // source_user_id | text | | not null | // created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP // updated_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP -// membership | omnivore.membership_tier | | not null | 'WAIT_LIST'::omnivore.membership_tier // Table "omnivore.user_profile" // Column | Type | Collation | Nullable | Default @@ -30,7 +29,6 @@ export interface UserData { id: string name: string source: string - membership: string email?: string | null phone?: string | null sourceUserId: string @@ -44,11 +42,7 @@ export interface UserData { private: boolean } password?: string | null -} - -export enum MembershipTier { - WaitList = 'WAIT_LIST', - Beta = 'BETA', + status?: StatusType } export enum RegistrationType { @@ -57,16 +51,21 @@ export enum RegistrationType { Email = 'EMAIL', } +export enum StatusType { + Active = 'ACTIVE', + Pending = 'PENDING', +} + export const keys = [ 'id', 'name', 'source', - 'membership', 'email', 'phone', 'sourceUserId', 'createdAt', 'password', + 'status', ] as const export const defaultedKeys = ['id', 'createdAt'] as const diff --git a/packages/api/src/entity/user.ts b/packages/api/src/entity/user.ts index 320d26885..58c4a6975 100644 --- a/packages/api/src/entity/user.ts +++ b/packages/api/src/entity/user.ts @@ -7,7 +7,7 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm' -import { MembershipTier, RegistrationType } from '../datalayer/user/model' +import { RegistrationType, StatusType } from '../datalayer/user/model' import { NewsletterEmail } from './newsletter_email' import { Profile } from './profile' import { Label } from './label' @@ -30,9 +30,6 @@ export class User { @Column('text') sourceUserId!: string - @Column({ type: 'enum', enum: MembershipTier }) - membership!: string - @CreateDateColumn() createdAt!: Date @@ -53,4 +50,7 @@ export class User { @OneToMany(() => Subscription, (subscription) => subscription.user) subscriptions?: Subscription[] + + @Column({ type: 'enum', enum: StatusType }) + status!: StatusType } diff --git a/packages/api/src/events/reports/content_display_report_created.ts b/packages/api/src/events/reports/content_display_report_created.ts index e1cc365a7..d8d1ab44e 100644 --- a/packages/api/src/events/reports/content_display_report_created.ts +++ b/packages/api/src/events/reports/content_display_report_created.ts @@ -28,10 +28,10 @@ export class ContentDisplayReportSubscriber if (!env.dev.isLocal) { // If we are in the local environment, just log a message, otherwise email the report await sendEmail({ - to: 'feedback@omnivore.app', + to: env.sender.feedback, subject: 'New content display report', text: message, - from: 'msgs@omnivore.app', + from: env.sender.message, }) } } diff --git a/packages/api/src/events/user/user_created.ts b/packages/api/src/events/user/user_created.ts index 71d49db17..2f1460770 100644 --- a/packages/api/src/events/user/user_created.ts +++ b/packages/api/src/events/user/user_created.ts @@ -86,7 +86,6 @@ export class IdentifySegmentUser implements EntitySubscriberInterface { traits: { name: profile.user.name, email: profile.user.email, - plan: profile.user.membership, source: profile.user.source, env: env.server.apiEnv, }, diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 2beab998c..2310ca181 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -825,11 +825,6 @@ export enum LoginErrorCode { WrongSource = 'WRONG_SOURCE' } -export type LoginInput = { - email: Scalars['String']; - password: Scalars['String']; -}; - export type LoginResult = LoginError | LoginSuccess; export type LoginSuccess = { @@ -893,7 +888,6 @@ export type Mutation = { googleLogin: LoginResult; googleSignup: GoogleSignupResult; logOut: LogOutResult; - login: LoginResult; mergeHighlight: MergeHighlightResult; reportItem: ReportItemResult; revokeApiKey: RevokeApiKeyResult; @@ -911,7 +905,6 @@ export type Mutation = { setShareHighlight: SetShareHighlightResult; setUserPersonalization: SetUserPersonalizationResult; setWebhook: SetWebhookResult; - signup: SignupResult; subscribe: SubscribeResult; unsubscribe: UnsubscribeResult; updateHighlight: UpdateHighlightResult; @@ -1022,11 +1015,6 @@ export type MutationGoogleSignupArgs = { }; -export type MutationLoginArgs = { - input: LoginInput; -}; - - export type MutationMergeHighlightArgs = { input: MergeHighlightInput; }; @@ -1112,11 +1100,6 @@ export type MutationSetWebhookArgs = { }; -export type MutationSignupArgs = { - input: SignupInput; -}; - - export type MutationSubscribeArgs = { name: Scalars['String']; }; @@ -1825,37 +1808,17 @@ export type SharedArticleSuccess = { article: Article; }; -export type SignupError = { - __typename?: 'SignupError'; - errorCodes: Array>; -}; - export enum SignupErrorCode { AccessDenied = 'ACCESS_DENIED', ExpiredToken = 'EXPIRED_TOKEN', GoogleAuthError = 'GOOGLE_AUTH_ERROR', + InvalidEmail = 'INVALID_EMAIL', InvalidPassword = 'INVALID_PASSWORD', InvalidUsername = 'INVALID_USERNAME', Unknown = 'UNKNOWN', UserExists = 'USER_EXISTS' } -export type SignupInput = { - bio?: InputMaybe; - email: Scalars['String']; - name: Scalars['String']; - password: Scalars['String']; - pictureUrl?: InputMaybe; - username: Scalars['String']; -}; - -export type SignupResult = SignupError | SignupSuccess; - -export type SignupSuccess = { - __typename?: 'SignupSuccess'; - me: User; -}; - export enum SortBy { PublishedAt = 'PUBLISHED_AT', SavedAt = 'SAVED_AT', @@ -2572,7 +2535,6 @@ export type ResolversTypes = { LogOutSuccess: ResolverTypeWrapper; LoginError: ResolverTypeWrapper; LoginErrorCode: LoginErrorCode; - LoginInput: LoginInput; LoginResult: ResolversTypes['LoginError'] | ResolversTypes['LoginSuccess']; LoginSuccess: ResolverTypeWrapper; MergeHighlightError: ResolverTypeWrapper; @@ -2676,11 +2638,7 @@ export type ResolversTypes = { SharedArticleErrorCode: SharedArticleErrorCode; SharedArticleResult: ResolversTypes['SharedArticleError'] | ResolversTypes['SharedArticleSuccess']; SharedArticleSuccess: ResolverTypeWrapper; - SignupError: ResolverTypeWrapper; SignupErrorCode: SignupErrorCode; - SignupInput: SignupInput; - SignupResult: ResolversTypes['SignupError'] | ResolversTypes['SignupSuccess']; - SignupSuccess: ResolverTypeWrapper; SortBy: SortBy; SortOrder: SortOrder; SortParams: SortParams; @@ -2900,7 +2858,6 @@ export type ResolversParentTypes = { LogOutResult: ResolversParentTypes['LogOutError'] | ResolversParentTypes['LogOutSuccess']; LogOutSuccess: LogOutSuccess; LoginError: LoginError; - LoginInput: LoginInput; LoginResult: ResolversParentTypes['LoginError'] | ResolversParentTypes['LoginSuccess']; LoginSuccess: LoginSuccess; MergeHighlightError: MergeHighlightError; @@ -2984,10 +2941,6 @@ export type ResolversParentTypes = { SharedArticleError: SharedArticleError; SharedArticleResult: ResolversParentTypes['SharedArticleError'] | ResolversParentTypes['SharedArticleSuccess']; SharedArticleSuccess: SharedArticleSuccess; - SignupError: SignupError; - SignupInput: SignupInput; - SignupResult: ResolversParentTypes['SignupError'] | ResolversParentTypes['SignupSuccess']; - SignupSuccess: SignupSuccess; SortParams: SortParams; String: Scalars['String']; SubscribeError: SubscribeError; @@ -3714,7 +3667,6 @@ export type MutationResolvers>; googleSignup?: Resolver>; logOut?: Resolver; - login?: Resolver>; mergeHighlight?: Resolver>; reportItem?: Resolver>; revokeApiKey?: Resolver>; @@ -3732,7 +3684,6 @@ export type MutationResolvers>; setUserPersonalization?: Resolver>; setWebhook?: Resolver>; - signup?: Resolver>; subscribe?: Resolver>; unsubscribe?: Resolver>; updateHighlight?: Resolver>; @@ -4124,20 +4075,6 @@ export type SharedArticleSuccessResolvers; }; -export type SignupErrorResolvers = { - errorCodes?: Resolver>, ParentType, ContextType>; - __isTypeOf?: IsTypeOfResolverFn; -}; - -export type SignupResultResolvers = { - __resolveType: TypeResolveFn<'SignupError' | 'SignupSuccess', ParentType, ContextType>; -}; - -export type SignupSuccessResolvers = { - me?: Resolver; - __isTypeOf?: IsTypeOfResolverFn; -}; - export type SubscribeErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -4627,9 +4564,6 @@ export type Resolvers = { SharedArticleError?: SharedArticleErrorResolvers; SharedArticleResult?: SharedArticleResultResolvers; SharedArticleSuccess?: SharedArticleSuccessResolvers; - SignupError?: SignupErrorResolvers; - SignupResult?: SignupResultResolvers; - SignupSuccess?: SignupSuccessResolvers; SubscribeError?: SubscribeErrorResolvers; SubscribeResult?: SubscribeResultResolvers; SubscribeSuccess?: SubscribeSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 187b8d072..d3170ed12 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -730,11 +730,6 @@ enum LoginErrorCode { WRONG_SOURCE } -input LoginInput { - email: String! - password: String! -} - union LoginResult = LoginError | LoginSuccess type LoginSuccess { @@ -794,7 +789,6 @@ type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! googleSignup(input: GoogleSignupInput!): GoogleSignupResult! logOut: LogOutResult! - login(input: LoginInput!): LoginResult! mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult! reportItem(input: ReportItemInput!): ReportItemResult! revokeApiKey(id: ID!): RevokeApiKeyResult! @@ -812,7 +806,6 @@ type Mutation { setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult! setUserPersonalization(input: SetUserPersonalizationInput!): SetUserPersonalizationResult! setWebhook(input: SetWebhookInput!): SetWebhookResult! - signup(input: SignupInput!): SignupResult! subscribe(name: String!): SubscribeResult! unsubscribe(name: String!): UnsubscribeResult! updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult! @@ -1347,35 +1340,17 @@ type SharedArticleSuccess { article: Article! } -type SignupError { - errorCodes: [SignupErrorCode]! -} - enum SignupErrorCode { ACCESS_DENIED EXPIRED_TOKEN GOOGLE_AUTH_ERROR + INVALID_EMAIL INVALID_PASSWORD INVALID_USERNAME UNKNOWN USER_EXISTS } -input SignupInput { - bio: String - email: String! - name: String! - password: String! - pictureUrl: String - username: String! -} - -union SignupResult = SignupError | SignupSuccess - -type SignupSuccess { - me: User! -} - enum SortBy { PUBLISHED_AT SAVED_AT diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 0ba6ac410..8ed87340f 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -50,7 +50,6 @@ import { googleLoginResolver, googleSignupResolver, labelsResolver, - loginResolver, logOutResolver, mergeHighlightResolver, newsletterEmailsResolver, @@ -73,7 +72,6 @@ import { setShareHighlightResolver, setUserPersonalizationResolver, setWebhookResolver, - signupResolver, subscribeResolver, subscriptionsResolver, typeaheadSearchResolver, @@ -154,8 +152,6 @@ export const functionResolvers = { createLabel: createLabelResolver, updateLabel: updateLabelResolver, deleteLabel: deleteLabelResolver, - login: loginResolver, - signup: signupResolver, setLabels: setLabelsResolver, generateApiKey: generateApiKeyResolver, unsubscribe: unsubscribeResolver, @@ -572,8 +568,6 @@ export const functionResolvers = { ...resultResolveTypeResolver('Labels'), ...resultResolveTypeResolver('CreateLabel'), ...resultResolveTypeResolver('DeleteLabel'), - ...resultResolveTypeResolver('Login'), - ...resultResolveTypeResolver('Signup'), ...resultResolveTypeResolver('SetLabels'), ...resultResolveTypeResolver('GenerateApiKey'), ...resultResolveTypeResolver('Search'), diff --git a/packages/api/src/resolvers/send_install_instructions/index.ts b/packages/api/src/resolvers/send_install_instructions/index.ts index 4fffe5de6..2f050d4f8 100644 --- a/packages/api/src/resolvers/send_install_instructions/index.ts +++ b/packages/api/src/resolvers/send_install_instructions/index.ts @@ -7,6 +7,7 @@ import { authorized } from '../../utils/helpers' import { sendEmail } from '../../utils/sendEmail' import { AppDataSource } from '../../server' import { User } from '../../entity/user' +import { env } from '../../env' const INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID = 'd-c576bdc3b9a849dab250655ba14c7794' @@ -25,8 +26,10 @@ export const sendInstallInstructionsResolver = authorized< } const sendInstallInstructions = await sendEmail({ - from: 'msgs@omnivore.app', - templateId: INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID, + from: env.sender.message, + templateId: + env.sendgrid.installationTemplateId || + INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID, to: user?.email, }) diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index 7a41f0878..d9a6f3828 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -10,15 +10,12 @@ import { MutationDeleteAccountArgs, MutationGoogleLoginArgs, MutationGoogleSignupArgs, - MutationLoginArgs, - MutationSignupArgs, MutationUpdateUserArgs, MutationUpdateUserProfileArgs, QueryUserArgs, QueryValidateUsernameArgs, ResolverFn, SignupErrorCode, - SignupResult, UpdateUserError, UpdateUserErrorCode, UpdateUserProfileError, @@ -37,7 +34,6 @@ import { env } from '../../env' import { validateUsername } from '../../utils/usernamePolicy' import * as jwt from 'jsonwebtoken' import { createUser } from '../../services/create_user' -import { comparePassword, hashPassword } from '../../utils/auth' import { deletePagesByParam } from '../../elastic/pages' import { setClaims } from '../../entity/utils' import { User as UserEntity } from '../../entity/user' @@ -297,73 +293,6 @@ export function isErrorWithCode(error: unknown): error is ErrorWithCode { ) } -export const loginResolver: ResolverFn< - LoginResult, - unknown, - WithDataSourcesContext, - MutationLoginArgs -> = async (_obj, { input }, { models, setAuth }) => { - const { email, password } = input - - const user = await models.user.getWhere({ - email, - }) - if (!user?.id) { - return { errorCodes: [LoginErrorCode.UserNotFound] } - } - - if (!user?.password) { - // user has no password, so they need to set one - return { errorCodes: [LoginErrorCode.WrongSource] } - } - - // check if password is correct - const validPassword = await comparePassword(password, user.password) - if (!validPassword) { - return { errorCodes: [LoginErrorCode.InvalidCredentials] } - } - - // set auth cookie in response header - await setAuth({ uid: user.id }) - return { me: userDataToUser(user) } -} - -export const signupResolver: ResolverFn< - SignupResult, - Record, - WithDataSourcesContext, - MutationSignupArgs -> = async (_obj, { input }) => { - const { email, username, name, bio, password, pictureUrl } = input - const lowerCasedUsername = username.toLowerCase() - - try { - // hash password - const hashedPassword = await hashPassword(password) - - const [user, profile] = await createUser({ - email, - provider: 'EMAIL', - sourceUserId: email, - name, - username: lowerCasedUsername, - pictureUrl: pictureUrl || undefined, - bio: bio || undefined, - password: hashedPassword, - }) - - return { - me: userDataToUser({ ...user, profile: { ...profile, private: false } }), - } - } catch (err) { - console.log('error', err) - if (isErrorWithCode(err)) { - return { errorCodes: [err.errorCode as SignupErrorCode] } - } - return { errorCodes: [SignupErrorCode.Unknown] } - } -} - export const deleteAccountResolver = authorized< DeleteAccountSuccess, DeleteAccountError, diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index d9289111e..c6e7ce3d3 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -33,10 +33,25 @@ import { corsConfig } from '../../utils/corsConfig' import cors from 'cors' import { - MembershipTier, RegistrationType, + StatusType, UserData, } from '../../datalayer/user/model' +import { + comparePassword, + getClaimsByToken, + hashPassword, + setAuthInCookie, +} from '../../utils/auth' +import { createUser } from '../../services/create_user' +import { isErrorWithCode } from '../../resolvers' +import { initModels } from '../../server' +import { getRepository } from '../../entity/utils' +import { User } from '../../entity/user' +import { + sendConfirmationEmail, + sendPasswordResetEmail, +} from '../../services/send_emails' const logger = buildLogger('app.dispatch') const signToken = promisify(jwt.sign) @@ -286,13 +301,13 @@ export function authRouter() { res.setHeader('set-cookie', result.headers['set-cookie']) - handleSuccessfulLogin(req, res, user, data.googleLogin.newUser) + await handleSuccessfulLogin(req, res, user, data.googleLogin.newUser) }) async function handleSuccessfulLogin( req: express.Request, res: express.Response, - user: UserData, + user: UserData | User, newUser: boolean ): Promise { try { @@ -311,6 +326,13 @@ export function authRouter() { } } + const message = res.get('Message') + if (message) { + return res.redirect( + `${env.client.url}/home?message=${encodeURIComponent(message)}` + ) + } + if (newUser) { if (redirectUri && redirectUri !== '/') { return res.redirect( @@ -322,10 +344,6 @@ export function authRouter() { ) } - if (user.membership === MembershipTier.WaitList) { - return res.redirect(`${env.client.url}/waitlist`) - } - return res.redirect( url.resolve(env.client.url, decodeURIComponent(redirectUri || 'home')) ) @@ -356,56 +374,53 @@ export function authRouter() { cors(corsConfig), async (req: express.Request, res: express.Response) => { const { email, password } = req.body + if (!email || !password) { - res.redirect(`${env.client.url}/email-login?errorCodes=AUTH_FAILED`) - return + return res.redirect( + `${env.client.url}/email-login?errorCodes=${LoginErrorCode.InvalidCredentials}` + ) } - const query = ` - mutation login{ - login(input: { - email: "${email}", - password: "${password}" - }) { - __typename - ... on LoginError { errorCodes } - ... on LoginSuccess { - me { - id - name - profile { - username - } - } - } - } - }` - try { - const result = await axios.post(env.server.gateway_url + '/graphql', { - query, + const models = initModels(kx, false) + const user = await models.user.getWhere({ + email, }) - const { data } = result.data - - if (data.login.__typename === 'LoginError') { - const errorCodes = data.login.errorCodes.join(',') + if (!user?.id) { return res.redirect( - `${env.client.url}/email-login?errorCodes=${errorCodes}` + `${env.client.url}/email-login?errorCodes=${LoginErrorCode.UserNotFound}` ) } - if (!result.headers['set-cookie']) { + if (user.status === StatusType.Pending && user.email) { + await sendConfirmationEmail({ + id: user.id, + email: user.email, + name: user.name, + }) return res.redirect( - `${env.client.url}/${ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (req.params as any)?.action - }?errorCodes=unknown` + `${env.client.url}/email-login?errorCodes=PENDING_VERIFICATION` ) } - res.setHeader('set-cookie', result.headers['set-cookie']) + if (!user?.password) { + // user has no password, so they need to set one + return res.redirect( + `${env.client.url}/email-login?errorCodes=${LoginErrorCode.WrongSource}` + ) + } - await handleSuccessfulLogin(req, res, data.login.me, false) + // check if password is correct + const validPassword = await comparePassword(password, user.password) + if (!validPassword) { + return res.redirect( + `${env.client.url}/email-login?errorCodes=${LoginErrorCode.InvalidCredentials}` + ) + } + + // set auth cookie in response header + await setAuthInCookie({ uid: user.id }, res) + await handleSuccessfulLogin(req, res, user, false) } catch (e) { logger.info('email-login exception:', e) res.redirect(`${env.client.url}/email-login?errorCodes=AUTH_FAILED`) @@ -422,57 +437,206 @@ export function authRouter() { '/email-signup', cors(corsConfig), async (req: express.Request, res: express.Response) => { - const { email, password, name, username, bio } = req.body - if (!email || !password || !name || !username) { - res.redirect(`${env.client.url}/email-signup?errorCodes=BAD_DATA`) - return - } + const { email, password, name, username, bio, pictureUrl } = req.body - const query = ` - mutation signup { - signup(input: { - email: "${email}", - password: "${password}", - name: "${name}", - username: "${username}", - bio: "${bio ?? ''}" - }) { - __typename - ... on SignupSuccess { - me { - id - name - profile { - username - } - } - } - ... on SignupError { - errorCodes - } - } - }` + if (!email || !password || !name || !username) { + return res.redirect( + `${env.client.url}/email-signup?errorCodes=INVALID_CREDENTIALS` + ) + } + const lowerCasedUsername = username.toLowerCase() try { - const result = await axios.post(env.server.gateway_url + '/graphql', { - query, - }) - const { data } = result.data + // hash password + const hashedPassword = await hashPassword(password) - if (data.signup.__typename === 'SignupError') { - const errorCodes = data.signup.errorCodes.join(',') - return res.redirect( - `${env.client.url}/email-signup?errorCodes=${errorCodes}` - ) - } + await createUser({ + email, + provider: 'EMAIL', + sourceUserId: email, + name, + username: lowerCasedUsername, + pictureUrl, + bio, + password: hashedPassword, + pendingConfirmation: true, + }) res.redirect(`${env.client.url}/email-login?message=SIGNUP_SUCCESS`) } catch (e) { logger.info('email-signup exception:', e) + if (isErrorWithCode(e)) { + return res.redirect( + `${env.client.url}/email-signup?errorCodes=${e.errorCode}` + ) + } res.redirect(`${env.client.url}/email-signup?errorCodes=UNKNOWN`) } } ) + router.options( + '/confirm-email', + cors({ ...corsConfig, maxAge: 600 }) + ) + + router.post( + '/confirm-email', + cors(corsConfig), + async (req: express.Request, res: express.Response) => { + const token = req.body.token + + try { + // verify token + const claims = await getClaimsByToken(token) + if (!claims) { + return res.redirect( + `${env.client.url}/confirm-email?errorCodes=INVALID_TOKEN` + ) + } + + const user = await getRepository(User).findOneBy({ id: claims.uid }) + if (!user) { + return res.redirect( + `${env.client.url}/confirm-email?errorCodes=USER_NOT_FOUND` + ) + } + + if (user.status === StatusType.Pending) { + await getRepository(User).update( + { id: user.id }, + { status: StatusType.Active } + ) + } + + res.set('Message', 'EMAIL_CONFIRMED') + await setAuthInCookie({ uid: user.id }, res) + await handleSuccessfulLogin(req, res, user, false) + } catch (e) { + logger.info('confirm-email exception:', e) + if (e instanceof jwt.TokenExpiredError) { + return res.redirect( + `${env.client.url}/confirm-email?errorCodes=TOKEN_EXPIRED` + ) + } + + res.redirect(`${env.client.url}/confirm-email?errorCodes=INVALID_TOKEN`) + } + } + ) + + router.options( + '/forgot-password', + cors({ ...corsConfig, maxAge: 600 }) + ) + + router.post( + '/forgot-password', + cors(corsConfig), + async (req: express.Request, res: express.Response) => { + const email = req.body.email + if (!email) { + return res.redirect( + `${env.client.url}/forgot-password?errorCodes=INVALID_EMAIL` + ) + } + + try { + const user = await getRepository(User).findOneBy({ + email, + }) + if (!user) { + return res.redirect( + `${env.client.url}/forgot-password?errorCodes=USER_NOT_FOUND` + ) + } + + if (user.status === StatusType.Pending) { + return res.redirect( + `${env.client.url}/email-login?errorCodes=PENDING_VERIFICATION` + ) + } + + if (!(await sendPasswordResetEmail(user))) { + return res.redirect( + `${env.client.url}/forgot-password?errorCodes=INVALID_EMAIL` + ) + } + + res.redirect(`${env.client.url}/forgot-password?message=SUCCESS`) + } catch (e) { + logger.info('forgot-password exception:', e) + + res.redirect(`${env.client.url}/forgot-password?errorCodes=UNKNOWN`) + } + } + ) + + router.options( + '/reset-password', + cors({ ...corsConfig, maxAge: 600 }) + ) + + router.post( + '/reset-password', + cors(corsConfig), + async (req: express.Request, res: express.Response) => { + const { token, password } = req.body + + try { + // verify token + const claims = await getClaimsByToken(token) + if (!claims) { + return res.redirect( + `${env.client.url}/reset-password?errorCodes=INVALID_TOKEN` + ) + } + + if (!password) { + return res.redirect( + `${env.client.url}/reset-password?errorCodes=INVALID_PASSWORD` + ) + } + + const user = await getRepository(User).findOneBy({ id: claims.uid }) + if (!user) { + return res.redirect( + `${env.client.url}/reset-password?errorCodes=USER_NOT_FOUND` + ) + } + + if (user.status === StatusType.Pending) { + return res.redirect( + `${env.client.url}/email-login?errorCodes=PENDING_VERIFICATION` + ) + } + + const hashedPassword = await hashPassword(password) + const updated = await getRepository(User).update( + { id: user.id }, + { password: hashedPassword } + ) + if (!updated.affected) { + return res.redirect( + `${env.client.url}/reset-password?errorCodes=UNKNOWN` + ) + } + + res.redirect(`${env.client.url}/reset-password?message=SUCCESS`) + } catch (e) { + logger.info('reset-password exception:', e) + if (e instanceof jwt.TokenExpiredError) { + return res.redirect( + `${env.client.url}/reset-password?errorCodes=TOKEN_EXPIRED` + ) + } + + res.redirect( + `${env.client.url}/reset-password?errorCodes=INVALID_TOKEN` + ) + } + } + ) + return router } diff --git a/packages/api/src/routers/auth/mobile/account_creation.ts b/packages/api/src/routers/auth/mobile/account_creation.ts index 5957dfebf..b19607e1d 100644 --- a/packages/api/src/routers/auth/mobile/account_creation.ts +++ b/packages/api/src/routers/auth/mobile/account_creation.ts @@ -1,11 +1,10 @@ import { JsonResponsePayload, UserProfile } from '../auth_types' import { - decodePendingUserToken, createMobileAuthPayload, + decodePendingUserToken, } from './../jwt_helpers' import { createUser } from '../../../services/create_user' import { SignupErrorCode } from '../../../generated/graphql' -import { MembershipTier } from '../../../datalayer/user/model' export async function createMobileAccountCreationResponse( pendingUserToken?: string, @@ -34,7 +33,6 @@ export async function createMobileAccountCreationResponse( username: userProfile.username, pictureUrl: undefined, bio: userProfile.bio || undefined, - membershipTier: MembershipTier.Beta, }) const mobileAuthPayload = await createMobileAuthPayload(user.id) diff --git a/packages/api/src/routers/svc/emails.ts b/packages/api/src/routers/svc/emails.ts index 03f5f8fba..01498f83a 100644 --- a/packages/api/src/routers/svc/emails.ts +++ b/packages/api/src/routers/svc/emails.ts @@ -89,7 +89,7 @@ export function emailsServiceRouter() { // forward non-newsletter emails to the registered email address const result = await sendEmail({ - from: 'msgs@omnivore.app', + from: env.sender.message, to: newsletterEmail.user.email, subject: `Fwd: ${data.subject}`, html: data.html, diff --git a/packages/api/src/routers/svc/reminders.ts b/packages/api/src/routers/svc/reminders.ts index 76825d175..9536f28ff 100644 --- a/packages/api/src/routers/svc/reminders.ts +++ b/packages/api/src/routers/svc/reminders.ts @@ -102,9 +102,9 @@ export function remindersServiceRouter() { console.log('dynamic template data:', dynamicTemplateData) await sendEmail({ - from: 'msgs@omnivore.app', + from: env.sender.message, dynamicTemplateData: dynamicTemplateData, - templateId: process.env.SENDGRID_REMINDER_TEMPLATE_ID, + templateId: env.sendgrid.reminderTemplateId, to: user.email, }) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 9dea9b1ec..336056564 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -168,6 +168,7 @@ const schema = gql` USER_EXISTS EXPIRED_TOKEN INVALID_PASSWORD + INVALID_EMAIL } type GoogleSignupError { @@ -1409,30 +1410,6 @@ const schema = gql` union UpdateLabelResult = UpdateLabelSuccess | UpdateLabelError - input LoginInput { - password: String! - email: String! - } - - input SignupInput { - email: String! - password: String! @sanitize(maxLength: 40) - username: String! - name: String! - pictureUrl: String - bio: String - } - - type SignupSuccess { - me: User! - } - - type SignupError { - errorCodes: [SignupErrorCode]! - } - - union SignupResult = SignupSuccess | SignupError - input SetLabelsInput { pageId: ID! labelIds: [ID!]! @@ -1843,8 +1820,6 @@ const schema = gql` createLabel(input: CreateLabelInput!): CreateLabelResult! updateLabel(input: UpdateLabelInput!): UpdateLabelResult! deleteLabel(id: ID!): DeleteLabelResult! - login(input: LoginInput!): LoginResult! - signup(input: SignupInput!): SignupResult! setLabels(input: SetLabelsInput!): SetLabelsResult! generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult! unsubscribe(name: String!): UnsubscribeResult! diff --git a/packages/api/src/services/create_user.ts b/packages/api/src/services/create_user.ts index bd94ead22..2d6267b6c 100644 --- a/packages/api/src/services/create_user.ts +++ b/packages/api/src/services/create_user.ts @@ -1,5 +1,5 @@ import { AuthProvider } from '../routers/auth/auth_types' -import { MembershipTier } from '../datalayer/user/model' +import { StatusType } from '../datalayer/user/model' import { EntityManager } from 'typeorm' import { User } from '../entity/user' import { Profile } from '../entity/profile' @@ -9,6 +9,7 @@ import { Invite } from '../entity/groups/invite' import { GroupMembership } from '../entity/groups/group_membership' import { AppDataSource } from '../server' import { getRepository } from '../entity/utils' +import { sendConfirmationEmail } from './send_emails' export const createUser = async (input: { provider: AuthProvider @@ -19,9 +20,9 @@ export const createUser = async (input: { pictureUrl?: string bio?: string groups?: [string] - membershipTier?: MembershipTier inviteCode?: string password?: string + pendingConfirmation?: boolean }): Promise<[User, Profile]> => { const existingUser = await getUser(input.email) if (existingUser) { @@ -61,13 +62,13 @@ export const createUser = async (input: { } const user = await t.getRepository(User).save({ source: input.provider, - membership: - input.membershipTier || - (hasInvite ? MembershipTier.Beta : MembershipTier.WaitList), name: input.name, email: input.email, sourceUserId: input.sourceUserId, password: input.password, + status: input.pendingConfirmation + ? StatusType.Pending + : StatusType.Active, }) const profile = await t.getRepository(Profile).save({ username: input.username, @@ -86,6 +87,12 @@ export const createUser = async (input: { } ) + if (input.pendingConfirmation) { + if (!(await sendConfirmationEmail(user))) { + return Promise.reject({ errorCode: SignupErrorCode.InvalidEmail }) + } + } + return [user, profile] } diff --git a/packages/api/src/services/send_emails.ts b/packages/api/src/services/send_emails.ts new file mode 100644 index 000000000..a6fbf6d42 --- /dev/null +++ b/packages/api/src/services/send_emails.ts @@ -0,0 +1,47 @@ +import { generateVerificationToken } from '../utils/auth' +import { env } from '../env' +import { sendEmail } from '../utils/sendEmail' + +export const sendConfirmationEmail = async (user: { + id: string + name: string + email: string +}): Promise => { + // generate confirmation link + const token = generateVerificationToken(user.id) + const link = `${env.client.url}/confirm-email/${token}` + // send email + const dynamicTemplateData = { + name: user.name, + link, + } + + return sendEmail({ + from: env.sender.message, + to: user.email, + templateId: env.sendgrid.confirmationTemplateId, + dynamicTemplateData, + }) +} + +export const sendPasswordResetEmail = async (user: { + id: string + name: string + email: string +}): Promise => { + // generate link + const token = generateVerificationToken(user.id) + const link = `${env.client.url}/reset-password/${token}` + // send email + const dynamicTemplateData = { + name: user.name, + link, + } + + return sendEmail({ + from: env.sender.message, + to: user.email, + templateId: env.sendgrid.resetPasswordTemplateId, + dynamicTemplateData, + }) +} diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts index bda3ae1ae..d1c6d9fd4 100755 --- a/packages/api/src/util.ts +++ b/packages/api/src/util.ts @@ -73,6 +73,17 @@ interface BackendEnv { username: string password: string } + sender: { + message: string + feedback: string + general: string + } + sendgrid: { + confirmationTemplateId: string + reminderTemplateId: string + resetPasswordTemplateId: string + installationTemplateId: string + } } /*** @@ -114,6 +125,13 @@ const nullableEnvVars = [ 'ELASTIC_USERNAME', 'ELASTIC_PASSWORD', 'GCS_UPLOAD_PRIVATE_BUCKET', + 'SENDER_MESSAGE', + 'SENDER_FEEDBACK', + 'SENDER_GENERAL', + 'SENDGRID_CONFIRMATION_TEMPLATE_ID', + 'SENDGRID_REMINDER_TEMPLATE_ID', + 'SENDGRID_RESET_PASSWORD_TEMPLATE_ID', + 'SENDGRID_INSTALLATION_TEMPLATE_ID', ] // Allow some vars to be null/empty /* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */ @@ -214,6 +232,18 @@ export function getEnv(): BackendEnv { username: parse('ELASTIC_USERNAME'), password: parse('ELASTIC_PASSWORD'), } + const sender = { + message: parse('SENDER_MESSAGE'), + feedback: parse('SENDER_FEEDBACK'), + general: parse('SENDER_GENERAL'), + } + + const sendgrid = { + confirmationTemplateId: parse('SENDGRID_CONFIRMATION_TEMPLATE_ID'), + reminderTemplateId: parse('SENDGRID_REMINDER_TEMPLATE_ID'), + resetPasswordTemplateId: parse('SENDGRID_RESET_PASSWORD_TEMPLATE_ID'), + installationTemplateId: parse('SENDGRID_INSTALLATION_TEMPLATE_ID'), + } return { pg, @@ -230,6 +260,8 @@ export function getEnv(): BackendEnv { fileUpload, queue, elastic, + sender, + sendgrid, } } diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts index 12b34cce3..8885d279e 100644 --- a/packages/api/src/utils/auth.ts +++ b/packages/api/src/utils/auth.ts @@ -1,11 +1,15 @@ import * as bcrypt from 'bcryptjs' import { v4 as uuidv4 } from 'uuid' -import { Claims } from '../resolvers/types' +import { Claims, ClaimsToSet } from '../resolvers/types' import { getRepository } from '../entity/utils' import { ApiKey } from '../entity/api_key' import crypto from 'crypto' import * as jwt from 'jsonwebtoken' import { env } from '../env' +import express from 'express' +import { promisify } from 'util' + +const signToken = promisify(jwt.sign) export const hashPassword = async (password: string, salt = 10) => { return bcrypt.hash(password, salt) @@ -68,14 +72,43 @@ export const getClaimsByToken = async ( try { jwt.verify(token, env.server.jwtSecret) && (claims = jwt.decode(token) as Claims) - } catch (e) { - if (e instanceof jwt.JsonWebTokenError) { - console.log(`not a jwt token, checking api key`, { token }) - claims = await claimsFromApiKey(token) - } else { - throw e - } - } - return claims + return claims + } catch (e) { + if ( + e instanceof jwt.JsonWebTokenError && + !(e instanceof jwt.TokenExpiredError) + ) { + console.log(`not a jwt token, checking api key`, { token }) + return claimsFromApiKey(token) + } + + throw e + } +} + +export const generateVerificationToken = ( + userId: string, + expireInDays = 1 +): string => { + const iat = Math.floor(Date.now() / 1000) + const exp = Math.floor( + new Date(Date.now() + 1000 * 60 * 60 * 24 * expireInDays).getTime() / 1000 + ) + + return jwt.sign({ uid: userId, iat, exp }, env.server.jwtSecret) +} + +export const setAuthInCookie = async ( + claims: ClaimsToSet, + res: express.Response, + secret: string = env.server.jwtSecret +) => { + // set auth cookie in response header + const token = await signToken(claims, secret) + + res.cookie('auth', token, { + httpOnly: true, + expires: new Date(new Date().getTime() + 365 * 24 * 60 * 60 * 1000), + }) } diff --git a/packages/api/src/utils/helpers.ts b/packages/api/src/utils/helpers.ts index c4db77d44..70cdb1154 100644 --- a/packages/api/src/utils/helpers.ts +++ b/packages/api/src/utils/helpers.ts @@ -7,11 +7,7 @@ import { ResolverFn, } from '../generated/graphql' import { Claims, WithDataSourcesContext } from '../resolvers/types' -import { - MembershipTier, - RegistrationType, - UserData, -} from '../datalayer/user/model' +import { RegistrationType, UserData } from '../datalayer/user/model' import crypto from 'crypto' import slugify from 'voca/slugify' import { Merge } from '../util' @@ -128,7 +124,6 @@ export const userDataToUser = ( id: string name: string source: RegistrationType - membership: MembershipTier email?: string | null phone?: string | null picture?: string | null @@ -149,11 +144,10 @@ export const userDataToUser = ( ...user, name: user.name, source: user.source as RegistrationType, - membership: user.membership as MembershipTier, createdAt: user.createdAt || new Date(), friendsCount: user.friendsCount || 0, followersCount: user.followersCount || 0, - isFullUser: isFullUser(user.membership as MembershipTier), + isFullUser: true, viewerIsFollowing: user.viewerIsFollowing || user.isFriend || false, picture: user.profile.picture_url, sharedArticles: [], @@ -166,10 +160,6 @@ export const userDataToUser = ( }, }) -export const isFullUser = (membership: MembershipTier): boolean => { - return membership != MembershipTier.WaitList -} - export const generateSlug = (title: string): string => { return slugify(title).substring(0, 64) + '-' + Date.now().toString(16) } diff --git a/packages/api/src/utils/sendEmail.ts b/packages/api/src/utils/sendEmail.ts index 9ef0d1b4d..cb75de464 100644 --- a/packages/api/src/utils/sendEmail.ts +++ b/packages/api/src/utils/sendEmail.ts @@ -30,14 +30,18 @@ export const sendEmail = async (msg: MailDataRequired): Promise => { console.log('sending email', msg) - await client.send(msg).catch((error) => { + try { + const response = await client.send(msg) + console.log('email sent', response) + + return true + } catch (error) { console.log('error sending email', error) const err = asSendGridError(error) if (err) { console.log('sendgrid error:', JSON.stringify(err.response?.body)) } - return false - }) - return true + return false + } } diff --git a/packages/api/test/db.ts b/packages/api/test/db.ts index 4d9928c82..4af92259b 100644 --- a/packages/api/test/db.ts +++ b/packages/api/test/db.ts @@ -71,7 +71,8 @@ export const deleteTestUser = async (name: string) => { export const createTestUser = async ( name: string, invite?: string | undefined, - password?: string + password?: string, + pendingConfirmation?: boolean ): Promise => { const [newUser] = await createUser({ provider: 'GOOGLE', @@ -82,6 +83,7 @@ export const createTestUser = async ( name: name, inviteCode: invite, password: password, + pendingConfirmation, }) return newUser diff --git a/packages/api/test/resolvers/article_saving_request.test.ts b/packages/api/test/resolvers/article_saving_request.test.ts index cc8aabc1e..aa398831d 100644 --- a/packages/api/test/resolvers/article_saving_request.test.ts +++ b/packages/api/test/resolvers/article_saving_request.test.ts @@ -7,12 +7,12 @@ import { createTestUser, deleteTestUser } from '../db' import { graphqlRequest, request } from '../util' import { createPubSubClient } from '../../src/datalayer/pubsub' import { expect } from 'chai' -import { describe } from 'mocha' import { getPageById } from '../../src/elastic/pages' import { ArticleSavingRequestErrorCode, CreateArticleSavingRequestErrorCode, } from '../../src/generated/graphql' +import 'mocha' const articleSavingRequestQuery = (id: string) => ` query { diff --git a/packages/api/test/resolvers/user.test.ts b/packages/api/test/resolvers/user.test.ts index 7115ca7ed..58ebd8cb6 100644 --- a/packages/api/test/resolvers/user.test.ts +++ b/packages/api/test/resolvers/user.test.ts @@ -2,8 +2,6 @@ import { createTestUser, deleteTestUser, getProfile, getUser } from '../db' import { graphqlRequest, request } from '../util' import { expect } from 'chai' import { - LoginErrorCode, - SignupErrorCode, UpdateUserErrorCode, UpdateUserProfileErrorCode, } from '../../src/generated/graphql' @@ -239,184 +237,4 @@ describe('User API', () => { return graphqlRequest(query, invalidAuthToken).expect(500) }) }) - - describe('login', () => { - let query: string - let email: string - let password: string - - beforeEach(() => { - query = ` - mutation { - login( - input: { - email: "${email}" - password: "${password}" - } - ) { - ... on LoginSuccess { - me { - id - name - profile { - username - } - } - } - ... on LoginError { - errorCodes - } - } - } - ` - }) - - context('when email and password are valid', () => { - before(() => { - email = user.email - password = correctPassword - }) - - it('responds with 200', async () => { - const res = await graphqlRequest(query).expect(200) - expect(res.body.data.login.me.id).to.eql(user.id) - }) - }) - - context('when user not exists', () => { - before(() => { - email = 'Some email' - }) - - it('responds with error code UserNotFound', async () => { - const response = await graphqlRequest(query).expect(200) - expect(response.body.data.login.errorCodes).to.eql([ - LoginErrorCode.UserNotFound, - ]) - }) - }) - - context('when user has no password stored in db', () => { - before(() => { - email = anotherUser.email - password = 'Some password' - }) - - it('responds with error code WrongSource', async () => { - const response = await graphqlRequest(query).expect(200) - expect(response.body.data.login.errorCodes).to.eql([ - LoginErrorCode.WrongSource, - ]) - }) - }) - - context('when password is wrong', () => { - before(() => { - email = user.email - password = 'Some password' - }) - - it('responds with error code UserNotFound', async () => { - const response = await graphqlRequest(query).expect(200) - expect(response.body.data.login.errorCodes).to.eql([ - LoginErrorCode.InvalidCredentials, - ]) - }) - }) - }) - - describe('signup', () => { - let query: string - let email: string - let password: string - let username: string - - beforeEach(() => { - query = ` - mutation { - signup( - input: { - email: "${email}" - password: "${password}" - name: "Some name" - username: "${username}" - } - ) { - ... on SignupSuccess { - me { - id - name - profile { - username - } - } - } - ... on SignupError { - errorCodes - } - } - } - ` - }) - - context('when inputs are valid and user not exists', () => { - before(() => { - password = correctPassword - username = 'Some_username' - email = `${username}@fake.com` - }) - - after(async () => { - await deleteTestUser(username) - }) - - it('responds with 200', async () => { - const res = await graphqlRequest(query).expect(200) - const user = await getUser(res.body.data.signup.me.id) - expect(user).to.exist - }) - }) - - context('when password is too long', () => { - before(() => { - email = 'Some_email' - password = 'Some_password_that_is_too_long_for_database' - username = 'Some_username' - }) - - it('responds with status code 400', async () => { - return graphqlRequest(query).expect(400) - }) - }) - - context('when user exists', () => { - before(() => { - email = user.email - password = 'Some password' - username = 'Some username' - }) - - it('responds with error code UserExists', async () => { - const response = await graphqlRequest(query).expect(200) - expect(response.body.data.signup.errorCodes).to.eql([ - SignupErrorCode.UserExists, - ]) - }) - }) - - context('when username is invalid', () => { - before(() => { - email = 'Some_email' - password = correctPassword - username = 'omnivore_admin' - }) - - it('responds with error code InvalidUsername', async () => { - const response = await graphqlRequest(query).expect(200) - expect(response.body.data.signup.errorCodes).to.eql([ - SignupErrorCode.InvalidUsername, - ]) - }) - }) - }) }) diff --git a/packages/api/test/routers/article_router.test.ts b/packages/api/test/routers/article.test.ts similarity index 100% rename from packages/api/test/routers/article_router.test.ts rename to packages/api/test/routers/article.test.ts diff --git a/packages/api/test/routers/auth.test.ts b/packages/api/test/routers/auth.test.ts new file mode 100644 index 000000000..526ccce02 --- /dev/null +++ b/packages/api/test/routers/auth.test.ts @@ -0,0 +1,554 @@ +import { createTestUser, deleteTestUser } from '../db' +import { generateFakeUuid, request } from '../util' +import { expect } from 'chai' +import { StatusType } from '../../src/datalayer/user/model' +import { getRepository } from '../../src/entity/utils' +import { User } from '../../src/entity/user' +import { MailDataRequired } from '@sendgrid/helpers/classes/mail' +import sinon from 'sinon' +import * as util from '../../src/utils/sendEmail' +import supertest from 'supertest' +import { + comparePassword, + generateVerificationToken, + hashPassword, +} from '../../src/utils/auth' + +describe('auth router', () => { + const route = '/api/auth' + + describe('email signup', () => { + const signupRequest = ( + email: string, + password: string, + name: string, + username: string + ): supertest.Test => { + return request.post(`${route}/email-signup`).send({ + email, + password, + name, + username, + }) + } + const validPassword = 'validPassword' + + let email: string + let password: string + let username: string + let name: string + + context('when inputs are valid and user not exists', () => { + let fake: (msg: MailDataRequired) => Promise + + before(() => { + password = validPassword + username = 'Some_username' + email = `${username}@fake.com` + name = 'Some name' + }) + + afterEach(async () => { + await deleteTestUser(username) + }) + + context('when confirmation email sent', () => { + beforeEach(() => { + fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true)) + }) + + afterEach(() => { + sinon.restore() + }) + + it('redirects to login page', async () => { + const res = await signupRequest( + email, + password, + name, + username + ).expect(302) + expect(res.header.location).to.endWith( + '/email-login?message=SIGNUP_SUCCESS' + ) + }) + + it('creates the user with pending status and correct name', async () => { + await signupRequest(email, password, name, username).expect(302) + const user = await getRepository(User).findOneBy({ name }) + + expect(user?.status).to.eql(StatusType.Pending) + expect(user?.name).to.eql(name) + }) + }) + + context('when confirmation email not sent', () => { + before(() => { + fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(false)) + }) + + after(() => { + sinon.restore() + }) + + it('redirects to sign up page with error code INVALID_EMAIL', async () => { + const res = await signupRequest( + email, + password, + name, + username + ).expect(302) + expect(res.header.location).to.endWith( + '/email-signup?errorCodes=INVALID_EMAIL' + ) + }) + }) + }) + + context('when user exists', () => { + before(async () => { + username = 'Some_username' + const user = await createTestUser(username) + email = user.email + password = 'Some password' + }) + + after(async () => { + await deleteTestUser(username) + }) + + it('redirects to sign up page with error code USER_EXISTS', async () => { + const res = await signupRequest(email, password, name, username).expect( + 302 + ) + expect(res.header.location).to.endWith( + '/email-signup?errorCodes=USER_EXISTS' + ) + }) + }) + + context('when username is invalid', () => { + before(() => { + email = 'Some_email' + password = validPassword + username = 'omnivore_admin' + }) + + it('redirects to sign up page with error code INVALID_USERNAME', async () => { + const res = await signupRequest(email, password, name, username).expect( + 302 + ) + expect(res.header.location).to.endWith( + '/email-signup?errorCodes=INVALID_USERNAME' + ) + }) + }) + }) + + describe('login', () => { + const loginRequest = (email: string, password: string): supertest.Test => { + return request.post(`${route}/email-login`).send({ + email, + password, + }) + } + const correctPassword = 'correctPassword' + + let user: User + let email: string + let password: string + + before(async () => { + const hashedPassword = await hashPassword(correctPassword) + user = await createTestUser('login_test_user', undefined, hashedPassword) + }) + + after(async () => { + await deleteTestUser(user.name) + }) + + context('when email and password are valid', () => { + before(() => { + email = user.email + password = correctPassword + }) + + it('redirects to home page', async () => { + const res = await loginRequest(email, password).expect(302) + expect(res.header.location).to.endWith('/home') + }) + + it('set auth token in cookie', async () => { + const res = await loginRequest(email, password).expect(302) + expect(res.header['set-cookie']).to.be.an('array') + expect(res.header['set-cookie'][0]).to.contain('auth') + }) + }) + + context('when user is not confirmed', async () => { + let fake: (msg: MailDataRequired) => Promise + + beforeEach(async () => { + fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true)) + await getRepository(User).update(user.id, { + status: StatusType.Pending, + }) + email = user.email + password = correctPassword + }) + + afterEach(async () => { + await getRepository(User).update(user.id, { + status: StatusType.Active, + }) + sinon.restore() + }) + + it('redirects with error code PendingVerification', async () => { + const res = await loginRequest(email, password).expect(302) + expect(res.header.location).to.endWith( + '/email-login?errorCodes=PENDING_VERIFICATION' + ) + }) + + it('sends a verification email', async () => { + await loginRequest(email, password).expect(302) + expect(fake).to.have.been.calledOnce + }) + }) + + context('when user not exists', () => { + before(() => { + email = 'Some email' + }) + + it('redirects with error code UserNotFound', async () => { + const res = await loginRequest(email, password).expect(302) + expect(res.header.location).to.endWith( + '/email-login?errorCodes=USER_NOT_FOUND' + ) + }) + }) + + context('when user has no password stored in db', async () => { + before(async () => { + await getRepository(User).update(user.id, { + password: '', + }) + email = user.email + password = user.password! + }) + + after(async () => { + await getRepository(User).update(user.id, { + password, + }) + }) + + it('redirects with error code WrongSource', async () => { + const res = await loginRequest(email, password).expect(302) + expect(res.header.location).to.endWith( + '/email-login?errorCodes=WRONG_SOURCE' + ) + }) + }) + + context('when password is wrong', () => { + before(() => { + email = user.email + password = 'Wrong password' + }) + + it('redirects with error code InvalidCredentials', async () => { + const res = await loginRequest(email, password).expect(302) + expect(res.header.location).to.endWith( + '/email-login?errorCodes=INVALID_CREDENTIALS' + ) + }) + }) + }) + + describe('confirm-email', () => { + const confirmEmailRequest = (token: string): supertest.Test => { + return request.post(`${route}/confirm-email`).send({ token }) + } + + let user: User + let token: string + + before(async () => { + sinon.replace(util, 'sendEmail', sinon.fake.resolves(true)) + user = await createTestUser('pendingUser', undefined, 'password', true) + }) + + after(async () => { + sinon.restore() + await deleteTestUser(user.name) + }) + + context('when token is valid', () => { + before(() => { + token = generateVerificationToken(user.id) + }) + + it('set auth token in cookie', async () => { + const res = await confirmEmailRequest(token).expect(302) + expect(res.header['set-cookie']).to.be.an('array') + expect(res.header['set-cookie'][0]).to.contain('auth') + }) + + it('redirects to home page', async () => { + const res = await confirmEmailRequest(token).expect(302) + expect(res.header.location).to.endWith('/home?message=EMAIL_CONFIRMED') + }) + + it('sets user as active', async () => { + await confirmEmailRequest(token).expect(302) + const updatedUser = await getRepository(User).findOneBy({ + name: user.name, + }) + expect(updatedUser?.status).to.eql(StatusType.Active) + }) + }) + + context('when token is invalid', () => { + it('redirects to confirm-email with error code InvalidToken', async () => { + const res = await confirmEmailRequest('invalid_token').expect(302) + expect(res.header.location).to.endWith( + '/confirm-email?errorCodes=INVALID_TOKEN' + ) + }) + }) + + context('when token is expired', () => { + before(() => { + token = generateVerificationToken(user.id, -1) + }) + + it('redirects to confirm-email page with error code TokenExpired', async () => { + const res = await confirmEmailRequest(token).expect(302) + expect(res.header.location).to.endWith( + '/confirm-email?errorCodes=TOKEN_EXPIRED' + ) + }) + }) + + context('when user is not found', () => { + before(() => { + const nonExistsUserId = generateFakeUuid() + token = generateVerificationToken(nonExistsUserId) + }) + + it('redirects to confirm-email page with error code UserNotFound', async () => { + const res = await confirmEmailRequest(token).expect(302) + expect(res.header.location).to.endWith( + '/confirm-email?errorCodes=USER_NOT_FOUND' + ) + }) + }) + }) + + describe('forgot-password', () => { + const emailResetPasswordReq = (email: string): supertest.Test => { + return request.post(`${route}/forgot-password`).send({ + email, + }) + } + + let email: string + + context('when email is not empty', () => { + before(() => { + email = `some_email@domain.app` + }) + + context('when user exists', () => { + let user: User + + before(async () => { + user = await createTestUser('test_user') + email = user.email + }) + + after(async () => { + await deleteTestUser(user.name) + }) + + context('when email is verified', () => { + let fake: (msg: MailDataRequired) => Promise + + before(async () => { + await getRepository(User).update(user.id, { + status: StatusType.Active, + }) + }) + + context('when reset password email sent', () => { + before(() => { + fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true)) + }) + + after(() => { + sinon.restore() + }) + + it('redirects to forgot-password page with success message', async () => { + const res = await emailResetPasswordReq(email).expect(302) + expect(res.header.location).to.endWith( + '/forgot-password?message=SUCCESS' + ) + }) + }) + + context('when reset password email not sent', () => { + before(() => { + fake = sinon.replace( + util, + 'sendEmail', + sinon.fake.resolves(false) + ) + }) + + after(() => { + sinon.restore() + }) + + it('redirects to sign up page with error code INVALID_EMAIL', async () => { + const res = await emailResetPasswordReq(email).expect(302) + expect(res.header.location).to.endWith( + '/forgot-password?errorCodes=INVALID_EMAIL' + ) + }) + }) + }) + + context('when email is not verified', () => { + before(async () => { + await getRepository(User).update(user.id, { + status: StatusType.Pending, + }) + }) + + it('redirects to email-login page with error code PENDING_VERIFICATION', async () => { + const res = await emailResetPasswordReq(email).expect(302) + expect(res.header.location).to.endWith( + '/email-login?errorCodes=PENDING_VERIFICATION' + ) + }) + }) + }) + + context('when user does not exist', () => { + before(() => { + email = 'non_exists_email@domain.app' + }) + + it('redirects to forgot-password page with error code USER_NOT_FOUND', async () => { + const res = await emailResetPasswordReq(email).expect(302) + expect(res.header.location).to.endWith( + '/forgot-password?errorCodes=USER_NOT_FOUND' + ) + }) + }) + }) + + context('when email is empty', () => { + before(() => { + email = '' + }) + + it('redirects to forgot-password page with error code INVALID_EMAIL', async () => { + const res = await emailResetPasswordReq(email).expect(302) + expect(res.header.location).to.endWith( + '/forgot-password?errorCodes=INVALID_EMAIL' + ) + }) + }) + }) + + describe('reset-password', () => { + const resetPasswordRequest = ( + token: string, + password: string + ): supertest.Test => { + return request.post(`${route}/reset-password`).send({ + token, + password, + }) + } + + let user: User + let token: string + + before(async () => { + user = await createTestUser('test_user', undefined, 'test_password') + }) + + after(async () => { + await deleteTestUser(user.name) + }) + + context('when token is valid', () => { + before(async () => { + token = generateVerificationToken(user.id) + }) + + context('when password is not empty', () => { + it('redirects to reset-password page with success message', async () => { + const res = await resetPasswordRequest(token, 'new_password').expect( + 302 + ) + expect(res.header.location).to.endWith( + '/reset-password?message=SUCCESS' + ) + }) + + it('resets password', async () => { + const password = 'test_reset_password' + await resetPasswordRequest(token, password).expect(302) + const updatedUser = await getRepository(User).findOneBy({ + id: user?.id, + }) + expect(await comparePassword(password, updatedUser?.password!)).to.be + .true + }) + }) + + context('when password is empty', () => { + it('redirects to reset-password page with error code INVALID_PASSWORD', async () => { + const res = await resetPasswordRequest(token, '').expect(302) + expect(res.header.location).to.endWith( + '/reset-password?errorCodes=INVALID_PASSWORD' + ) + }) + }) + }) + + context('when token is invalid', () => { + it('redirects to reset-password page with error code InvalidToken', async () => { + const res = await resetPasswordRequest( + 'invalid_token', + 'new_password' + ).expect(302) + expect(res.header.location).to.endWith( + '/reset-password?errorCodes=INVALID_TOKEN' + ) + }) + + context('when token is expired', () => { + before(() => { + token = generateVerificationToken(user.id, -1) + }) + + it('redirects to reset-password page with error code ExpiredToken', async () => { + const res = await resetPasswordRequest(token, 'new_password').expect( + 302 + ) + expect(res.header.location).to.endWith( + '/reset-password?errorCodes=TOKEN_EXPIRED' + ) + }) + }) + }) + }) +}) diff --git a/packages/api/test/routers/reminders_router.test.ts b/packages/api/test/routers/reminders.test.ts similarity index 100% rename from packages/api/test/routers/reminders_router.test.ts rename to packages/api/test/routers/reminders.test.ts diff --git a/packages/api/test/services/create_user.test.ts b/packages/api/test/services/create_user.test.ts index b5081679f..029d6027e 100644 --- a/packages/api/test/services/create_user.test.ts +++ b/packages/api/test/services/create_user.test.ts @@ -1,5 +1,5 @@ import 'mocha' -import { expect } from 'chai' +import chai, { expect } from 'chai' import 'chai/register-should' import { createTestUser, @@ -12,42 +12,96 @@ import { getUserFollowers, getUserFollowing, } from '../../src/services/followers' +import { StatusType } from '../../src/datalayer/user/model' +import sinonChai from 'sinon-chai' +import sinon from 'sinon' +import * as util from '../../src/utils/sendEmail' +import { MailDataRequired } from '@sendgrid/helpers/classes/mail' -describe('create a user with an invite', () => { - it('follows the other user in the group', async () => { - after(async () => { - await deleteTestUser(testOwner) - await deleteTestUser(testUser) +chai.use(sinonChai) + +describe('create user', () => { + context('create a user with an invite', () => { + it('follows the other user in the group', async () => { + after(async () => { + await deleteTestUser(testOwner) + await deleteTestUser(testUser) + }) + + const testOwner = 'testowner' + const testUser = 'testuser' + + const adminUser = await createTestUser(testOwner) + const [, invite] = await createGroup({ + admin: adminUser, + name: 'testgroup', + }) + const user = await createTestUser(testUser, invite.code) + + expect(await getUserFollowers(user)).to.eql([adminUser]) + expect(await getUserFollowing(user)).to.eql([adminUser]) + expect(await getUserFollowers(adminUser)).to.eql([user]) + expect(await getUserFollowing(adminUser)).to.eql([user]) }) - const testOwner = 'testowner' - const testUser = 'testuser' + it('creates profile when user exists but profile not', async () => { + after(async () => { + await deleteTestUser(name) + }) - const adminUser = await createTestUser(testOwner) - const [, invite] = await createGroup({ - admin: adminUser, - name: 'testgroup', + const name = 'userWithoutProfile' + const user = await createUserWithoutProfile(name) + + await createTestUser(user.name) + + const profile = await getProfile(user) + + expect(profile).to.exist }) - const user = await createTestUser(testUser, invite.code) + }) - expect(await getUserFollowers(user)).to.eql([adminUser]) - expect(await getUserFollowing(user)).to.eql([adminUser]) - expect(await getUserFollowers(adminUser)).to.eql([user]) - expect(await getUserFollowing(adminUser)).to.eql([user]) - }).timeout(10000) + context('create a user with pending confirmation', () => { + const name = 'pendingUser' + let fake: (msg: MailDataRequired) => Promise - it('creates profile when user exists but profile not', async () => { - after(async () => { - await deleteTestUser(name) + context('when email sends successfully', () => { + beforeEach(() => { + fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true)) + }) + + afterEach(async () => { + sinon.restore() + await deleteTestUser(name) + }) + + it('creates the user with pending status and correct name', async () => { + const user = await createTestUser(name, undefined, undefined, true) + + expect(user.status).to.eql(StatusType.Pending) + expect(user.name).to.eql(name) + }) + + it('sends an email to the user', async () => { + await createTestUser(name, undefined, undefined, true) + + expect(fake).to.have.been.calledOnce + }) }) - const name = 'userWithoutProfile' - const user = await createUserWithoutProfile(name) + context('when failed to send email', () => { + before(() => { + fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(false)) + }) - await createTestUser(user.name) + after(async () => { + sinon.restore() + await deleteTestUser(name) + }) - const profile = await getProfile(user) - - expect(profile).to.exist + it('rejects with error', async () => { + return expect(createTestUser(name, undefined, undefined, true)).to.be + .rejected + }) + }) }) }) diff --git a/packages/db/migrations/0088.do.add_status_to_user.sql b/packages/db/migrations/0088.do.add_status_to_user.sql new file mode 100755 index 000000000..ba0416fb8 --- /dev/null +++ b/packages/db/migrations/0088.do.add_status_to_user.sql @@ -0,0 +1,11 @@ +-- Type: DO +-- Name: add_status_to_user +-- Description: Add status to user table + +BEGIN; + +CREATE TYPE user_status_type AS ENUM ('ACTIVE', 'PENDING'); + +ALTER TABLE omnivore.user ADD COLUMN status user_status_type NOT NULL DEFAULT 'ACTIVE'; + +COMMIT; diff --git a/packages/db/migrations/0088.undo.add_status_to_user.sql b/packages/db/migrations/0088.undo.add_status_to_user.sql new file mode 100755 index 000000000..182e1aef0 --- /dev/null +++ b/packages/db/migrations/0088.undo.add_status_to_user.sql @@ -0,0 +1,11 @@ +-- Type: UNDO +-- Name: add_status_to_user +-- Description: Add status to user table + +BEGIN; + +DROP TYPE IF EXISTS user_status_type CASCADE; + +ALTER TABLE omnivore.user DROP COLUMN IF EXISTS status; + +COMMIT; diff --git a/packages/db/migrations/0089.do.drop_membership_from_user.sql b/packages/db/migrations/0089.do.drop_membership_from_user.sql new file mode 100755 index 000000000..4ffa64dfd --- /dev/null +++ b/packages/db/migrations/0089.do.drop_membership_from_user.sql @@ -0,0 +1,12 @@ +-- Type: DO +-- Name: drop_membership_from_user +-- Description: drop membership column from user table + +BEGIN; + +ALTER TABLE omnivore.user + DROP column membership; + +DROP TYPE omnivore.membership_tier; + +COMMIT; diff --git a/packages/db/migrations/0089.undo.drop_membership_from_user.sql b/packages/db/migrations/0089.undo.drop_membership_from_user.sql new file mode 100755 index 000000000..db8cc2186 --- /dev/null +++ b/packages/db/migrations/0089.undo.drop_membership_from_user.sql @@ -0,0 +1,14 @@ +-- Type: UNDO +-- Name: drop_membership_from_user +-- Description: drop membership column from user table + +BEGIN; + +CREATE TYPE omnivore.membership_tier AS ENUM ('WAIT_LIST', 'BETA'); + +ALTER TABLE omnivore.user + ADD column membership omnivore.membership_tier NOT NULL DEFAULT 'WAIT_LIST'; + +UPDATE omnivore.user SET membership = 'BETA'; + +COMMIT; diff --git a/yarn.lock b/yarn.lock index 57ff3bf6a..09856e25c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5415,13 +5415,20 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== -"@sinonjs/commons@^1", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.4.0", "@sinonjs/commons@^1.7.0": +"@sinonjs/commons@^1", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.4.0", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ== dependencies: type-detect "4.0.8" +"@sinonjs/fake-timers@>=5", "@sinonjs/fake-timers@^9.1.2": + version "9.1.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c" + integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw== + dependencies: + "@sinonjs/commons" "^1.7.0" + "@sinonjs/fake-timers@^8.0.1": version "8.0.1" resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-8.0.1.tgz#1c1c9a91419f804e59ae8df316a07dd1c3a76b94" @@ -5446,6 +5453,15 @@ array-from "^2.1.1" lodash "^4.17.15" +"@sinonjs/samsam@^6.1.1": + version "6.1.1" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.1.tgz#627f7f4cbdb56e6419fa2c1a3e4751ce4f6a00b1" + integrity sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA== + dependencies: + "@sinonjs/commons" "^1.6.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + "@sinonjs/text-encoding@^0.7.1": version "0.7.1" resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" @@ -7994,6 +8010,26 @@ "@types/mime" "^1" "@types/node" "*" +"@types/sinon-chai@^3.2.8": + version "3.2.8" + resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc" + integrity sha512-d4ImIQbT/rKMG8+AXpmcan5T2/PNeSjrYhvkwet6z0p8kzYtfgA32xzOBlbU0yqJfq+/0Ml805iFoODO0LP5/g== + dependencies: + "@types/chai" "*" + "@types/sinon" "*" + +"@types/sinon@*", "@types/sinon@^10.0.13": + version "10.0.13" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.13.tgz#60a7a87a70d9372d0b7b38cc03e825f46981fb83" + integrity sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "8.1.2" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e" + integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA== + "@types/sinonjs__fake-timers@8.1.1": version "8.1.1" resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz#b49c2c70150141a15e0fa7e79cf1f92a72934ce3" @@ -12105,6 +12141,11 @@ diff@^4.0.1: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diff@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" + integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== + diff@~1.0.7: version "1.0.8" resolved "https://registry.yarnpkg.com/diff/-/diff-1.0.8.tgz#343276308ec991b7bc82267ed55bc1411f971666" @@ -18738,6 +18779,17 @@ nise@^1.5.2: lolex "^5.0.1" path-to-regexp "^1.7.0" +nise@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.1.tgz#ac4237e0d785ecfcb83e20f389185975da5c31f3" + integrity sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A== + dependencies: + "@sinonjs/commons" "^1.8.3" + "@sinonjs/fake-timers" ">=5" + "@sinonjs/text-encoding" "^0.7.1" + just-extend "^4.0.2" + path-to-regexp "^1.7.0" + no-case@^2.2.0, no-case@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" @@ -22318,6 +22370,23 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" +sinon-chai@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.7.0.tgz#cfb7dec1c50990ed18c153f1840721cf13139783" + integrity sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g== + +sinon@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-14.0.0.tgz#203731c116d3a2d58dc4e3cbe1f443ba9382a031" + integrity sha512-ugA6BFmE+WrJdh0owRZHToLd32Uw3Lxq6E6LtNRU+xTVBefx632h03Q7apXWRsRdZAJ41LB8aUfn2+O4jsDNMw== + dependencies: + "@sinonjs/commons" "^1.8.3" + "@sinonjs/fake-timers" "^9.1.2" + "@sinonjs/samsam" "^6.1.1" + diff "^5.0.0" + nise "^5.1.1" + supports-color "^7.2.0" + sinon@^7.3.2: version "7.5.0" resolved "https://registry.yarnpkg.com/sinon/-/sinon-7.5.0.tgz#e9488ea466070ea908fd44a3d6478fd4923c67ec" @@ -23144,7 +23213,7 @@ supports-color@^5.3.0, supports-color@^5.5.0: dependencies: has-flag "^3.0.0" -supports-color@^7.0.0, supports-color@^7.1.0: +supports-color@^7.0.0, supports-color@^7.1.0, supports-color@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== @@ -23874,7 +23943,7 @@ type-detect@0.1.1: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-0.1.1.tgz#0ba5ec2a885640e470ea4e8505971900dac58822" integrity sha1-C6XsKohWQORw6k6FBZcZANrFiCI= -type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5: +type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==