Skip to content

Commit

Permalink
Merge branch 'minor' into major
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Feb 2, 2023
2 parents 7913b9a + ab806b2 commit 081a01a
Show file tree
Hide file tree
Showing 22 changed files with 275 additions and 55 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ export class DefaultInterceptor implements HttpInterceptor {
const firstCode: string = graqhQLErrors[0]?.extensions?.code;
if (firstCode === 'FORBIDDEN') {
this.authService.logOut().subscribe(() => {
const { loginUrl } = getAppConfig();
if (loginUrl) {
window.location.href = loginUrl;
return;
}

if (!window.location.pathname.includes('login')) {
const path = graqhQLErrors[0].path.join(' > ');
this.displayErrorNotification(_(`error.403-forbidden`), { path });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ul.nav {
overflow-x: auto;
}
1 change: 1 addition & 0 deletions packages/asset-server-plugin/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const loggerCtx = 'AssetServerPlugin';
export const DEFAULT_CACHE_HEADER = 'public, max-age=15552000';
18 changes: 17 additions & 1 deletion packages/asset-server-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import fs from 'fs-extra';
import path from 'path';

import { getValidFormat } from './common';
import { loggerCtx } from './constants';
import { DEFAULT_CACHE_HEADER, loggerCtx } from './constants';
import { defaultAssetStorageStrategyFactory } from './default-asset-storage-strategy-factory';
import { HashedAssetNamingStrategy } from './hashed-asset-naming-strategy';
import { SharpAssetPreviewStrategy } from './sharp-asset-preview-strategy';
Expand Down Expand Up @@ -151,6 +151,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
{ name: 'large', width: 800, height: 800, mode: 'resize' },
];
private static options: AssetServerOptions;
private cacheHeader: string;

/**
* @description
Expand Down Expand Up @@ -196,6 +197,20 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
}
}

// Configure Cache-Control header
const { cacheHeader } = AssetServerPlugin.options;
if (!cacheHeader) {
this.cacheHeader = DEFAULT_CACHE_HEADER;
} else {
if (typeof cacheHeader === 'string') {
this.cacheHeader = cacheHeader;
} else {
this.cacheHeader = [cacheHeader.restriction, `max-age: ${cacheHeader.maxAge}`]
.filter(value => !!value)
.join(', ');
}
}

const cachePath = path.join(AssetServerPlugin.options.assetUploadDir, this.cacheDir);
fs.ensureDirSync(cachePath);
}
Expand Down Expand Up @@ -232,6 +247,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
}
res.contentType(mimeType);
res.setHeader('content-security-policy', `default-src 'self'`);
res.setHeader('Cache-Control', this.cacheHeader);
res.send(file);
} catch (e: any) {
const err = new Error('File not found');
Expand Down
29 changes: 29 additions & 0 deletions packages/asset-server-plugin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ export interface ImageTransformPreset {
mode: ImageTransformMode;
}

/**
* @description
* A configuration option for the Cache-Control header in the AssetServerPlugin asset response.
*
* @docsCategory AssetServerPlugin
*/
export type CacheConfig = {
/**
* @description
* The max-age=N response directive indicates that the response remains fresh until N seconds after the response is generated.
*/
maxAge: number;
/**
* @description
* The `private` response directive indicates that the response can be stored only in a private cache (e.g. local caches in browsers).
* The `public` response directive indicates that the response can be stored in a shared cache.
*/
restriction?: 'public' | 'private';
};

/**
* @description
* The configuration options for the AssetServerPlugin.
Expand Down Expand Up @@ -117,4 +137,13 @@ export interface AssetServerOptions {
storageStrategyFactory?: (
options: AssetServerOptions,
) => AssetStorageStrategy | Promise<AssetStorageStrategy>;
/**
* @description
* Configures the `Cache-Control` directive for response to control caching in browsers and shared caches (e.g. Proxies, CDNs).
* Defaults to publicly cached for 6 months.
*
* @default 'public, max-age=15552000'
* @since 1.9.3
*/
cacheHeader?: CacheConfig | string;
}
100 changes: 99 additions & 1 deletion packages/core/e2e/auth.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* tslint:disable:no-non-null-assertion */
import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '@vendure/common/lib/shared-constants';
import { createTestEnvironment } from '@vendure/testing';
import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
import { DocumentNode } from 'graphql';
import gql from 'graphql-tag';
import path from 'path';
Expand All @@ -11,9 +11,11 @@ import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-conf
import { ProtectedFieldsPlugin, transactions } from './fixtures/test-plugins/with-protected-field-resolver';
import { ErrorCode, Permission } from './graphql/generated-e2e-admin-types';
import * as Codegen from './graphql/generated-e2e-admin-types';
import * as CodegenShop from './graphql/generated-e2e-shop-types';
import {
ATTEMPT_LOGIN,
CREATE_ADMINISTRATOR,
CREATE_CUSTOMER,
CREATE_CUSTOMER_GROUP,
CREATE_PRODUCT,
CREATE_ROLE,
Expand Down Expand Up @@ -174,6 +176,102 @@ describe('Authorization & permissions', () => {
});
});

describe('administrator and customer users with the same email address', () => {
const emailAddress = 'same-email@test.com';
const adminPassword = 'admin-password';
const customerPassword = 'customer-password';

const loginErrorGuard: ErrorResultGuard<Codegen.CurrentUserFragment> = createErrorResultGuard(
input => !!input.identifier,
);

beforeAll(async () => {
await adminClient.asSuperAdmin();

await adminClient.query<
Codegen.CreateAdministratorMutation,
Codegen.CreateAdministratorMutationVariables
>(CREATE_ADMINISTRATOR, {
input: {
emailAddress,
firstName: 'First',
lastName: 'Last',
password: adminPassword,
roleIds: ['1'],
},
});

await adminClient.query<Codegen.CreateCustomerMutation, Codegen.CreateCustomerMutationVariables>(
CREATE_CUSTOMER,
{
input: {
emailAddress,
firstName: 'First',
lastName: 'Last',
},
password: customerPassword,
},
);
});

beforeEach(async () => {
await adminClient.asAnonymousUser();
await shopClient.asAnonymousUser();
});

it('can log in as an administrator', async () => {
const loginResult = await adminClient.query<
CodegenShop.AttemptLoginMutation,
CodegenShop.AttemptLoginMutationVariables
>(ATTEMPT_LOGIN, {
username: emailAddress,
password: adminPassword,
});

loginErrorGuard.assertSuccess(loginResult.login);
expect(loginResult.login.identifier).toEqual(emailAddress);
});

it('can log in as a customer', async () => {
const loginResult = await shopClient.query<
CodegenShop.AttemptLoginMutation,
CodegenShop.AttemptLoginMutationVariables
>(ATTEMPT_LOGIN, {
username: emailAddress,
password: customerPassword,
});

loginErrorGuard.assertSuccess(loginResult.login);
expect(loginResult.login.identifier).toEqual(emailAddress);
});

it('cannot log in as an administrator using a customer password', async () => {
const loginResult = await adminClient.query<
CodegenShop.AttemptLoginMutation,
CodegenShop.AttemptLoginMutationVariables
>(ATTEMPT_LOGIN, {
username: emailAddress,
password: customerPassword,
});

loginErrorGuard.assertErrorResult(loginResult.login);
expect(loginResult.login.errorCode).toEqual(ErrorCode.INVALID_CREDENTIALS_ERROR);
});

it('cannot log in as a customer using an administrator password', async () => {
const loginResult = await shopClient.query<
CodegenShop.AttemptLoginMutation,
CodegenShop.AttemptLoginMutationVariables
>(ATTEMPT_LOGIN, {
username: emailAddress,
password: adminPassword,
});

loginErrorGuard.assertErrorResult(loginResult.login);
expect(loginResult.login.errorCode).toEqual(ErrorCode.INVALID_CREDENTIALS_ERROR);
});
});

describe('protected field resolvers', () => {
let readCatalogAdmin: { identifier: string; password: string };
let transactionsAdmin: { identifier: string; password: string };
Expand Down
42 changes: 39 additions & 3 deletions packages/core/e2e/customer.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from './graphql/generated-e2e-shop-types';
import {
CREATE_ADDRESS,
CREATE_ADMINISTRATOR,
CREATE_CUSTOMER,
DELETE_CUSTOMER,
DELETE_CUSTOMER_NOTE,
Expand Down Expand Up @@ -123,16 +124,51 @@ describe('Customer resolver', () => {
});

it('customer resolver resolves User', async () => {
const emailAddress = 'same-email@test.com';

// Create an administrator with the same email first in order to ensure the right user is resolved.
// This test also validates that a customer can be created with the same identifier
// of an existing administrator
const { createAdministrator } = await adminClient.query<
Codegen.CreateAdministratorMutation,
Codegen.CreateAdministratorMutationVariables
>(CREATE_ADMINISTRATOR, {
input: {
emailAddress,
firstName: 'First',
lastName: 'Last',
password: '123',
roleIds: ['1'],
},
});

expect(createAdministrator.emailAddress).toEqual(emailAddress);

const { createCustomer } = await adminClient.query<
Codegen.CreateCustomerMutation,
Codegen.CreateCustomerMutationVariables
>(CREATE_CUSTOMER, {
input: {
emailAddress,
firstName: 'New',
lastName: 'Customer',
},
password: 'test',
});

customerErrorGuard.assertSuccess(createCustomer);
expect(createCustomer.emailAddress).toEqual(emailAddress);

const { customer } = await adminClient.query<
Codegen.GetCustomerWithUserQuery,
Codegen.GetCustomerWithUserQueryVariables
>(GET_CUSTOMER_WITH_USER, {
id: firstCustomer.id,
id: createCustomer.id,
});

expect(customer!.user).toEqual({
id: 'T_2',
identifier: firstCustomer.emailAddress,
id: createCustomer.user?.id,
identifier: emailAddress,
verified: true,
});
});
Expand Down
4 changes: 4 additions & 0 deletions packages/core/e2e/graphql/shared-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ export const ATTEMPT_LOGIN = gql`
mutation AttemptLogin($username: String!, $password: String!, $rememberMe: Boolean) {
login(username: $username, password: $password, rememberMe: $rememberMe) {
...CurrentUser
... on ErrorResult {
errorCode
message
}
}
}
${CURRENT_USER_FRAGMENT}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class CustomerEntityResolver {
return customer.user;
}

return this.userService.getUserByEmailAddress(ctx, customer.emailAddress);
return this.userService.getUserByEmailAddress(ctx, customer.emailAddress, 'customer');
}
}

Expand Down
14 changes: 5 additions & 9 deletions packages/core/src/config/auth/native-authentication-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,15 @@ export class NativeAuthenticationStrategy implements AuthenticationStrategy<Nati

private connection: TransactionalConnection;
private passwordCipher: import('../../service/helpers/password-cipher/password-cipher').PasswordCipher;
private userService: import('../../service/services/user.service').UserService;

async init(injector: Injector) {
this.connection = injector.get(TransactionalConnection);
// This is lazily-loaded to avoid a circular dependency
// These are lazily-loaded to avoid a circular dependency
const { PasswordCipher } = await import('../../service/helpers/password-cipher/password-cipher');
const { UserService } = await import('../../service/services/user.service');
this.passwordCipher = injector.get(PasswordCipher);
this.userService = injector.get(UserService);
}

defineInputType(): DocumentNode {
Expand All @@ -48,7 +51,7 @@ export class NativeAuthenticationStrategy implements AuthenticationStrategy<Nati
}

async authenticate(ctx: RequestContext, data: NativeAuthenticationData): Promise<User | false> {
const user = await this.getUserFromIdentifier(ctx, data.username);
const user = await this.userService.getUserByEmailAddress(ctx, data.username);
if (!user) {
return false;
}
Expand All @@ -59,13 +62,6 @@ export class NativeAuthenticationStrategy implements AuthenticationStrategy<Nati
return user;
}

private getUserFromIdentifier(ctx: RequestContext, identifier: string): Promise<User | undefined> {
return this.connection.getRepository(ctx, User).findOne({
where: { identifier, deletedAt: null },
relations: ['roles', 'roles.channels', 'authenticationMethods'],
});
}

/**
* Verify the provided password against the one we have for the given user.
*/
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/service/services/customer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,12 +227,11 @@ export class CustomerService {
deletedAt: null,
},
});
const existingUser = await this.connection.getRepository(ctx, User).findOne({
where: {
identifier: input.emailAddress,
deletedAt: null,
},
});
const existingUser = await this.userService.getUserByEmailAddress(
ctx,
input.emailAddress,
'customer',
);

if (existingCustomer && existingUser) {
// Customer already exists, bring to this Channel
Expand Down Expand Up @@ -326,6 +325,7 @@ export class CustomerService {
const existingUserWithEmailAddress = await this.userService.getUserByEmailAddress(
ctx,
input.emailAddress,
'customer',
);

if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ export class ProductOptionGroupService {
};
}

for (const option of optionGroup.options) {
const optionsToDelete = optionGroup.options && optionGroup.options.filter(group => !group.deletedAt);

for (const option of optionsToDelete) {
const { result, message } = await this.productOptionService.delete(ctx, option.id);
if (result === DeletionResult.NOT_DELETED) {
await this.connection.rollBackTransaction(ctx);
Expand Down
Loading

0 comments on commit 081a01a

Please sign in to comment.