Skip to content
File renamed without changes.
4 changes: 2 additions & 2 deletions src/infra/auth-guard/auth-guard.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { ConfigurationModule } from '../configuration/configuration.module.js';
import { XApiKeyStrategy } from './strategy/index.js';
import { XApiKeyConfig } from './x-api-key.config.js';
import { X_API_KEY_CONFIG, XApiKeyConfig } from './x-api-key.config.js';

@Module({
imports: [PassportModule, ConfigurationModule.register(XApiKeyConfig)],
imports: [PassportModule, ConfigurationModule.register(X_API_KEY_CONFIG, XApiKeyConfig)],
providers: [XApiKeyStrategy],
exports: [],
})
Expand Down
12 changes: 6 additions & 6 deletions src/infra/auth-guard/strategy/x-api-key.strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createMock } from '@golevelup/ts-jest';
import { UnauthorizedException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { XApiKeyConfig } from '../x-api-key.config.js';
import { X_API_KEY_CONFIG, XApiKeyConfig } from '../x-api-key.config.js';
import { XApiKeyStrategy } from './x-api-key.strategy.js';

describe('XApiKeyStrategy', () => {
Expand All @@ -15,14 +15,14 @@ describe('XApiKeyStrategy', () => {
providers: [
XApiKeyStrategy,
{
provide: XApiKeyConfig,
provide: X_API_KEY_CONFIG,
useValue: createMock<XApiKeyConfig>(),
},
],
}).compile();

strategy = module.get(XApiKeyStrategy);
config = module.get(XApiKeyConfig);
config = module.get(X_API_KEY_CONFIG);
});

afterAll(async () => {
Expand All @@ -37,7 +37,7 @@ describe('XApiKeyStrategy', () => {
describe('when a valid api key is provided', () => {
const setup = () => {
const CORRECT_API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4';
config.X_API_ALLOWED_KEYS = [CORRECT_API_KEY];
config.xApiAllowedKeys = [CORRECT_API_KEY];

return { CORRECT_API_KEY };
};
Expand All @@ -50,7 +50,7 @@ describe('XApiKeyStrategy', () => {
describe('when an invalid api key is provided', () => {
const setup = () => {
const INVALID_API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4BAD';
config.X_API_ALLOWED_KEYS = ['some-other-key'];
config.xApiAllowedKeys = ['some-other-key'];

return { INVALID_API_KEY };
};
Expand All @@ -63,7 +63,7 @@ describe('XApiKeyStrategy', () => {
describe('when no api keys are allowed', () => {
const setup = () => {
const ANY_API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4';
config.X_API_ALLOWED_KEYS = [];
config.xApiAllowedKeys = [];

return { ANY_API_KEY };
};
Expand Down
8 changes: 4 additions & 4 deletions src/infra/auth-guard/strategy/x-api-key.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-headerapikey/lib/Strategy.js';
import { StrategyType } from '../interface/index.js';
import { XApiKeyConfig } from '../x-api-key.config.js';
import { X_API_KEY_CONFIG, XApiKeyConfig } from '../x-api-key.config.js';

@Injectable()
export class XApiKeyStrategy extends PassportStrategy(Strategy, StrategyType.API_KEY) {
public constructor(private readonly config: XApiKeyConfig) {
public constructor(@Inject(X_API_KEY_CONFIG) private readonly config: XApiKeyConfig) {
super({ header: 'X-API-KEY', prefix: '' }, false);
}

public validate(apiKey: string): boolean {
if (this.config.X_API_ALLOWED_KEYS.includes(apiKey)) {
if (this.config.xApiAllowedKeys.includes(apiKey)) {
return true;
}
throw new UnauthorizedException();
Expand Down
10 changes: 7 additions & 3 deletions src/infra/auth-guard/x-api-key.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { Transform } from 'class-transformer';
import { IsArray } from 'class-validator';
import { CommaSeparatedStringToArray } from '../../shared/transformer/index.js';
import { ConfigProperty, Configuration } from '../configuration/index.js';

export const X_API_KEY_CONFIG = 'X_API_KEY_CONFIG';
@Configuration()
export class XApiKeyConfig {
@Transform(({ value }) => value.split(',').map((part: string) => (part.split(':').pop() ?? '').trim()))
@CommaSeparatedStringToArray()
@IsArray()
public X_API_ALLOWED_KEYS!: string[];
@ConfigProperty('X_API_ALLOWED_KEYS')
public xApiAllowedKeys!: string[];
}
6 changes: 5 additions & 1 deletion src/infra/authorization/authorization.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { IsUrl } from 'class-validator';
import { ConfigProperty, Configuration } from '../configuration/index.js';

export const AUTHORIZATION_CONFIG = 'AUTHORIZATION_CONFIG';
@Configuration()
export class AuthorizationConfig {
@IsUrl({ require_tld: false })
public AUTHORIZATION_API_HOST!: string;
@ConfigProperty('AUTHORIZATION_API_HOST')
public authorizationApiHost!: string;
}
8 changes: 4 additions & 4 deletions src/infra/authorization/authorization.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@ import { Module } from '@nestjs/common';
import { ConfigurationModule } from '../configuration/configuration.module.js';
import { LoggerModule } from '../logger/logger.module.js';
import { AuthorizationApi, Configuration } from './authorization-api-client/index.js';
import { AuthorizationConfig } from './authorization.config.js';
import { AUTHORIZATION_CONFIG, AuthorizationConfig } from './authorization.config.js';
import { AuthorizationService } from './authorization.service.js';

@Module({
imports: [LoggerModule, ConfigurationModule.register(AuthorizationConfig)],
imports: [LoggerModule, ConfigurationModule.register(AUTHORIZATION_CONFIG, AuthorizationConfig)],
providers: [
{
provide: AuthorizationApi,
useFactory: (config: AuthorizationConfig): AuthorizationApi => {
const apiHost = config.AUTHORIZATION_API_HOST;
const apiHost = config.authorizationApiHost;
const configuration = new Configuration({ basePath: `${apiHost}/api/v3` });
const authorizationApi = new AuthorizationApi(configuration);

return authorizationApi;
},
inject: [AuthorizationConfig],
inject: [AUTHORIZATION_CONFIG],
},
AuthorizationService,
],
Expand Down
75 changes: 75 additions & 0 deletions src/infra/configuration/configuration.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
interface PropertyAccessKey {
propertyKey: string | symbol;
key?: string | symbol;
}

export interface WithConfigurationDecorator {
[key: string]: unknown;
getConfigKeys(): (string | symbol)[];
}

/**
* Decorator to mark a class as a configuration class.
* @returns ClassDecorator
*/
export function Configuration() {
return function ConfigurationDecorator<T extends new (...args: any[]) => object>(constructor: T): T {
return class extends constructor {
public constructor(...args: any[]) {
super(...args);
const proxyInstance = new Proxy(this, {
set: (target: any, prop: string | symbol, value: unknown): true => {
const propertyAccessKeys = this.getPropertyAccessKeys();
const propKey = propertyAccessKeys.find((item) => item.key === prop)?.propertyKey ?? prop;

Check failure on line 24 in src/infra/configuration/configuration.decorator.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest functions more than 4 levels deep.

See more on https://sonarcloud.io/project/issues?id=hpi-schul-cloud_tldraw-server&issues=AZy5UrhqN2aVe4dorc08&open=AZy5UrhqN2aVe4dorc08&pullRequest=101
if (propKey) {
target[propKey] = value;
}

return true;
},
get: (target: any, prop: string | symbol, receiver: any): any => {
const value = Reflect.get(target, prop, receiver);

return value;
},
});

return proxyInstance;

Check warning on line 38 in src/infra/configuration/configuration.decorator.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected return statement in constructor.

See more on https://sonarcloud.io/project/issues?id=hpi-schul-cloud_tldraw-server&issues=AZy5UrhqN2aVe4dorc09&open=AZy5UrhqN2aVe4dorc09&pullRequest=101
}

public getConfigKeys(): (string | symbol)[] {
const objectKeys = Object.keys(this);
const propertyAccessKeys = this.getPropertyAccessKeys();
const keys = propertyAccessKeys.map((item) => item.key ?? item.propertyKey);

for (const key of objectKeys) {
if (!propertyAccessKeys.some((item) => item.propertyKey === key)) {
keys.push(key);
}
}

return keys;
}

private getPropertyAccessKeys(): PropertyAccessKey[] {
const proto = Object.getPrototypeOf(this) as { __propertyAccessKeys?: PropertyAccessKey[] };
const propertyAccessKeys = proto.__propertyAccessKeys ?? [];

return propertyAccessKeys;
}
};
};
}

/**
* Decorator to mark a class property as a configuration property.
* @param key Optional environment variable name to map to the decorated property.If not provided, the property name will be used.
* @returns PropertyDecorator
*/
export function ConfigProperty(key?: string): PropertyDecorator {
return function (target: { __propertyAccessKeys?: PropertyAccessKey[] }, propertyKey: string | symbol) {
target.__propertyAccessKeys ??= [];
target.__propertyAccessKeys.push({ propertyKey, key });
};
}
165 changes: 165 additions & 0 deletions src/infra/configuration/configuration.factory.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/* eslint-disable max-classes-per-file */
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { IsBoolean, IsString } from 'class-validator';
import { StringToBoolean } from '../../shared/transformer/index.js';
import { ConfigProperty, Configuration } from './configuration.decorator.js';
import { ConfigurationFactory } from './configuration.factory.js';

@Configuration()
class TestConfig {
@IsString()
@ConfigProperty('TEST_VALUE_1')
public valueWithOtherEnvVar!: string;

@IsString()
@ConfigProperty('TEST_VALUE2')
public valueWithOtherEnvVarAndDefault = 'default';

@IsString()
@ConfigProperty()
public valueWithoutDefault!: string;

@IsBoolean()
@StringToBoolean()
@ConfigProperty()
public valueWithoutDefaultBoolean!: boolean;

@IsBoolean()
public valueWithDefaultBoolean = true;
}

describe(ConfigurationFactory.name, () => {
let module: TestingModule;
let configFactory: ConfigurationFactory;
let configService: DeepMocked<ConfigService>;

beforeAll(async () => {
module = await Test.createTestingModule({
providers: [
{
provide: ConfigService,
useValue: createMock<ConfigService>(),
},
],
}).compile();

configService = module.get(ConfigService);
configFactory = new ConfigurationFactory(configService);
});

afterAll(async () => {
await module.close();
});

afterEach(() => {
jest.resetAllMocks();
});

describe('loadAndValidateConfigs', () => {
describe('when value is valid', () => {
const setup = () => {
jest.spyOn(configService, 'get').mockImplementation((key: string) => {
if (key === 'TEST_VALUE_1') {
return 'test';
}
if (key === 'TEST_VALUE2') {
return 'test2';
}
if (key === 'valueWithoutDefault') {
return 'testValueWithD';
}
if (key === 'valueWithoutDefaultBoolean') {
return 'true';
}

return undefined;
});
};
describe('when @ConfigProperty("TEST_VALUE_1") is provided by env variable', () => {
it('should return the correct value', () => {
setup();
const result = configFactory.loadAndValidateConfigs(TestConfig);

expect(result.valueWithOtherEnvVar).toEqual('test');
});
});

describe('when @ConfigProperty("TEST_VALUE2") is provided by env variable', () => {
it('should return the correct value', () => {
setup();
const result = configFactory.loadAndValidateConfigs(TestConfig);

expect(result.valueWithOtherEnvVarAndDefault).toEqual('test2');
});
});

describe('when @ConfigProperty() without default is provided by env variable', () => {
it('should return the correct value', () => {
setup();
const result = configFactory.loadAndValidateConfigs(TestConfig);

expect(result.valueWithoutDefault).toEqual('testValueWithD');
});
});

describe('when @ConfigProperty() without default is provided by env variable and transformed to boolean', () => {
it('should return the correct value', () => {
setup();
const result = configFactory.loadAndValidateConfigs(TestConfig);

expect(result.valueWithoutDefaultBoolean).toEqual(true);
});
});

describe('when @ConfigProperty() with default is not provided by env variable', () => {
it('should return the default value', () => {
setup();
const result = configFactory.loadAndValidateConfigs(TestConfig);

expect(result.valueWithDefaultBoolean).toEqual(true);
});
});
});

describe('when value is not valid', () => {
const setup = () => {
jest.spyOn(configService, 'get').mockImplementation((key: string) => {
if (key === 'TEST_VALUE_1') {
return 123;
}
if (key === 'TEST_VALUE2') {
return 'test2';
}
if (key === 'testValueWithD') {
return 'testValueWithD';
}
if (key === 'testValueWith') {
return 'true';
}

return undefined;
});
};
it('should throw error', () => {
setup();
expect(() => configFactory.loadAndValidateConfigs(TestConfig)).toThrow(/isString/);
});
});

describe('when The class is not decorated with @Configuration()', () => {
class InvalidConfig {
@IsString()
@ConfigProperty('TEST_VALUE_1')
public TEST_VALUE!: string;
}

it('should throw error', () => {
expect(() => configFactory.loadAndValidateConfigs(InvalidConfig)).toThrow(
`The class InvalidConfig is not decorated with @Configuration()`,
);
});
});
});
});
Loading
Loading