Merge pull request #14368 from mozilla/FXA-5358

task(admin-panel): Unsubcribe users from newsletters
This commit is contained in:
Dan Schomburg 2022-11-04 11:02:29 -07:00 коммит произвёл GitHub
Родитель 52512c11a6 a3cf41701b
Коммит 2d8807b6f8
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
16 изменённых файлов: 468 добавлений и 5 удалений

Просмотреть файл

@ -77,6 +77,12 @@ export const UNLINK_ACCOUNT = gql`
}
`;
export const UNSUBSCRIBE_FROM_MAILING_LISTS = gql`
mutation unsubscribeFromMailingLists($uid: String!) {
unsubscribeFromMailingLists(uid: $uid)
}
`;
export const LinkedAccount = ({
uid,
authAt,
@ -189,7 +195,6 @@ export const DangerZone = ({
window.alert('Error in unconfirming email');
},
});
const handleUnverify = () => {
if (!window.confirm('Are you sure? This cannot be undone.')) {
return;
@ -197,6 +202,30 @@ export const DangerZone = ({
unverify({ variables: { email: email.email } });
};
const [unsubscribeFromMailingLists] = useMutation(
UNSUBSCRIBE_FROM_MAILING_LISTS,
{
onCompleted: (data) => {
if (data.unsubscribeFromMailingLists) {
window.alert(
"The user's email has been unsubscribed from mozilla mailing lists."
);
} else {
window.alert('Unsubscribing was not successful.');
}
},
onError: () => {
window.alert('Unexpected error encountered!');
},
}
);
const handleUnsubscribeFromMailingLists = () => {
if (!window.confirm('Are you sure? This cannot be undone.')) {
return;
}
unsubscribeFromMailingLists({ variables: { uid } });
};
const [disableAccount] = useMutation(DISABLE_ACCOUNT, {
onCompleted: () => {
window.alert('The account has been disabled.');
@ -265,6 +294,7 @@ export const DangerZone = ({
AdminPanelFeature.UnverifyEmail,
AdminPanelFeature.DisableAccount,
AdminPanelFeature.EnableAccount,
AdminPanelFeature.UnsubscribeFromMailingLists,
]}
>
<h3 className="mt-0 mb-1 bg-red-600 font-medium h-8 pb-8 pl-1 pt-1 rounded-sm text-lg text-white">
@ -348,6 +378,24 @@ export const DangerZone = ({
</div>
</Guard>
)}
<Guard features={[AdminPanelFeature.UnsubscribeFromMailingLists]}>
<h2 className="text-lg account-header">
Unsubscribe From Mailing Lists
</h2>
<div className="border-l-2 border-red-600 mb-4 pl-4">
<p className="text-base leading-6">
Unsubscribe user from <b>all</b> mozilla mailing lists.
</p>
<button
className="bg-grey-10 border-2 border-grey-100 font-medium h-12 leading-6 mt-4 mr-4 rounded text-red-700 w-40 hover:border-2 hover:border-grey-10 hover:bg-grey-50 hover:text-red-700"
type="button"
data-testid="unsubscribe-from-mailing-lists"
onClick={handleUnsubscribeFromMailingLists}
>
Unsubscribe
</button>
</div>
</Guard>
</>
);
};
@ -706,6 +754,7 @@ export const Account = ({
disabledAt: disabledAt!,
email: primaryEmail, // only the primary for now
onCleared: onCleared,
unsubscribeToken: '<USER_TOKEN>',
}}
/>
</section>

Просмотреть файл

@ -0,0 +1,30 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { MockedResponse } from '@apollo/client/testing';
import { UNSUBSCRIBE_FROM_MAILING_LISTS } from '.';
export const mockUnsubscribe = (success: boolean): MockedResponse => {
const request = {
query: UNSUBSCRIBE_FROM_MAILING_LISTS,
};
let result = undefined;
let error = undefined;
if (success) {
result = {
data: {
status: success,
},
};
} else {
error = new Error('Unsubscribe failed.');
}
return {
request,
result,
error,
};
};

Просмотреть файл

@ -10,6 +10,7 @@ import {
CLEAR_BOUNCES_BY_EMAIL,
EDIT_LOCALE,
RECORD_ADMIN_SECURITY_EVENT,
UNSUBSCRIBE_FROM_MAILING_LISTS,
} from './Account/index';
import { GET_ACCOUNT_BY_EMAIL, AccountSearch, GET_EMAILS_LIKE } from './index';
import {
@ -459,3 +460,64 @@ describe('Editing user locale', () => {
);
});
});
describe('unsubscribe from mailing lists', () => {
class Unsubscribe {
static readonly ErrorMessage = 'Error unsubscribing';
static request(uid: string) {
return {
query: UNSUBSCRIBE_FROM_MAILING_LISTS,
variables: {
uid: uid,
},
};
}
static result(success: boolean) {
return {
data: { unsubscribeFromMailingLists: success },
};
}
static mock(uid: string, success: boolean) {
return {
request: this.request(uid),
result: this.result(success),
};
}
}
let alertSpy: jest.SpyInstance;
async function renderAndClickUnSubscribe(success: boolean) {
renderView([
GetAccountsByEmail.mock(testEmail, false, true),
Unsubscribe.mock('123', success),
]);
jest.spyOn(window, 'confirm').mockImplementation(() => true);
alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {
return true;
});
fireEvent.change(screen.getByTestId('email-input'), {
target: { value: testEmail },
});
fireEvent.click(screen.getByTestId('search-button'));
await waitFor(() => screen.getByTestId('account-section'));
fireEvent.click(screen.getByTestId('unsubscribe-from-mailing-lists'));
await waitFor(() => new Promise((r) => setTimeout(r, 100)));
}
it('handles unsubscribe', async () => {
await renderAndClickUnSubscribe(true);
expect(alertSpy).toBeCalledWith(
"The user's email has been unsubscribed from mozilla mailing lists."
);
});
it('handles unsubscribe failure', async () => {
await renderAndClickUnSubscribe(false);
expect(alertSpy).toHaveBeenCalledWith('Unsubscribing was not successful.');
});
});

Просмотреть файл

@ -199,7 +199,7 @@ export const AccountSearch = () => {
return filteredList.map((item) => (
<a
key={item}
className="p-2 border-b border-grey-100 block hover:bg-grey-10 focus:bg-grey-10"
className="p-2 border-b border-grey-100 block hover:bg-grey-10 focus:bg-grey-10 z-50"
href="#suggestion"
onClick={(e: any) => {
e.preventDefault();
@ -266,7 +266,7 @@ export const AccountSearch = () => {
</button>
{showSuggestion && filteredList.length > 0 && (
<div
className="suggestions-list absolute top-full w-full bg-white border border-grey-100 mt-3 shadow-sm rounded overflow-hidden"
className="suggestions-list absolute top-full w-full bg-white border border-grey-100 mt-3 shadow-sm rounded overflow-hidden z-50"
data-testid="email-suggestions"
>
{renderSuggestions()}

Просмотреть файл

@ -23,6 +23,7 @@ import { DatabaseService } from './database/database.service';
import { EventLoggingModule } from './event-logging/event-logging.module';
import { GqlModule } from './gql/gql.module';
import { SubscriptionModule } from './subscriptions/subscriptions.module';
import { NewslettersModule } from './newsletters/newsletters.module';
import { BackendModule } from './backend/backend.module';
const version = getVersionInfo(__dirname);
@ -37,6 +38,7 @@ const version = getVersionInfo(__dirname);
DatabaseModule,
EventLoggingModule,
SubscriptionModule,
NewslettersModule,
GqlModule,
GraphQLModule.forRootAsync({
imports: [ConfigModule, SentryModule],

Просмотреть файл

@ -524,6 +524,26 @@ const conf = convict({
default: path.resolve(__dirname, '../config/public-key.json'),
env: 'PUBLIC_KEY_FILE',
},
newsletters: {
basketHost: {
doc: 'Host where basket api lives',
format: String,
default: 'basket.mozilla.org',
env: 'BASKET_HOST',
},
basketApiKey: {
doc: 'Api key for basket',
format: String,
default: '',
env: 'BASKET_API_KEY',
},
newsletterHost: {
doc: 'Host where newsletter api lives',
format: String,
default: 'www.mozilla.org',
env: 'NEWSLETTER_HOST',
},
},
});
const configDir = __dirname;

Просмотреть файл

@ -12,6 +12,7 @@ export enum EventNames {
DisableLogin = 'disable-login',
AccountSearch = 'account-search',
EditLocale = 'edit-locale',
UnsubscribeFromMailingLists = 'unsubscribe-from-mailing-lists',
}
/**

Просмотреть файл

@ -1,6 +1,7 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Provider } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
@ -41,6 +42,7 @@ import { EventLoggingService } from '../../event-logging/event-logging.service';
import { SubscriptionsService } from '../../subscriptions/subscriptions.service';
import { AccountResolver } from './account.resolver';
import { AuthClientService } from '../../backend/auth-client.service';
import { BasketService } from '../../newsletters/basket.service';
export const chance = new Chance();
@ -91,6 +93,7 @@ describe('AccountResolver', () => {
},
};
let authClient: any;
let basketService: any;
beforeAll(async () => {
knex = await testDatabaseSetup();
@ -150,7 +153,23 @@ describe('AccountResolver', () => {
},
};
basketService = {};
const MockBasket: Provider = {
provide: BasketService,
useValue: basketService,
};
const MockDb: Provider = {
provide: DatabaseService,
useValue: db,
};
authClient = {};
const MockAuthClient = {
provide: AuthClientService,
useValue: authClient,
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AccountResolver,
@ -159,8 +178,9 @@ describe('AccountResolver', () => {
MockConfig,
MockMetricsFactory,
MockSubscription,
{ provide: DatabaseService, useValue: db },
{ provide: AuthClientService, useValue: authClient },
MockBasket,
MockDb,
MockAuthClient,
],
}).compile();
@ -347,4 +367,55 @@ describe('AccountResolver', () => {
expect(authClient.passwordForgotSendCode).toBeCalledTimes(1);
expect(result).toBe(true);
});
describe('unsubscribes from mailing lists', () => {
const fakeToken = '123';
const uid = USER_1.uid;
const badUid = uid.replace(/\d/g, '0');
const email = USER_1.email;
beforeEach(() => {
basketService.getUserToken = jest.fn().mockResolvedValue(fakeToken);
basketService.unsubscribeAll = jest.fn().mockResolvedValue(true);
});
it('unsubscribes successfully', async () => {
const result = await resolver.unsubscribeFromMailingLists(uid);
expect(basketService.getUserToken).toBeCalledWith(email);
expect(basketService.unsubscribeAll).toBeCalledWith(fakeToken);
expect(result).toBeTruthy();
});
it('handles bad request - unsubscribe', async () => {
basketService.unsubscribeAll = jest.fn().mockResolvedValue(undefined);
const result = await resolver.unsubscribeFromMailingLists(uid);
expect(basketService.getUserToken).toBeCalledWith(USER_1.email);
expect(basketService.unsubscribeAll).toBeCalledWith(fakeToken);
expect(result).toBeFalsy();
});
it('handles bad request - invalid basket user', async () => {
basketService.getUserToken = jest.fn().mockResolvedValue(undefined);
const result = await resolver.unsubscribeFromMailingLists(uid);
expect(basketService.getUserToken).toBeCalledWith(email);
expect(basketService.unsubscribeAll).toBeCalledTimes(0);
expect(result).toBeFalsy();
});
it('handles bad request - invalid fxa user', async () => {
basketService.getUserToken = jest.fn().mockResolvedValue(undefined);
basketService.unsubscribeAll = jest.fn().mockResolvedValue(undefined);
const result = await resolver.unsubscribeFromMailingLists(badUid);
expect(basketService.getUserToken).toBeCalledTimes(0);
expect(basketService.unsubscribeAll).toBeCalledTimes(0);
expect(result).toBeFalsy();
});
});
});

Просмотреть файл

@ -38,6 +38,7 @@ import { Email as EmailType } from '../../gql/model/emails.model';
import { SubscriptionsService } from '../../subscriptions/subscriptions.service';
import { AuthClientService } from '../../backend/auth-client.service';
import AuthClient from 'fxa-auth-client';
import { BasketService } from '../../newsletters/basket.service';
const ACCOUNT_COLUMNS = [
'uid',
@ -89,6 +90,7 @@ export class AccountResolver {
private subscriptionsService: SubscriptionsService,
private configService: ConfigService<AppConfig>,
private eventLogging: EventLoggingService,
private basketService: BasketService,
@Inject(AuthClientService) private authAPI: AuthClient
) {}
@ -373,4 +375,36 @@ export class AccountResolver {
});
return !!result;
}
@Features(AdminPanelFeature.UnsubscribeFromMailingLists)
@Mutation((returns) => Boolean)
public async unsubscribeFromMailingLists(@Args('uid') uid: string) {
// Look up email. This end point is protected, but using a uid would makes it harder
// to abuse regardless.
const account = await this.db.account
.query()
.select('email')
.where({ uid: uuidTransformer.to(uid) })
.first();
if (!account) {
return false;
}
// Look up user token
const token = await this.basketService.getUserToken(account.email);
if (!token) {
return false;
}
// Request that user is unsubscribed from mailing list
const success = await this.basketService.unsubscribeAll(token);
// Record an event if action was successful
if (success) {
this.eventLogging.onEvent(EventNames.UnsubscribeFromMailingLists);
}
return success;
}
}

Просмотреть файл

@ -10,6 +10,7 @@ import { AccountResolver } from './account/account.resolver';
import { EmailBounceResolver } from './email-bounce/email-bounce.resolver';
import { RelyingPartyResolver } from './relying-party/relying-party.resolver';
import { BackendModule } from '../backend/backend.module';
import { NewslettersModule } from '../newsletters/newsletters.module';
@Module({
imports: [
@ -17,6 +18,7 @@ import { BackendModule } from '../backend/backend.module';
SubscriptionModule,
EventLoggingModule,
BackendModule,
NewslettersModule,
],
providers: [AccountResolver, EmailBounceResolver, RelyingPartyResolver],
})

Просмотреть файл

@ -174,6 +174,7 @@ export interface IMutation {
sendPasswordResetEmail(email: string): boolean | Promise<boolean>;
recordAdminSecurityEvent(name: string, uid: string): boolean | Promise<boolean>;
unlinkAccount(uid: string): boolean | Promise<boolean>;
unsubscribeFromMailingLists(uid: string): boolean | Promise<boolean>;
clearEmailBounce(email: string): boolean | Promise<boolean>;
updateNotes(notes: string, id: string): boolean | Promise<boolean>;
}

Просмотреть файл

@ -0,0 +1,80 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { AppConfig } from '../config';
import { MockConfig, mockConfigOverrides } from '../mocks';
import { BasketService } from './basket.service';
describe('BasketService', () => {
const validEmail = 'success@example.com';
const invalidEmail = 'failure@example.com';
let service: BasketService;
let config: AppConfig['newsletters'];
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [BasketService, MockConfig],
}).compile();
service = module.get<BasketService>(BasketService);
config = module.get<ConfigService>(ConfigService).get('').newsletters;
});
it('has api key', () => {
// Ensure basket api key is set in config/secrets.json or as env variable
expect(config.basketApiKey).toBeDefined();
});
it('looks up uid by valid email', async () => {
if (!config.basketApiKey) {
return;
}
const token = await service.getUserToken(validEmail);
expect(token).toBeDefined();
expect(token.length).toBeGreaterThan(8);
});
it('looks up invalid email', async () => {
if (!config.basketApiKey) {
return;
}
const token = await service.getUserToken(invalidEmail);
expect(token).toBeUndefined();
});
it('unsubscribes from mailing lists', async () => {
if (!config.basketApiKey) {
return;
}
const token = await service.getUserToken(validEmail);
const result = await service.unsubscribeAll(token);
expect(result).toBeTruthy();
});
describe('Missing api key', () => {
beforeAll(async () => {
mockConfigOverrides.newsletters = {
newsletterHost: '',
basketHost: '',
basketApiKey: '',
};
const module: TestingModule = await Test.createTestingModule({
providers: [BasketService, MockConfig],
}).compile();
service = module.get<BasketService>(BasketService);
});
afterAll(() => {
delete mockConfigOverrides.newsletters;
});
it('throws on query', async () => {
await expect(async () => {
await service.getUserToken(validEmail);
}).rejects.toThrowError('No API key configured!');
});
});
});

Просмотреть файл

@ -0,0 +1,92 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import fetch from 'node-fetch';
import { AppConfig } from '../config';
/**
* A service for basket and newsletter APIs.
*/
@Injectable()
export class BasketService {
/** Basket config */
private readonly config: AppConfig['newsletters'];
private get basketUrl() {
return `https://${this.config.basketHost}`;
}
private get newsLetterUrl() {
return `https://${this.config.newsletterHost}`;
}
constructor(configService: ConfigService<AppConfig>) {
const config = configService.get('newsletters');
if (!config) {
throw new Error('No basket api key defined!');
}
this.config = config;
}
/**
* Locates a user token by email in basket
* @param email
* @returns User's token or undefined if no user is found.
*/
async getUserToken(email: string) {
// Check that API key was configured.
if (!this.config.basketApiKey) {
throw new Error('No API key configured!');
}
const url = getUrl(this.basketUrl, '/news/lookup-user/', {
email,
'api-key': this.config.basketApiKey,
});
const resp = await fetch(url);
if (resp.status === 200) {
const body = await resp.json();
return body?.token;
}
}
/**
* Unsubscribes a user from all email lists
* @param userToken - A valid user token. Can be located with getUserToken method.
* @returns True if request was successful.
*/
async unsubscribeAll(userToken: string) {
const url = getUrl(
this.newsLetterUrl,
`/en-US/newsletter/existing/${userToken}/`,
{}
);
const params = new URLSearchParams();
params.append('form-TOTAL_FORMS', '0');
params.append('form-INITIAL_FORMS', '0');
params.append('form-MIN_NUM_FORMS', '0');
params.append('country', 'us');
params.append('lang', 'en');
params.append('format', 'H');
params.append('remove_all', 'on');
const resp = await fetch(url, {
method: 'POST',
body: params,
});
return resp?.status === 200;
}
}
function getUrl<T>(baseUrl: string, path: string, params: Record<string, any>) {
const q = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
return `${baseUrl}${path}?${q}`;
}

Просмотреть файл

@ -0,0 +1,12 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Module } from '@nestjs/common';
import { BasketService } from './basket.service';
@Module({
providers: [BasketService],
exports: [BasketService],
})
export class NewslettersModule {}

Просмотреть файл

@ -169,6 +169,7 @@ type Mutation {
sendPasswordResetEmail(email: String!): Boolean!
recordAdminSecurityEvent(name: String!, uid: String!): Boolean!
unlinkAccount(uid: String!): Boolean!
unsubscribeFromMailingLists(uid: String!): Boolean!
clearEmailBounce(email: String!): Boolean!
updateNotes(notes: String!, id: String!): Boolean!
}

Просмотреть файл

@ -18,6 +18,7 @@ export enum PermissionLevel {
}
/** Enum of known features */
export enum AdminPanelFeature {
AccountSearch = 'AccountSearch',
AccountHistory = 'AccountHistory',
@ -32,6 +33,7 @@ export enum AdminPanelFeature {
RelyingParties = 'RelyingParties',
RelyingPartiesEditNotes = 'RelyingPartiesEditNotes',
SendPasswordResetEmail = 'SendPasswordResetEmail',
UnsubscribeFromMailingLists = 'UnsubscribeFromMailingLists',
}
/** Enum of known user groups */
@ -138,6 +140,10 @@ const defaultAdminPanelPermissions: Permissions = {
name: 'Send Password Reset Email',
level: PermissionLevel.Support,
},
[AdminPanelFeature.UnsubscribeFromMailingLists]: {
name: 'Unsubscribe User From Mozilla Mailing Lists',
level: PermissionLevel.Support,
},
};
/**