feat(admin-server): convert to NestJS

Because:

* We're standardizing our applications on NestJS.

This commit:

* Converts the admin-server to NestJS.

Closes #6262
This commit is contained in:
Ben Bangert 2020-09-17 16:38:54 -07:00
Родитель 0007318e83
Коммит 69f44d78e1
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 340D6D716D25CCA6
57 изменённых файлов: 871 добавлений и 1212 удалений

30
packages/fxa-admin-server/.vscode/tasks.json поставляемый
Просмотреть файл

@ -1,30 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "tsc-watch",
"command": "npm",
"args": ["run", "watch"],
"type": "shell",
"isBackground": true,
"group": "build",
"problemMatcher": "$tsc-watch",
"presentation": {
"reveal": "always"
}
},
{
"label": "Run Current Test",
"type": "shell",
"command": "./node_modules/mocha/bin/mocha",
"args": ["-r", "ts-node/register", "${relativeFile}"],
"group": "test",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "dedicated"
}
}
]
}

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

@ -4,7 +4,7 @@ This is the GraphQL server for an internal resource for FxA Admins to access a s
## Generate test email bounces
If you need to create a handful of test email bounces in development you can use `npm run email-bounce`.
If you need to create a handful of test email bounces in development you can use `yarn email-bounce`.
By default this will create a new email bounce for a newly-created dummy account.
@ -12,24 +12,22 @@ Use the `--email` flag to create a bounce for an existing account.
Use the `--count` flag to create X number of bounces in a single command.
Example: `npm run email-bounce -- --email=test@example.com --count=3`
Example: `yarn email-bounce --email test@example.com --count 3`
## Testing
This package uses [Mocha](https://mochajs.org/) to test its code. By default `npm test` will test all files ending in `.spec.ts` under `src/test/` and uses `ts-node` so it can process TypeScript files.
This package uses [Jest](https://mochajs.org/) to test its code. By default `yarn test` will test all files ending in `.spec.ts`.
Test specific tests with the following commands:
Test commands:
```bash
# Test only src/test/lib/sentry.spec.ts
npx mocha -r ts-node/register src/test/lib/sentry.spec.ts
# Test with coverage
yarn test:cov
# Grep for "returns lbheartbeat"
npx mocha -r ts-node/register src/test/lib/** -g "returns lbheartbeat"
# Test on file change
yarn test:watch
```
Refer to Mocha's [CLI documentation](https://mochajs.org/#command-line-usage) for more advanced test configuration.
## License
MPL-2.0

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

@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

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

@ -3,14 +3,19 @@
"version": "1.188.1",
"description": "FxA GraphQL Admin Server",
"scripts": {
"build": "tsc",
"prebuild": "rimraf dist",
"build": "nest build",
"lint": "eslint *",
"audit": "npm audit --json | audit-filter --nsp-config=.nsprc --audit=-",
"watch": "tsc -w",
"start": "pm2 start pm2.config.js",
"stop": "pm2 stop pm2.config.js",
"start:prod": "node dist/main",
"restart": "pm2 restart pm2.config.js",
"test": "mocha -r ts-node/register src/test/**/*.spec.ts src/test/**/**/*.spec.ts src/test/**/**/**/*.spec.ts",
"test": "jest && yarn test:e2e",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"email-bounce": "ts-node ./scripts/email-bounce.ts"
},
"repository": {
@ -28,49 +33,70 @@
"homepage": "https://github.com/mozilla/fxa#readme",
"readmeFilename": "README.md",
"dependencies": {
"@sentry/integrations": "^5.21.1",
"@sentry/node": "^5.21.1",
"@nestjs/common": "^7.4.4",
"@nestjs/config": "^0.5.0",
"@nestjs/core": "^7.4.4",
"@nestjs/graphql": "^7.6.0",
"@nestjs/mapped-types": "^0.1.0",
"@nestjs/platform-express": "^7.4.4",
"@sentry/integrations": "^5.23.0",
"@sentry/node": "^5.23.0",
"apollo-server": "^2.16.1",
"apollo-server-express": "^2.16.1",
"class-transformer": "^0.3.1",
"class-validator": "^0.12.2",
"convict": "^6.0.0",
"convict-format-with-moment": "^6.0.0",
"convict-format-with-validator": "^6.0.0",
"express": "^4.17.1",
"fxa-shared": "workspace:*",
"graphql": "^14.6.0",
"graphql-tools": "^4.0.8",
"helmet": "^4.1.1",
"knex": "^0.21.4",
"mozlog": "^3.0.1",
"mysql": "^2.18.1",
"objection": "^2.2.2",
"reflect-metadata": "^0.1.13",
"tslib": "^2.0.1",
"type-graphql": "^0.17.6",
"typedi": "^0.8.0"
"rimraf": "^3.0.2",
"rxjs": "^6.5.5",
"tslib": "^2.0.1"
},
"devDependencies": {
"@types/chai": "^4.2.12",
"@types/chance": "^1.1.0",
"@types/convict": "^5.2.1",
"@types/eslint": "7.2.0",
"@types/graphql": "^14.5.0",
"@types/mocha": "^8.0.2",
"@types/node": "^13.9.1",
"@types/proxyquire": "^1.3.28",
"@types/sinon": "9.0.5",
"@types/rimraf": "3.0.0",
"@types/supertest": "^2.0.10",
"@types/yargs": "^15.0.5",
"audit-filter": "^0.5.0",
"chai": "^4.2.0",
"chance": "^1.1.6",
"eslint": "^7.6.0",
"fxa-shared": "workspace:*",
"mocha": "^8.1.1",
"jest": "26.4.2",
"pm2": "^4.4.1",
"prettier": "^2.0.5",
"proxyquire": "^2.1.3",
"sinon": "^9.0.3",
"supertest": "^4.0.2",
"ts-node": "^8.10.2",
"ts-sinon": "^1.2.0",
"ts-jest": "26.1.0",
"ts-node": "^8.6.2",
"tsconfig-paths": "^3.9.0",
"typescript": "3.9.7",
"yargs": "^15.4.1"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

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

@ -10,17 +10,16 @@ module.exports = {
apps: [
{
name: 'admin-server',
script: 'node -r ts-node/register src/bin/main.ts',
script: 'nest start --debug=9150 --watch',
cwd: __dirname,
max_restarts: '1',
min_uptime: '2m',
env: {
PATH,
NODE_ENV: 'development',
NODE_OPTIONS: '--inspect=9150',
TS_NODE_TRANSPILE_ONLY: 'true',
TS_NODE_FILES: 'true',
PORT: '8095', // TODO: this needs to get added to src/config.ts
PORT: '8095',
},
filter_env: ['npm_'],
watch: ['src'],

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

@ -1,22 +1,28 @@
import { argv } from 'yargs';
import { EmailBounces } from '../src/lib/db/models/email-bounces';
import { Account } from '../src/lib/db/models/account';
import Config from '../src/config';
import { setupDatabase } from '../src/lib/db';
import Knex from 'knex';
import { Model } from 'objection';
import yargs from 'yargs';
import Config from '../src/config';
import { Account, EmailBounces } from '../src/database/model';
import {
AccountIsh,
BounceIsh,
randomAccount,
randomEmail,
randomEmailBounce,
AccountIsh,
BounceIsh
} from '../src/test/lib/db/models/helpers';
} from '../src/database/model/helpers';
const config = Config.getProperties();
const argv = yargs.options({
count: { type: 'number', default: 1 },
email: { type: 'string' },
}).argv;
async function addBounceToDB() {
const knex = setupDatabase(config.database);
const count = argv.count || 1;
const knex = Knex({ client: 'mysql', connection: config.database });
Model.knex(knex);
const count = argv.count;
let bounce: BounceIsh;
let account: AccountIsh;
@ -34,13 +40,20 @@ async function addBounceToDB() {
}
await EmailBounces.query().insertGraph(bounce);
if (!argv.email) {
console.log(`=> Created 1 email bounce for ${bounce.email}`);
}
}
if (argv.email) {
console.log(
`=> Created ${count} email ${count === 1 ? 'bounce' : 'bounces'} for ${
argv.email
}`
);
}
await knex.destroy();
console.log(
`=> Created ${count} email ${count === 1 ? 'bounce' : 'bounces'} for ${bounce.email}`
);
}
addBounceToDB();

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

@ -0,0 +1,62 @@
/* 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 { ConfigModule, ConfigService } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { HealthModule } from 'fxa-shared/nestjs/health/health.module';
import { LoggerModule } from 'fxa-shared/nestjs/logger/logger.module';
import { SentryModule } from 'fxa-shared/nestjs/sentry/sentry.module';
import { getVersionInfo } from 'fxa-shared/nestjs/version';
import { join } from 'path';
import Config, { AppConfig } from './config';
import { DatabaseModule } from './database/database.module';
import { DatabaseService } from './database/database.service';
import { GqlModule } from './gql/gql.module';
const version = getVersionInfo(__dirname);
@Module({
imports: [
ConfigModule.forRoot({
load: [(): AppConfig => Config.getProperties()],
isGlobal: true,
}),
DatabaseModule,
GqlModule,
GraphQLModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
path: '/graphql',
useGlobalPrefix: true,
debug: configService.get<string>('env') !== 'production',
playground: configService.get<string>('env') !== 'production',
autoSchemaFile: join(__dirname, './schema.gql'),
context: ({ req }) => ({ req }),
}),
}),
HealthModule.forRootAsync({
imports: [DatabaseModule],
inject: [DatabaseService],
useFactory: async (db: DatabaseService) => ({
version,
extraHealthData: () => db.dbHealthCheck(),
}),
}),
LoggerModule,
SentryModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService<AppConfig>) => ({
dsn: configService.get('sentryDsn'),
environment: configService.get('env'),
version: version.version,
}),
}),
],
controllers: [],
providers: [],
})
export class AppModule {}

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

@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
export const CurrentUser = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req.user;
}
);

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

@ -0,0 +1,10 @@
import { GqlAuthHeaderGuard } from './auth-header.guard';
describe('AuthHeaderGuard', () => {
it('should be defined', () => {
const MockConfig = {
get: jest.fn().mockReturnValue({ authHeader: 'test' }),
};
expect(new GqlAuthHeaderGuard(MockConfig as any)).toBeDefined();
});
});

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

@ -0,0 +1,28 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { AppConfig } from '../config';
@Injectable()
export class GqlAuthHeaderGuard implements CanActivate {
private authHeader: string;
constructor(configService: ConfigService<AppConfig>) {
this.authHeader = configService.get('authHeader') as string;
}
canActivate(
context: ExecutionContext
): boolean | Promise<boolean> | Observable<boolean> {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req as Request;
const username = request.get(this.authHeader);
if (username) {
(request as any).user = username;
}
return !!username;
}
}

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

@ -1,37 +0,0 @@
/* 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 'reflect-metadata';
import express from 'express';
import mozlog from 'mozlog';
import Config from '../config';
import { dbHealthCheck } from '../lib/db';
import { loadBalancerRoutes, strictTransportSecurity } from '../lib/middleware';
import { configureSentry } from '../lib/sentry';
import { createServer } from '../lib/server';
import { version } from '../lib/version';
const logger = mozlog(Config.get('logging'))('supportPanel');
configureSentry({ dsn: Config.getProperties().sentryDsn, release: version.version });
async function run() {
const app = express();
app.use(strictTransportSecurity);
const server = await createServer(Config.getProperties(), logger);
server.applyMiddleware({ app });
app.use(loadBalancerRoutes(dbHealthCheck));
app.listen({ port: 8090 }, () => {
logger.info('startup', {
message: `Server is running, GraphQL Playground available at http://localhost:8090${server.graphqlPath}`
});
});
}
run().catch(err => {
logger.error('startup', { err });
});

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

@ -1,7 +1,6 @@
/* 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 convict from 'convict';
import fs from 'fs';
import path from 'path';
@ -54,7 +53,7 @@ const conf = convict({
env: 'NODE_ENV',
format: ['development', 'test', 'stage', 'production'],
},
logging: {
log: {
app: { default: 'fxa-user-admin-server' },
fmt: {
default: 'heka',
@ -65,17 +64,12 @@ const conf = convict({
default: 'info',
env: 'LOG_LEVEL',
},
routes: {
enabled: {
default: true,
doc: 'Enable route logging. Set to false to trimming CI logs.',
env: 'ENABLE_ROUTE_LOGGING',
},
format: {
default: 'default_fxa',
format: ['default_fxa', 'dev_fxa', 'default', 'dev', 'short', 'tiny'],
},
},
},
port: {
default: 8095,
doc: 'Default port to listen on',
env: 'PORT',
format: Number,
},
sentryDsn: {
default: '',
@ -110,4 +104,5 @@ conf.loadFile(files);
conf.validate({ allowed: 'strict' });
const Config = conf;
export type AppConfig = ReturnType<typeof Config['getProperties']>;
export default Config;

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

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { DatabaseService } from './database.service';
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}

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

@ -0,0 +1,38 @@
/* 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 { Test, TestingModule } from '@nestjs/testing';
import { DatabaseService } from './database.service';
import { Provider } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
describe('DatabaseService', () => {
let service: DatabaseService;
beforeEach(async () => {
const MockConfig: Provider = {
provide: ConfigService,
useValue: {
get: jest.fn().mockReturnValue({
database: {
database: 'testAdmin',
host: 'localhost',
password: '',
port: 3306,
user: 'root',
},
}),
},
};
const module: TestingModule = await Test.createTestingModule({
providers: [DatabaseService, MockConfig],
}).compile();
service = module.get<DatabaseService>(DatabaseService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

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

@ -0,0 +1,37 @@
/* 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 Knex from 'knex';
import { AppConfig } from '../config';
import { Account, EmailBounces, Emails } from './model';
@Injectable()
export class DatabaseService {
public knex: Knex;
public account: typeof Account;
public emails: typeof Emails;
public emailBounces: typeof EmailBounces;
constructor(configService: ConfigService<AppConfig>) {
const dbConfig = configService.get('database') as AppConfig['database'];
this.knex = Knex({ connection: dbConfig, client: 'mysql' });
this.account = Account.bindKnex(this.knex);
this.emails = Emails.bindKnex(this.knex);
this.emailBounces = EmailBounces.bindKnex(this.knex);
}
async dbHealthCheck(): Promise<Record<string, any>> {
let status = 'ok';
try {
await this.account.query().limit(1);
} catch (err) {
status = 'error';
}
return {
db: { status },
};
}
}

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

@ -0,0 +1,53 @@
/* 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 Knex from 'knex';
import { uuidTransformer } from '../transformers';
import { Account } from './account.model';
import { chance, randomAccount, testDatabaseSetup } from './helpers';
const USER_1 = randomAccount();
describe('account model', () => {
let knex: Knex;
beforeAll(async () => {
knex = await testDatabaseSetup('testAdminAccount');
// Load the user in
await Account.bindKnex(knex).query().insertGraph(USER_1);
});
afterAll(async () => {
await knex.destroy();
});
it('looks up the user by email', async () => {
const result = await Account.bindKnex(knex).query().findOne({
normalizedEmail: USER_1.normalizedEmail,
});
expect(result.uid).toBe(USER_1.uid);
expect(result.email).toBe(USER_1.email);
});
it('looks up the user by uid buffer', async () => {
const uidBuffer = uuidTransformer.to(USER_1.uid);
const result = await Account.bindKnex(knex)
.query()
.findOne({ uid: uidBuffer });
expect(result.uid).toBe(USER_1.uid);
expect(result.email).toBe(USER_1.email);
});
it('does not find a non-existent user', async () => {
let result = await Account.bindKnex(knex).query().findOne({
normalizedEmail: chance.email(),
});
expect(result).toBeUndefined();
const uid = chance.guid({ version: 4 }).replace(/-/g, '');
const uidBuffer = uuidTransformer.to(uid);
result = await Account.bindKnex(knex).query().findOne({ uid: uidBuffer });
expect(result).toBeUndefined();
});
});

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

@ -1,11 +1,10 @@
/* 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 { Model } from 'objection';
import { intBoolTransformer, uuidTransformer } from '../transformers';
import { Emails } from './emails';
import { Emails } from './emails.model';
export class Account extends Model {
public static tableName = 'accounts';
@ -15,11 +14,11 @@ export class Account extends Model {
emails: {
join: {
from: 'accounts.uid',
to: 'emails.uid'
to: 'emails.uid',
},
modelClass: Emails,
relation: Model.HasManyRelation
}
relation: Model.HasManyRelation,
},
};
public uid!: string;

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

@ -0,0 +1,44 @@
/* 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 Knex from 'knex';
import { EmailBounces } from './email-bounces.model';
import {
chance,
randomAccount,
randomEmailBounce,
testDatabaseSetup,
} from './helpers';
const USER_1 = randomAccount();
const EMAIL_BOUNCE = randomEmailBounce(USER_1.email);
describe('emailBounces model', () => {
let knex: Knex;
beforeAll(async () => {
knex = await testDatabaseSetup('testAdminEmail');
// Load the emailBounce in
await EmailBounces.bindKnex(knex).query().insertGraph(EMAIL_BOUNCE);
});
afterAll(async () => {
await knex.destroy();
});
it('looks up the email bounce', async () => {
const result = await EmailBounces.bindKnex(knex).query().findOne({
email: EMAIL_BOUNCE.email,
});
expect(result.email).toBe(EMAIL_BOUNCE.email);
expect(result.bounceType).toBe(EMAIL_BOUNCE.bounceType);
});
it('does not find a non-existent email bounce', async () => {
const result = await EmailBounces.bindKnex(knex).query().findOne({
email: chance.email(),
});
expect(result).toBeUndefined();
});
});

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

@ -1,7 +1,6 @@
/* 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 { Model } from 'objection';
export class EmailBounces extends Model {

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

@ -1,7 +1,6 @@
/* 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 { Model } from 'objection';
import { intBoolTransformer, uuidTransformer } from '../transformers';

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

@ -1,16 +1,13 @@
/* 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 Chance from 'chance';
import fs from 'fs';
import Knex from 'knex';
import path from 'path';
import Chance from 'chance';
import Knex from 'knex';
import { setupDatabase } from '../../../../lib/db';
import { Account } from '../../../../lib/db/models/account';
import { EmailBounces } from '../../../../lib/db/models/email-bounces';
import { Account, EmailBounces } from '.';
import { Model } from 'objection';
export type AccountIsh = Pick<
Account,
@ -80,7 +77,7 @@ export function randomEmail(account: AccountIsh, createSecondaryEmail = false) {
};
}
export async function testDatabaseSetup(): Promise<Knex> {
export async function testDatabaseSetup(dbname: string): Promise<Knex> {
// Create the db if it doesn't exist
let knex = Knex({
client: 'mysql',
@ -93,16 +90,18 @@ export async function testDatabaseSetup(): Promise<Knex> {
},
});
await knex.raw('DROP DATABASE IF EXISTS testAdmin');
await knex.raw('CREATE DATABASE testAdmin');
await knex.raw(`DROP DATABASE IF EXISTS ${dbname}`);
await knex.raw(`CREATE DATABASE ${dbname}`);
await knex.destroy();
knex = setupDatabase({
database: 'testAdmin',
host: 'localhost',
password: '',
port: 3306,
user: 'root',
knex = Knex({
connection: {
database: dbname,
host: 'localhost',
password: '',
port: 3306,
user: 'root',
},
client: 'mysql',
});
await knex.raw(accountTable);

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

@ -1,9 +1,8 @@
/* 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 { Account } from './account';
import { EmailBounces } from './email-bounces';
import { Emails } from './emails';
import { Account } from './account.model';
import { EmailBounces } from './email-bounces.model';
import { Emails } from './emails.model';
export { Account, Emails, EmailBounces };

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

@ -20,6 +20,12 @@ function buffer(value: string | Buffer) {
return value;
}
export const uuidTransformer = { to: (v: any) => buffer(v), from: (v: any) => unbuffer(v) };
export const uuidTransformer = {
to: (v: any) => buffer(v),
from: (v: any) => unbuffer(v),
};
export const intBoolTransformer = { to: (v: any) => (v ? 1 : 0), from: (v: any) => !!v };
export const intBoolTransformer = {
to: (v: any) => (v ? 1 : 0),
from: (v: any) => !!v,
};

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

@ -0,0 +1,131 @@
/* 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';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import Knex from 'knex';
import { Account, EmailBounces, Emails } from '../../database/model';
import {
randomAccount,
randomEmail,
randomEmailBounce,
testDatabaseSetup,
} from '../../database/model/helpers';
import { AccountResolver } from './account.resolver';
import { DatabaseService } from 'fxa-admin-server/src/database/database.service';
const USER_1 = randomAccount();
const EMAIL_1 = randomEmail(USER_1);
const EMAIL_2 = randomEmail(USER_1, true);
const EMAIL_BOUNCE_1 = randomEmailBounce(USER_1.email);
const USER_2 = randomAccount();
describe('AccountResolver', () => {
let resolver: AccountResolver;
let logger: any;
let knex: Knex;
let db = {
account: Account,
emails: Emails,
emailBounces: EmailBounces,
};
beforeAll(async () => {
knex = await testDatabaseSetup('testAdminAccountResolver');
// Load the users in
db.account = Account.bindKnex(knex);
db.emails = Emails.bindKnex(knex);
db.emailBounces = EmailBounces.bindKnex(knex);
await (db.account as any).query().insertGraph({
...USER_1,
emails: [EMAIL_1, EMAIL_2],
});
await db.emailBounces.query().insert(EMAIL_BOUNCE_1);
});
beforeEach(async () => {
logger = { debug: jest.fn(), error: jest.fn(), info: jest.fn() };
const MockMozLogger: Provider = {
provide: MozLoggerService,
useValue: logger,
};
const MockConfig: Provider = {
provide: ConfigService,
useValue: {
get: jest.fn().mockReturnValue({ authHeader: 'test' }),
},
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AccountResolver,
MockMozLogger,
MockConfig,
{ provide: DatabaseService, useValue: db },
],
}).compile();
resolver = module.get<AccountResolver>(AccountResolver);
});
afterAll(async () => {
await knex.destroy();
});
it('should be defined', () => {
expect(resolver).toBeDefined();
});
it('locates the user by uid', async () => {
const result = (await resolver.accountByUid(USER_1.uid, 'joe')) as Account;
expect(result).toBeDefined();
expect(result.email).toBe(USER_1.email);
expect(logger.info).toBeCalledTimes(1);
});
it('does not locate non-existent users by uid', async () => {
const result = await resolver.accountByUid(USER_2.uid, 'joe');
expect(result).toBeUndefined();
expect(logger.info).toBeCalledTimes(1);
});
it('locates the user by email', async () => {
const result = (await resolver.accountByEmail(
USER_1.email,
'joe'
)) as Account;
expect(result).toBeDefined();
expect(result.email).toBe(USER_1.email);
expect(logger.info).toBeCalledTimes(1);
});
it('does not locate non-existent users by email', async () => {
const result = await resolver.accountByEmail(USER_2.email, 'joe');
expect(result).toBeUndefined();
expect(logger.info).toBeCalledTimes(1);
});
it('loads emailBounces', async () => {
const user = (await resolver.accountByEmail(
USER_1.email,
'joe'
)) as Account;
const result = await resolver.emailBounces(user);
expect(result).toBeDefined();
const bounce = result[0];
expect(bounce).toEqual(EMAIL_BOUNCE_1);
});
it('loads emails', async () => {
const user = (await resolver.accountByEmail(
USER_1.email,
'joe'
)) as Account;
const result = await resolver.emails(user);
expect(result).toBeDefined();
expect(result).toHaveLength(2);
});
});

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

@ -1,13 +1,16 @@
/* 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 { UseGuards } from '@nestjs/common';
import { Args, Query, ResolveField, Resolver, Root } from '@nestjs/graphql';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { Arg, Ctx, FieldResolver, Query, Resolver, Root } from 'type-graphql';
import { Account, EmailBounces, Emails } from '../db/models';
import { uuidTransformer } from '../db/transformers';
import { Context } from '../server';
import { Account as AccountType } from './types/account';
import { CurrentUser } from '../../auth/auth-header.decorator';
import { GqlAuthHeaderGuard } from '../../auth/auth-header.guard';
import { DatabaseService } from '../../database/database.service';
import { Account } from '../../database/model';
import { uuidTransformer } from '../../database/transformers';
import { Account as AccountType } from '../../gql/model/account.model';
const ACCOUNT_COLUMNS = ['uid', 'email', 'emailVerified', 'createdAt'];
const EMAIL_COLUMNS = [
@ -21,12 +24,15 @@ const EMAIL_COLUMNS = [
'verifiedAt',
];
@Resolver((of) => AccountType)
@UseGuards(GqlAuthHeaderGuard)
@Resolver((of: any) => AccountType)
export class AccountResolver {
constructor(private log: MozLoggerService, private db: DatabaseService) {}
@Query((returns) => AccountType, { nullable: true })
public accountByUid(
@Arg('uid', { nullable: false }) uid: string,
@Ctx() context: Context
@Args('uid', { nullable: false }) uid: string,
@CurrentUser() user: string
) {
let uidBuffer;
try {
@ -34,31 +40,36 @@ export class AccountResolver {
} catch (err) {
return;
}
context.logAction('accountByUid', { uid });
return Account.query().select(ACCOUNT_COLUMNS).findOne({ uid: uidBuffer });
this.log.info('accountByUid', { uid, user });
return this.db.account
.query()
.select(ACCOUNT_COLUMNS)
.findOne({ uid: uidBuffer });
}
@Query((returns) => AccountType, { nullable: true })
public accountByEmail(
@Arg('email', { nullable: false }) email: string,
@Ctx() context: Context
@Args('email', { nullable: false }) email: string,
@CurrentUser() user: string
) {
context.logAction('accountByEmail', { email });
return Account.query()
this.log.info('accountByEmail', { email, user });
return this.db.account
.query()
.select(ACCOUNT_COLUMNS.map((c) => 'accounts.' + c))
.innerJoin('emails', 'emails.uid', 'accounts.uid')
.where('emails.normalizedEmail', email)
.first();
}
@FieldResolver()
@ResolveField()
public async emailBounces(@Root() account: Account) {
const uidBuffer = uuidTransformer.to(account.uid);
// MySQL Query optimizer does weird things, use separate queries to force index use
const emails = await Emails.query()
const emails = await this.db.emails
.query()
.select('emails.normalizedEmail')
.where('emails.uid', uidBuffer);
const result = await EmailBounces.query().where(
const result = await this.db.emailBounces.query().where(
'emailBounces.email',
'in',
emails.map((x) => x.normalizedEmail)
@ -66,9 +77,12 @@ export class AccountResolver {
return result;
}
@FieldResolver()
@ResolveField()
public async emails(@Root() account: Account) {
const uidBuffer = uuidTransformer.to(account.uid);
return await Emails.query().select(EMAIL_COLUMNS).where('uid', uidBuffer);
return await this.db.emails
.query()
.select(EMAIL_COLUMNS)
.where('uid', uidBuffer);
}
}

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

@ -0,0 +1,84 @@
/* 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';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import Knex from 'knex';
import { Account, EmailBounces } from '../../database/model';
import {
randomAccount,
randomEmail,
randomEmailBounce,
testDatabaseSetup,
} from '../../database/model/helpers';
import { EmailBounceResolver } from './email-bounce.resolver';
import { DatabaseService } from 'fxa-admin-server/src/database/database.service';
const USER_1 = randomAccount();
const EMAIL_1 = randomEmail(USER_1);
const EMAIL_2 = randomEmail(USER_1, true);
const EMAIL_BOUNCE_1 = randomEmailBounce(USER_1.email);
describe('EmailBounceResolver', () => {
let resolver: EmailBounceResolver;
let logger: any;
let knex: Knex;
let db = {
account: Account,
emailBounces: EmailBounces,
};
beforeAll(async () => {
knex = await testDatabaseSetup('testAdminEmailResolver');
db.emailBounces = EmailBounces.bindKnex(knex);
db.account = Account.bindKnex(knex);
// Load the users in
await (db.account as any).query().insertGraph({
...USER_1,
emails: [EMAIL_1, EMAIL_2],
});
await db.emailBounces.query().insert(EMAIL_BOUNCE_1);
});
beforeEach(async () => {
logger = { debug: jest.fn(), error: jest.fn(), info: jest.fn() };
const MockMozLogger: Provider = {
provide: MozLoggerService,
useValue: logger,
};
const MockConfig: Provider = {
provide: ConfigService,
useValue: {
get: jest.fn().mockReturnValue({ authHeader: 'test' }),
},
};
const module: TestingModule = await Test.createTestingModule({
providers: [
EmailBounceResolver,
MockMozLogger,
MockConfig,
{ provide: DatabaseService, useValue: db },
],
}).compile();
resolver = module.get<EmailBounceResolver>(EmailBounceResolver);
});
afterAll(async () => {
await knex.destroy();
});
it('should be defined', () => {
expect(resolver).toBeDefined();
});
it('should clear email bounces', async () => {
const result = await resolver.clearEmailBounce(USER_1.email, 'test');
expect(result).toBeTruthy();
expect(logger.info).toBeCalledTimes(1);
});
});

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

@ -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 { UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { CurrentUser } from '../../auth/auth-header.decorator';
import { GqlAuthHeaderGuard } from '../../auth/auth-header.guard';
import { DatabaseService } from '../../database/database.service';
import { EmailBounce as EmailBounceType } from '../../gql/model/email-bounces.model';
@UseGuards(GqlAuthHeaderGuard)
@Resolver((of: any) => EmailBounceType)
export class EmailBounceResolver {
constructor(private log: MozLoggerService, private db: DatabaseService) {}
@Mutation((returns) => Boolean)
public async clearEmailBounce(
@Args('email') email: string,
@CurrentUser() user: string
) {
const result = await this.db.emailBounces
.query()
.delete()
.where('email', email);
this.log.info('clearEmailBounce', { user, email, success: result });
return !!result;
}
}

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

@ -0,0 +1,14 @@
/* 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 { AccountResolver } from './account/account.resolver';
import { EmailBounceResolver } from './email-bounce/email-bounce.resolver';
import { DatabaseModule } from '../database/database.module';
@Module({
imports: [DatabaseModule],
providers: [AccountResolver, EmailBounceResolver],
})
export class GqlModule {}

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

@ -1,11 +1,10 @@
/* 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 { Field, ID, ObjectType } from '@nestjs/graphql';
import { Field, ID, ObjectType } from 'type-graphql';
import { EmailBounce } from './email-bounces';
import { Email } from './emails';
import { EmailBounce } from './email-bounces.model';
import { Email } from './emails.model';
@ObjectType()
export class Account {

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

@ -1,14 +1,13 @@
/* 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 { Field, ObjectType, registerEnumType } from 'type-graphql';
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
export enum BounceType {
unmapped,
Permanent,
Transient,
Complaint
Complaint,
}
export enum BounceSubType {
@ -26,14 +25,14 @@ export enum BounceSubType {
Fraud,
NotSpam,
Other,
Virus
Virus,
}
registerEnumType(BounceType, {
name: 'BounceType'
name: 'BounceType',
});
registerEnumType(BounceSubType, {
name: 'BounceSubType'
name: 'BounceSubType',
});
@ObjectType()
@ -41,10 +40,10 @@ export class EmailBounce {
@Field()
public email!: string;
@Field(type => BounceType)
@Field((type) => BounceType)
public bounceType!: string;
@Field(type => BounceSubType)
@Field((type) => BounceSubType)
public bounceSubType!: string;
@Field()

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

@ -1,8 +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 { Field, ObjectType } from 'type-graphql';
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class Email {

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

@ -1,35 +0,0 @@
/* 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 Knex from 'knex';
import { Model } from 'objection';
import { HealthExtras } from '../middleware';
import { Account } from './models/account';
export type DatabaseConfig = {
host: string;
port: number;
user: string;
password: string;
database: string;
};
export async function dbHealthCheck(): Promise<HealthExtras> {
let status = 'ok';
try {
await Account.query().limit(1);
} catch (err) {
status = 'error';
}
return {
db: { status }
};
}
export function setupDatabase(opts: DatabaseConfig): Knex {
const knex = Knex({ connection: opts, client: 'mysql' });
Model.knex(knex);
return knex;
}

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

@ -1,49 +0,0 @@
/* 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 Config from '../config';
import { Request, Response, Router } from 'express';
import { version } from './version';
export type HealthExtras = {
[key: string]: string | number | boolean | HealthExtras;
};
export function loadBalancerRoutes(healthExtras?: () => Promise<HealthExtras>): Router {
const router = Router();
router.get('/__version__', (_: Request, res: Response) => {
res.json(version).status(200);
});
router.get('/__lbheartbeat__', (_: Request, res: Response) => {
res.json({}).status(200);
});
router.get('/__heartbeat__', async (_: Request, res: Response) => {
let healthExtra: HealthExtras;
let status = 200;
try {
healthExtra = (await healthExtras?.()) ?? {};
} catch (err) {
status = 500;
healthExtra = typeof err.healthExtra === 'object' ? err.healthExtra : {};
}
res.json({ status: status === 200 ? 'ok' : 'error', ...healthExtra }).status(status);
});
return router;
}
export function strictTransportSecurity(_: Request, res: Response, next: () => void) {
if (Config.get('hstsEnabled')) {
res.header(
'Strict-Transport-Security',
`max-age=${Config.get('hstsMaxAge')}; includeSubDomains`
);
}
next();
}

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

@ -1,21 +0,0 @@
/* 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 { Arg, Ctx, Mutation, Resolver } from 'type-graphql';
import { EmailBounces } from '../db/models';
import { Context } from '../server';
import { EmailBounce } from './types/email-bounces';
@Resolver(of => EmailBounce)
export class EmailBounceResolver {
@Mutation(returns => Boolean)
public async clearEmailBounce(@Arg('email') email: string, @Ctx() context: Context) {
const result = await EmailBounces.query()
.delete()
.where('email', email);
context.logAction('clearEmailBounce', { email, success: result });
return !!result;
}
}

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

@ -1,80 +0,0 @@
/* 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 path from 'path';
import { RewriteFrames } from '@sentry/integrations';
import * as Sentry from '@sentry/node';
import { GraphQLError } from 'graphql';
import { Logger } from 'mozlog';
// Matches uid, session, oauth and other common tokens which we would
// prefer not to include in Sentry reports.
const TOKENREGEX = /[a-fA-F0-9]{32,}/gi;
const FILTERED = '[Filtered]';
/**
* Filter a sentry event for PII in addition to the default filters.
*
* Current replacements:
* - A 32-char hex string that typically is a FxA user-id.
*
* Data Removed:
* - Request body.
*
* @param event
*/
function filterSentryEvent(event: Sentry.Event, hint: unknown) {
if (event.message) {
event.message = event.message.replace(TOKENREGEX, FILTERED);
}
return event;
}
/**
* Configure Sentry with additional Sentry event filtering.
*
* @param options Sentry options to include.
*/
export function configureSentry(options?: Sentry.NodeOptions) {
Sentry.init({
...options,
beforeSend(event, hint) {
return filterSentryEvent(event, hint);
},
integrations: [
new RewriteFrames({
root: path.dirname(path.dirname(__dirname))
})
]
});
}
/**
* Report a GraphQL error to Sentry if in production mode, otherwise log it out.
*
* @param debug Debug mode or not
* @param error
*/
export function reportGraphQLError(debug: boolean, logger: Logger, error: GraphQLError) {
if (debug) {
return error;
}
if (error.name === 'ValidationError') {
return new Error('Request error');
}
const graphPath = error.path?.join('.');
logger.error('graphql', { path: graphPath, error: error.originalError?.message });
Sentry.withScope(scope => {
scope.setContext('graphql', {
path: graphPath
});
Sentry.captureException(error.originalError);
});
return new Error('Internal server error');
}

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

@ -1,57 +0,0 @@
/* 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 { ApolloServer } from 'apollo-server-express';
import { Logger } from 'mozlog';
import * as TypeGraphQL from 'type-graphql';
import { Container } from 'typedi';
import { DatabaseConfig, setupDatabase } from './db';
import { AccountResolver } from './resolvers/account-resolver';
import { EmailBounceResolver } from './resolvers/email-bounce-resolver';
import { reportGraphQLError } from './sentry';
type ServerConfig = {
authHeader: string;
database: DatabaseConfig;
env: string;
};
/**
* Context available to resolvers
*/
export type Context = {
authUser: string | undefined;
logger: Logger;
logAction: (action: string, options?: object) => {};
};
export async function createServer(
config: ServerConfig,
logger: Logger,
context?: () => object
): Promise<ApolloServer> {
setupDatabase(config.database);
const schema = await TypeGraphQL.buildSchema({
container: Container,
resolvers: [AccountResolver, EmailBounceResolver]
});
const debugMode = config.env !== 'production';
const defaultContext = ({ req }: any) => {
const authUser = req.headers[config.authHeader.toLowerCase()];
return {
authUser,
logAction: (action: string, options?: object) => {
logger.info(action, { authUser, ...options });
},
logger
};
};
return new ApolloServer({
context: context ?? defaultContext,
formatError: err => reportGraphQLError(debugMode, logger, err),
schema
});
}

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

@ -1,73 +0,0 @@
/* 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/. */
/*
* Return version info based on package.json, the git sha, and source repo
*
* Try to statically determine commitHash and sourceRepo at startup.
*
* If commitHash cannot be found from `version.json` (i.e., this is not
* production or stage), then an attempt will be made to determine commitHash
* and sourceRepo dynamically from `git`. If it cannot be found with `git`,
* just show 'unknown' for commitHash and sourceRepo.
*
* This module may be called as ./dist/lib/version.js, or when testing,
* ./lib/version.ts so we need to look for `package.json` and `version.json`
* in two possible locations.
*/
import cp from 'child_process';
import path from 'path';
function readJson(filepath: string) {
try {
return require(filepath);
} catch (e) {
/* ignore */
}
return;
}
function getValue(name: string, command: string): string {
const value =
readJson(path.resolve(__dirname, '..', 'version.json')) ||
readJson(path.resolve(__dirname, '..', '..', 'version.json'));
if (value?.version?.[name]) {
return value.version[name];
}
let stdout = 'unknown';
try {
stdout = cp.execSync(command, { cwd: __dirname }).toString('utf8');
} catch (e) {
/* ignore */
}
return stdout?.toString().trim();
}
interface Version {
commit: string;
source: string;
version: string;
}
function getVersionInfo(): Version {
const commit = getValue('hash', 'git rev-parse HEAD');
const source = getValue('source', 'git config --get remote.origin.url');
const packageInfo =
readJson(path.resolve(__dirname, '..', '..', 'package.json')) ||
readJson(path.resolve(__dirname, '..', '..', '..', 'package.json'));
return {
commit,
source,
version: packageInfo?.version
};
}
export const version = getVersionInfo();

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

@ -0,0 +1,35 @@
/* 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 { NestApplicationOptions } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { SentryInterceptor } from 'fxa-shared/nestjs/sentry/sentry.interceptor';
import helmet from 'helmet';
import { AppModule } from './app.module';
import Config, { AppConfig } from './config';
async function bootstrap() {
const nestConfig: NestApplicationOptions = {};
if (Config.getProperties().env !== 'development') {
nestConfig.logger = false;
}
const app = await NestFactory.create(AppModule, nestConfig);
const config: ConfigService<AppConfig> = app.get(ConfigService);
const port = config.get('port') as number;
if (config.get<boolean>('hstsEnabled')) {
const maxAge = config.get<number>('hstsMaxAge');
app.use(helmet.hsts({ includeSubDomains: true, maxAge }));
}
// Add sentry as error reporter
app.useGlobalInterceptors(new SentryInterceptor());
// Starts listening for shutdown hooks
app.enableShutdownHooks();
await app.listen(port);
}
bootstrap();

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

@ -1,25 +0,0 @@
/* 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 { assert } from 'chai';
import 'mocha';
import { dbHealthCheck } from '../../../lib/db';
import { testDatabaseSetup } from './models/helpers';
describe('db', () => {
describe('dbHealthCheck', () => {
it('returns ok when db is configured', async () => {
const knex = await testDatabaseSetup();
const result = await dbHealthCheck();
assert.deepEqual(result, { db: { status: 'ok' } });
await knex.destroy();
});
it('returns error when db is not configured', async () => {
const result = await dbHealthCheck();
assert.deepEqual(result, { db: { status: 'error' } });
});
});
});

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

@ -1,53 +0,0 @@
/* 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 'reflect-metadata';
import { assert } from 'chai';
import Knex from 'knex';
import 'mocha';
import { chance, randomAccount, testDatabaseSetup } from './helpers';
import { Account } from '../../../../lib/db/models/account';
import { uuidTransformer } from '../../../../lib/db/transformers';
const USER_1 = randomAccount();
describe('account model', () => {
let knex: Knex;
before(async () => {
knex = await testDatabaseSetup();
// Load the user in
await Account.query().insertGraph(USER_1);
});
after(async () => {
await knex.destroy();
});
it('looks up the user by email', async () => {
const result = await Account.query().findOne({ normalizedEmail: USER_1.normalizedEmail });
assert.equal(result.uid, USER_1.uid);
assert.equal(result.email, USER_1.email);
});
it('looks up the user by uid buffer', async () => {
const uidBuffer = uuidTransformer.to(USER_1.uid);
const result = await Account.query().findOne({ uid: uidBuffer });
assert.equal(result.uid, USER_1.uid);
assert.equal(result.email, USER_1.email);
});
it('does not find a non-existent user', async () => {
let result = await Account.query().findOne({ normalizedEmail: chance.email() });
assert.isUndefined(result);
const uid = chance.guid({ version: 4 }).replace(/-/g, '');
const uidBuffer = uuidTransformer.to(uid);
result = await Account.query().findOne({ uid: uidBuffer });
assert.isUndefined(result);
});
});

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

@ -1,41 +0,0 @@
/* 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 'reflect-metadata';
import { assert } from 'chai';
import Knex from 'knex';
import 'mocha';
import { chance, randomAccount, randomEmailBounce, testDatabaseSetup } from './helpers';
import { EmailBounces } from '../../../../lib/db/models/email-bounces';
const USER_1 = randomAccount();
const EMAIL_BOUNCE = randomEmailBounce(USER_1.email);
describe('emailBounces model', () => {
let knex: Knex;
before(async () => {
knex = await testDatabaseSetup();
// Load the emailBounce in
await EmailBounces.query().insertGraph(EMAIL_BOUNCE);
});
after(async () => {
await knex.destroy();
});
it('looks up the email bounce', async () => {
const result = await EmailBounces.query().findOne({ email: EMAIL_BOUNCE.email });
assert.equal(result.email, EMAIL_BOUNCE.email);
assert.equal(result.bounceType, EMAIL_BOUNCE.bounceType);
});
it('does not find a non-existent email bounce', async () => {
const result = await EmailBounces.query().findOne({ email: chance.email() });
assert.isUndefined(result);
});
});

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

@ -1,69 +0,0 @@
/* 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 express from 'express';
import 'mocha';
import request from 'supertest';
import { loadBalancerRoutes, strictTransportSecurity } from '../../lib/middleware';
import { version } from '../../lib/version';
describe('loadBalancerRoutes', () => {
const app = express();
app.use(loadBalancerRoutes());
it('returns version', () => {
return request(app)
.get('/__version__')
.expect('Content-Type', /json/)
.expect(200, version);
});
it('returns lbheartbeat', () => {
return request(app)
.get('/__lbheartbeat__')
.expect('Content-Type', /json/)
.expect(200, {});
});
it('returns heartbeat', () => {
return request(app)
.get('/__heartbeat__')
.expect('Content-Type', /json/)
.expect(200, { status: 'ok' });
});
it('returns heartbeat with healthchecks', () => {
const app2 = express();
app2.use(loadBalancerRoutes(async () => ({ additional: true })));
return request(app2)
.get('/__heartbeat__')
.expect('Content-Type', /json/)
.expect(200, { status: 'ok', additional: true });
});
it('returns heartbeat with erroring healthcheck', () => {
const app2 = express();
app2.use(
loadBalancerRoutes(async () => {
throw new Error('boom');
})
);
return request(app2)
.get('/__heartbeat__')
.expect('Content-Type', /json/)
.expect(200, { status: 'error' });
});
});
describe('strictTransportSecurity', () => {
const app = express();
app.use(strictTransportSecurity);
it('sets Strict-Transport-Header', () => {
return request(app)
.get('/')
.expect('Strict-Transport-Security', /max-age=\d*; includeSubDomains/);
});
});

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

@ -1,15 +0,0 @@
/* 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 { Logger } from 'mozlog';
import sinon from 'sinon';
import { stubInterface } from 'ts-sinon';
export function mockContext() {
return {
authUser: sinon.stub(),
logAction: sinon.stub(),
logger: stubInterface<Logger>()
};
}

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

@ -1,185 +0,0 @@
/* 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 'reflect-metadata';
import { assert } from 'chai';
import { graphql, GraphQLSchema } from 'graphql';
import Chance from 'chance';
import Knex from 'knex';
import 'mocha';
import { buildSchema } from 'type-graphql';
import {
randomAccount,
randomEmail,
randomEmailBounce,
testDatabaseSetup,
} from '../db/models/helpers';
import { mockContext } from '../mocks';
import { Account, EmailBounces } from '../../../lib/db/models';
import { AccountResolver } from '../../../lib/resolvers/account-resolver';
import { EmailBounceResolver } from '../../../lib/resolvers/email-bounce-resolver';
import {
BounceSubType,
BounceType,
} from '../../../lib/resolvers/types/email-bounces';
const USER_1 = randomAccount();
const EMAIL_1 = randomEmail(USER_1);
const EMAIL_2 = randomEmail(USER_1, true);
const EMAIL_BOUNCE_1 = randomEmailBounce(USER_1.email);
const USER_2 = randomAccount();
describe('accountResolver', () => {
let knex: Knex;
let schema: GraphQLSchema;
let context: ReturnType<typeof mockContext>;
before(async () => {
knex = await testDatabaseSetup();
// Load the users in
await (Account as any)
.query()
.insertGraph({ ...USER_1, emails: [EMAIL_1, EMAIL_2] });
await EmailBounces.query().insert(EMAIL_BOUNCE_1);
schema = await buildSchema({
resolvers: [AccountResolver, EmailBounceResolver],
});
});
beforeEach(async () => {
context = mockContext();
});
after(async () => {
await knex.destroy();
});
it('locates the user by uid', async () => {
const query = `query {
accountByUid(uid: "${USER_1.uid}") {
uid
email
}
}`;
const result = (await graphql(schema, query, undefined, context)) as any;
assert.isDefined(result.data);
assert.isDefined(result.data.accountByUid);
assert.deepEqual(result.data.accountByUid, {
email: USER_1.email,
uid: USER_1.uid,
});
assert.isTrue(context.logAction.calledOnce);
});
it('does not locate non-existent users by uid', async () => {
const query = `query {
accountByUid(uid: "${USER_2.uid}") {
uid
email
}
}`;
const result = (await graphql(schema, query, undefined, context)) as any;
assert.isDefined(result.data);
assert.isNull(result.data.accountByUid);
assert.isTrue(context.logAction.calledOnce);
});
it('locates the user by email', async () => {
const query = `query {
accountByEmail(email: "${USER_1.email}") {
uid
email
}
}`;
const result = (await graphql(schema, query, undefined, context)) as any;
assert.isDefined(result.data);
assert.isDefined(result.data.accountByEmail);
assert.deepEqual(result.data.accountByEmail, {
email: USER_1.email,
uid: USER_1.uid,
});
assert.isTrue(context.logAction.calledOnce);
});
it('does not locate non-existent users by email', async () => {
const query = `query {
accountByEmail(email: "${USER_2.email}") {
uid
email
}
}`;
const result = (await graphql(schema, query, undefined, context)) as any;
assert.isDefined(result.data);
assert.isNull(result.data.accountByEmail);
assert.isTrue(context.logAction.calledOnce);
});
it('loads all emails with field resolver', async () => {
const query = `query {
accountByEmail(email: "${USER_1.email}") {
emails {
email
isPrimary
isVerified
createdAt
}
}
}`;
const result = (await graphql(schema, query, undefined, context)) as any;
assert.isDefined(result.data);
assert.isDefined(result.data.accountByEmail);
assert.deepEqual(result.data.accountByEmail, {
emails: [
{
email: USER_1.email,
isPrimary: true,
isVerified: true,
createdAt: EMAIL_1.createdAt,
},
{
email: EMAIL_2.email,
isPrimary: false,
isVerified: true,
createdAt: EMAIL_2.createdAt,
},
],
});
assert.isTrue(context.logAction.calledOnce);
});
it('loads emailBounces with field resolver', async () => {
const query = `query {
accountByEmail(email: "${USER_1.email}") {
uid
email
emailBounces {
email
bounceType
bounceSubType
createdAt
}
}
}`;
const result = (await graphql(schema, query, undefined, context)) as any;
assert.isDefined(result.data);
assert.isDefined(result.data.accountByEmail);
assert.deepEqual(result.data.accountByEmail, {
email: USER_1.email,
emailBounces: [
{
bounceSubType: BounceSubType[EMAIL_BOUNCE_1.bounceSubType],
bounceType: BounceType[EMAIL_BOUNCE_1.bounceType],
createdAt: EMAIL_BOUNCE_1.createdAt,
email: EMAIL_BOUNCE_1.email,
},
],
uid: USER_1.uid,
});
assert.isTrue(context.logAction.calledOnce);
});
});

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

@ -1,95 +0,0 @@
/* 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 'reflect-metadata';
import { assert } from 'chai';
import { graphql, GraphQLSchema } from 'graphql';
import Knex from 'knex';
import 'mocha';
import { buildSchema } from 'type-graphql';
import {
randomAccount,
randomEmail,
randomEmailBounce,
testDatabaseSetup
} from '../db/models/helpers';
import { mockContext } from '../mocks';
import { Account, EmailBounces } from '../../../lib/db/models';
import { AccountResolver } from '../../../lib/resolvers/account-resolver';
import { EmailBounceResolver } from '../../../lib/resolvers/email-bounce-resolver';
const USER_1 = randomAccount();
const EMAIL_1 = randomEmail(USER_1);
const EMAIL_BOUNCE_1 = randomEmailBounce(USER_1.email);
const USER_2 = randomAccount();
describe('emailBounceResolver', () => {
let knex: Knex;
let schema: GraphQLSchema;
let context: ReturnType<typeof mockContext>;
before(async () => {
knex = await testDatabaseSetup();
// Load the users in
await (Account as any).query().insertGraph({ ...USER_1, emails: [EMAIL_1] });
await EmailBounces.query().insert(EMAIL_BOUNCE_1);
schema = await buildSchema({ resolvers: [AccountResolver, EmailBounceResolver] });
});
beforeEach(async () => {
context = mockContext();
});
after(async () => {
await knex.destroy();
});
it('clears an email bounce', async () => {
const query = `query {
accountByEmail(email: "${USER_1.email}") {
uid
email
emailBounces {
email
bounceType
bounceSubType
createdAt
}
}
}`;
let result = (await graphql(schema, query, undefined, context)) as any;
assert.isDefined(result.data);
assert.isDefined(result.data.accountByEmail);
assert.lengthOf(result.data.accountByEmail.emailBounces, 1);
assert.isTrue(context.logAction.calledOnce);
const mutation = `mutation {
clearEmailBounce(email: "${USER_1.email}")
}`;
result = (await graphql(schema, mutation, undefined, context)) as any;
assert.isDefined(result.data);
assert.isTrue(result.data.clearEmailBounce);
assert.isTrue(context.logAction.calledTwice);
result = (await graphql(schema, query, undefined, context)) as any;
assert.isDefined(result.data);
assert.isDefined(result.data.accountByEmail);
assert.lengthOf(result.data.accountByEmail.emailBounces, 0);
assert.isTrue(context.logAction.calledThrice);
});
it('fails to clear a non-existent bounce', async () => {
const mutation = `mutation {
clearEmailBounce(email: "${USER_2.email}")
}`;
const result = (await graphql(schema, mutation, undefined, context)) as any;
assert.isDefined(result.data);
assert.isFalse(result.data.clearEmailBounce);
assert.isTrue(context.logAction.calledOnce);
});
});

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

@ -1,127 +0,0 @@
/* 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 'reflect-metadata';
import { assert } from 'chai';
import {
getIntrospectionQuery,
graphql,
IntrospectionEnumType,
IntrospectionObjectType,
IntrospectionSchema,
TypeKind,
} from 'graphql';
import 'mocha';
import { buildSchema } from 'type-graphql';
import { AccountResolver } from '../../lib/resolvers/account-resolver';
import { EmailBounceResolver } from '../../lib/resolvers/email-bounce-resolver';
describe('Schema', () => {
let builtSchema;
let schemaIntrospection: IntrospectionSchema;
let queryType: IntrospectionObjectType;
let mutationType: IntrospectionObjectType;
beforeEach(async () => {
const schema = await buildSchema({
resolvers: [AccountResolver, EmailBounceResolver],
});
builtSchema = await graphql(schema, getIntrospectionQuery());
schemaIntrospection = builtSchema.data!.__schema as IntrospectionSchema;
assert.isDefined(schemaIntrospection);
queryType = schemaIntrospection.types.find(
(type) =>
type.name ===
(schemaIntrospection as IntrospectionSchema).queryType.name
) as IntrospectionObjectType;
const mutationTypeNameRef = schemaIntrospection.mutationType;
if (mutationTypeNameRef) {
mutationType = schemaIntrospection.types.find(
(type) => type.name === mutationTypeNameRef.name
) as IntrospectionObjectType;
}
});
function findTypeByName(name: string) {
return schemaIntrospection.types.find(
(type) => type.kind === TypeKind.OBJECT && type.name === name
) as IntrospectionObjectType;
}
function findEnumByName(name: string) {
return schemaIntrospection.types.find(
(type) => type.kind === TypeKind.ENUM && type.name === name
) as IntrospectionEnumType;
}
it('is created with expected types', async () => {
const queryNames = queryType.fields.map((it) => it.name);
assert.sameMembers(queryNames, ['accountByUid', 'accountByEmail']);
const mutationNames = mutationType.fields.map((it) => it.name);
assert.sameMembers(mutationNames, ['clearEmailBounce']);
const accountType = findTypeByName('Account');
assert.isDefined(accountType);
assert.lengthOf(accountType.fields, 6);
const accountTypeNames = accountType.fields.map((it) => it.name);
assert.sameMembers(accountTypeNames, [
'uid',
'email',
'emails',
'emailVerified',
'createdAt',
'emailBounces',
]);
const emailBounceType = findTypeByName('EmailBounce');
assert.isDefined(emailBounceType);
assert.lengthOf(emailBounceType.fields, 4);
const emailBounceTypeNames = emailBounceType.fields.map((it) => it.name);
assert.sameMembers(emailBounceTypeNames, [
'email',
'bounceType',
'bounceSubType',
'createdAt',
]);
const bounceTypeEnum = findEnumByName('BounceType');
assert.isDefined(bounceTypeEnum);
assert.lengthOf(bounceTypeEnum.enumValues, 4);
const bounceTypeValues = bounceTypeEnum.enumValues.map((it) => it.name);
assert.sameOrderedMembers(bounceTypeValues, [
'unmapped',
'Permanent',
'Transient',
'Complaint',
]);
const bounceSubTypeEnum = findEnumByName('BounceSubType');
assert.isDefined(bounceSubTypeEnum);
assert.lengthOf(bounceSubTypeEnum.enumValues, 15);
const bounceSubTypeValues = bounceSubTypeEnum.enumValues.map(
(it) => it.name
);
assert.sameOrderedMembers(bounceSubTypeValues, [
'unmapped',
'Undetermined',
'General',
'NoEmail',
'Suppressed',
'MailboxFull',
'MessageTooLarge',
'ContentRejected',
'AttachmentRejected',
'Abuse',
'AuthFailure',
'Fraud',
'NotSpam',
'Other',
'Virus',
]);
});
});

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

@ -1,78 +0,0 @@
/* 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 'reflect-metadata';
import { ValidationError } from 'apollo-server';
import { assert } from 'chai';
import { GraphQLError } from 'graphql';
import 'mocha';
import { Logger } from 'mozlog';
import proxyquire from 'proxyquire';
import sinon, { SinonSpy } from 'sinon';
import { stubInterface } from 'ts-sinon';
const sandbox = sinon.createSandbox();
describe('sentry', () => {
let logger: Logger;
let mockCaptureException: any;
let mockScope: any;
let reportGraphQLError: any;
const originalError = new Error('boom');
const err = new GraphQLError(
'Internal server error',
undefined,
undefined,
undefined,
['resolver', 'field'],
originalError
);
beforeEach(async () => {
mockScope = { setContext: sinon.stub() };
const mockCapture = (func: any) => {
func(mockScope);
};
mockCaptureException = sinon.stub();
reportGraphQLError = proxyquire('../../lib/sentry.ts', {
'@sentry/node': { withScope: mockCapture, captureException: mockCaptureException }
}).reportGraphQLError;
logger = stubInterface<Logger>();
});
afterEach(() => {
sandbox.reset();
});
it('captures an error', async () => {
reportGraphQLError(false, logger, err);
const errResult = mockCaptureException.args[0][0];
assert.equal(errResult.message, 'boom');
const logSpy = (logger.error as unknown) as SinonSpy;
assert.isTrue(
(logger.error as SinonSpy).calledOnceWith('graphql', {
error: 'boom',
path: 'resolver.field'
})
);
assert.isTrue(mockScope.setContext.calledOnceWith('graphql', { path: 'resolver.field' }));
});
it('skips error capture in debug mode', () => {
const result = reportGraphQLError(true, logger, err);
assert.equal(result, err);
assert.isTrue((logger.error as SinonSpy).notCalled);
});
it('changes error capture on validation bugs', () => {
const validationErr = new ValidationError('Bad form');
const result = reportGraphQLError(false, logger, validationErr);
const logSpy = (logger.error as unknown) as SinonSpy;
assert.notEqual(result, err);
assert.equal(result.message, 'Request error');
assert.isTrue(logSpy.notCalled);
});
});

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

@ -0,0 +1,35 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/__version__ (GET)', () => {
return request(app.getHttpServer())
.get('/__lbheartbeat__')
.expect(200)
.expect('{}');
});
it('/graphql (GET)', () => {
return request(app.getHttpServer())
.post('/graphql')
.send({
operationName: null,
variables: {},
query: '{accountByEmail(email:"test@test.com"){uid}}',
})
.expect(200);
});
});

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

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

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

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"baseUrl": "."
},
"references": [{ "path": "../fxa-shared" }],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

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

@ -5,8 +5,8 @@
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"noEmitHelpers": true,
"importHelpers": true,
"types": ["mocha", "mozlog"]
"importHelpers": true
},
"include": ["./src"]
"references": [{ "path": "../fxa-shared" }],
"include": ["src"]
}

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

@ -4,7 +4,9 @@
"outDir": "./dist",
"declaration": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
"experimentalDecorators": true,
"noEmitHelpers": true,
"importHelpers": true
},
"references": [{ "path": "../fxa-shared" }],
"include": ["src"]

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

@ -1,7 +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 { DynamicModule, Module } from '@nestjs/common';
import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
import { Version } from '../version';
import { HEALTH_CONFIG } from './health.constants';
@ -12,6 +12,14 @@ export interface HealthControllerConfigParams {
extraHealthData?: () => Promise<Record<string, any>>;
}
export interface HealthModuleAsyncParams
extends Pick<ModuleMetadata, 'imports' | 'providers'> {
useFactory: (
...args: any[]
) => HealthControllerConfigParams | Promise<HealthControllerConfigParams>;
inject?: any[];
}
@Module({
controllers: [HealthController],
})
@ -22,4 +30,18 @@ export class HealthModule {
providers: [{ provide: HEALTH_CONFIG, useValue: options }],
};
}
static forRootAsync(options: HealthModuleAsyncParams): DynamicModule {
return {
module: HealthModule,
imports: options.imports,
providers: [
{
provide: HEALTH_CONFIG,
useFactory: options.useFactory,
inject: options.inject || [],
},
],
};
}
}

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

@ -16776,17 +16776,19 @@ fsevents@^1.2.7:
version: 0.0.0-use.local
resolution: "fxa-admin-server@workspace:packages/fxa-admin-server"
dependencies:
"@sentry/integrations": ^5.21.1
"@sentry/node": ^5.21.1
"@types/chai": ^4.2.12
"@nestjs/common": ^7.4.4
"@nestjs/config": ^0.5.0
"@nestjs/core": ^7.4.4
"@nestjs/graphql": ^7.6.0
"@nestjs/mapped-types": ^0.1.0
"@nestjs/platform-express": ^7.4.4
"@sentry/integrations": ^5.23.0
"@sentry/node": ^5.23.0
"@types/chance": ^1.1.0
"@types/convict": ^5.2.1
"@types/eslint": 7.2.0
"@types/graphql": ^14.5.0
"@types/mocha": ^8.0.2
"@types/node": ^13.9.1
"@types/proxyquire": ^1.3.28
"@types/sinon": 9.0.5
"@types/rimraf": 3.0.0
"@types/supertest": ^2.0.10
"@types/yargs": ^15.0.5
apollo-server: ^2.16.1
@ -16794,14 +16796,19 @@ fsevents@^1.2.7:
audit-filter: ^0.5.0
chai: ^4.2.0
chance: ^1.1.6
class-transformer: ^0.3.1
class-validator: ^0.12.2
convict: ^6.0.0
convict-format-with-moment: ^6.0.0
convict-format-with-validator: ^6.0.0
eslint: ^7.6.0
express: ^4.17.1
fxa-shared: "workspace:*"
graphql: ^14.6.0
graphql-tools: ^4.0.8
helmet: ^4.1.1
jest: 26.4.2
knex: ^0.21.4
mocha: ^8.1.1
mozlog: ^3.0.1
mysql: ^2.18.1
objection: ^2.2.2
@ -16809,13 +16816,13 @@ fsevents@^1.2.7:
prettier: ^2.0.5
proxyquire: ^2.1.3
reflect-metadata: ^0.1.13
sinon: ^9.0.3
rimraf: ^3.0.2
rxjs: ^6.5.5
supertest: ^4.0.2
ts-node: ^8.10.2
ts-sinon: ^1.2.0
ts-jest: 26.1.0
ts-node: ^8.6.2
tsconfig-paths: ^3.9.0
tslib: ^2.0.1
type-graphql: ^0.17.6
typedi: ^0.8.0
typescript: 3.9.7
yargs: ^15.4.1
languageName: unknown
@ -18649,7 +18656,7 @@ fsevents@^1.2.7:
languageName: node
linkType: hard
"graphql-tag@npm:^2.11.0, graphql-tag@npm:^2.4.2, graphql-tag@npm:^2.9.2":
"graphql-tag@npm:^2.11.0":
version: 2.11.0
resolution: "graphql-tag@npm:2.11.0"
peerDependencies:
@ -18658,7 +18665,16 @@ fsevents@^1.2.7:
languageName: node
linkType: hard
"graphql-tools@npm:^4.0.0":
"graphql-tag@npm:^2.4.2, graphql-tag@npm:^2.9.2":
version: 2.10.3
resolution: "graphql-tag@npm:2.10.3"
peerDependencies:
graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0
checksum: ea7746a7ed6f166754bba089c76595a2e9c0fbb4f64421be48974d00baf91be533296a953f700ca96b04fec469af029f199c35ce3bf819a48eb3794f8190fcd0
languageName: node
linkType: hard
"graphql-tools@npm:^4.0.0, graphql-tools@npm:^4.0.8":
version: 4.0.8
resolution: "graphql-tools@npm:4.0.8"
dependencies:
@ -19365,6 +19381,13 @@ fsevents@^1.2.7:
languageName: node
linkType: hard
"helmet@npm:^4.1.1":
version: 4.1.1
resolution: "helmet@npm:4.1.1"
checksum: 7d4371e3a4b5cb60c9d0b4e1341cade3836bff9734b837e04c6571ebe1237e6c52b928a4c9e308d80d6996bdf8561105e86911d04cff2f8957d355436191a19e
languageName: node
linkType: hard
"hex-color-regex@npm:^1.1.0":
version: 1.1.0
resolution: "hex-color-regex@npm:1.1.0"