Skip to content

Commit

Permalink
N21-1630 populate sanis user import (#4707)
Browse files Browse the repository at this point in the history
* adds schulconnex client module
* adds new user import endpoint to populate the import user with data from sanis which is using the schulconnex api
  • Loading branch information
arnegns authored Jan 25, 2024
1 parent f7f0c73 commit 74cc4d1
Show file tree
Hide file tree
Showing 164 changed files with 2,705 additions and 851 deletions.
20 changes: 20 additions & 0 deletions apps/server/src/infra/schulconnex-client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export { SchulconnexRestClientOptions } from './schulconnex-rest-client-options';
export { SchulconnexClientModule } from './schulconnex-client.module';
export { SchulconnexRestClient } from './schulconnex-rest-client';
export {
SanisResponse,
SanisRole,
SanisGroupRole,
SanisGroupType,
SanisGruppenResponse,
SanisResponseValidationGroups,
SanisPersonResponse,
SanisAnschriftResponse,
SanisGruppenzugehoerigkeitResponse,
SanisGruppeResponse,
SanisNameResponse,
SanisOrganisationResponse,
SanisPersonenkontextResponse,
SanisSonstigeGruppenzugehoerigeResponse,
} from './response';
export { schulconnexResponseFactory } from './testing/schulconnex-response-factory';
1 change: 1 addition & 0 deletions apps/server/src/infra/schulconnex-client/loggable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SchulconnexConfigurationMissingLoggable } from './schulconnex-configuration-missing.loggable';
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { SchulconnexConfigurationMissingLoggable } from './schulconnex-configuration-missing.loggable';

describe(SchulconnexConfigurationMissingLoggable.name, () => {
describe('getLogMessage', () => {
it('should return a log message', () => {
const loggable: SchulconnexConfigurationMissingLoggable = new SchulconnexConfigurationMissingLoggable();

const logMessage = loggable.getLogMessage();

expect(logMessage).toEqual({
message:
'SchulconnexRestClient: Missing configuration. Please check your environment variables in SCHULCONNEX_CLIENT.',
});
});
});
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger';

export class UserMigrationIsNotEnabled implements Loggable {
export class SchulconnexConfigurationMissingLoggable implements Loggable {
getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage {
return {
message: 'Feature flag of user migration may be disable or the school is not an LDAP pilot',
message: `SchulconnexRestClient: Missing configuration. Please check your environment variables in SCHULCONNEX_CLIENT.`,
};
}
}
1 change: 1 addition & 0 deletions apps/server/src/infra/schulconnex-client/request/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SchulconnexPersonenInfoParams } from './schulconnex-personen-info-params';
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type SchulconnexPropertyContext = 'personen' | 'personenkontexte' | 'organisationen' | 'gruppen' | 'beziehungen';

export interface SchulconnexPersonenInfoParams {
vollstaendig?: SchulconnexPropertyContext[];

pid?: string;

'personenkontext.id'?: string;

'organisation.id'?: string;

'gruppe.id'?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { SchulconnexPersonenInfoParams } from './request';
import { SanisResponse } from './response';

export interface SchulconnexApiInterface {
getPersonInfo(accessToken: string, options?: { overrideUrl: string }): Promise<SanisResponse>;

getPersonenInfo(params: SchulconnexPersonenInfoParams): Promise<SanisResponse[]>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { OauthAdapterService, OauthModule } from '@modules/oauth';
import { HttpModule, HttpService } from '@nestjs/axios';
import { DynamicModule, Global, Module } from '@nestjs/common';
import { Logger, LoggerModule } from '@src/core/logger';
import { SchulconnexRestClient } from './schulconnex-rest-client';
import { SchulconnexRestClientOptions } from './schulconnex-rest-client-options';

@Global()
/**
* @Global is used here to make sure that the module is only instantiated once, with the configuration and can be used in every module.
* Otherwise, you need to import the module with configuration in every module where you want to use it.
*/
@Module({})
export class SchulconnexClientModule {
static register(options: SchulconnexRestClientOptions): DynamicModule {
return {
imports: [HttpModule, OauthModule, LoggerModule],
module: SchulconnexClientModule,
providers: [
{
provide: SchulconnexRestClient,
useFactory: (httpService: HttpService, oauthAdapterService: OauthAdapterService, logger: Logger) =>
new SchulconnexRestClient(options, httpService, oauthAdapterService, logger),
inject: [HttpService, OauthAdapterService, Logger],
},
],
exports: [SchulconnexRestClient],
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface SchulconnexRestClientOptions {
apiUrl: string;

tokenEndpoint: string;

clientId: string;

clientSecret: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { OauthAdapterService, OAuthTokenDto } from '@modules/oauth';
import { HttpService } from '@nestjs/axios';
import { axiosResponseFactory } from '@shared/testing';
import { Logger } from '@src/core/logger';
import { of } from 'rxjs';
import { SchulconnexConfigurationMissingLoggable } from './loggable';
import { SanisResponse } from './response';
import { SchulconnexRestClient } from './schulconnex-rest-client';
import { SchulconnexRestClientOptions } from './schulconnex-rest-client-options';
import { schulconnexResponseFactory } from './testing';

describe(SchulconnexRestClient.name, () => {
let client: SchulconnexRestClient;

let httpService: DeepMocked<HttpService>;
let oauthAdapterService: DeepMocked<OauthAdapterService>;
let logger: DeepMocked<Logger>;
const options: SchulconnexRestClientOptions = {
apiUrl: 'https://schulconnex.url/api',
clientId: 'clientId',
clientSecret: 'clientSecret',
tokenEndpoint: 'https://schulconnex.url/token',
};

beforeAll(() => {
httpService = createMock<HttpService>();
oauthAdapterService = createMock<OauthAdapterService>();
logger = createMock<Logger>();

client = new SchulconnexRestClient(options, httpService, oauthAdapterService, logger);
});

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

describe('constructor', () => {
describe('when configuration is missing', () => {
const setup = () => {
const badOptions: SchulconnexRestClientOptions = {
apiUrl: '',
clientId: '',
clientSecret: '',
tokenEndpoint: '',
};
return {
badOptions,
};
};

it('should log a message', () => {
const { badOptions } = setup();

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const badOptionsClient = new SchulconnexRestClient(badOptions, httpService, oauthAdapterService, logger);

expect(logger.debug).toHaveBeenCalledWith(new SchulconnexConfigurationMissingLoggable());
});
});
});

describe('getPersonInfo', () => {
describe('when requesting person-info', () => {
const setup = () => {
const accessToken = 'accessToken';
const response: SanisResponse = schulconnexResponseFactory.build();

httpService.get.mockReturnValueOnce(of(axiosResponseFactory.build({ data: response })));

return {
accessToken,
response,
};
};

it('should make a request to a SchulConneX-API', async () => {
const { accessToken } = setup();

await client.getPersonInfo(accessToken);

expect(httpService.get).toHaveBeenCalledWith(`${options.apiUrl}/person-info`, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Accept-Encoding': 'gzip',
},
});
});

it('should return the response', async () => {
const { accessToken, response } = setup();

const result: SanisResponse = await client.getPersonInfo(accessToken);

expect(result).toEqual(response);
});
});

describe('when overriding the url', () => {
const setup = () => {
const accessToken = 'accessToken';
const customUrl = 'https://override.url/person-info';
const response: SanisResponse = schulconnexResponseFactory.build();

httpService.get.mockReturnValueOnce(of(axiosResponseFactory.build({ data: response })));

return {
accessToken,
customUrl,
};
};

it('should make a request to a SchulConneX-API', async () => {
const { accessToken, customUrl } = setup();

await client.getPersonInfo(accessToken, { overrideUrl: customUrl });

expect(httpService.get).toHaveBeenCalledWith(customUrl, expect.anything());
});
});
});

describe('getPersonenInfo', () => {
describe('when requesting personen-info', () => {
const setup = () => {
const tokens: OAuthTokenDto = new OAuthTokenDto({
idToken: 'id_token',
accessToken: 'access_token',
refreshToken: 'refresh_token',
});
const response: SanisResponse[] = schulconnexResponseFactory.buildList(2);

oauthAdapterService.sendTokenRequest.mockResolvedValueOnce(tokens);
httpService.get.mockReturnValueOnce(of(axiosResponseFactory.build({ data: response })));

return {
tokens,
response,
};
};

it('should make a request to a SchulConneX-API', async () => {
const { tokens } = setup();

await client.getPersonenInfo({ 'organisation.id': '1234', vollstaendig: ['personen', 'organisationen'] });

expect(httpService.get).toHaveBeenCalledWith(
`${options.apiUrl}/personen-info?organisation.id=1234&vollstaendig=personen%2Corganisationen`,
{
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
'Accept-Encoding': 'gzip',
},
}
);
});

it('should return the response', async () => {
const { response } = setup();

const result: SanisResponse[] = await client.getPersonenInfo({ 'organisation.id': '1234' });

expect(result).toEqual(response);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { OauthAdapterService, OAuthTokenDto } from '@modules/oauth';
import { OAuthGrantType } from '@modules/oauth/interface/oauth-grant-type.enum';
import { ClientCredentialsGrantTokenRequest } from '@modules/oauth/service/dto';
import { HttpService } from '@nestjs/axios';
import { Logger } from '@src/core/logger';
import { AxiosResponse } from 'axios';
import QueryString from 'qs';
import { lastValueFrom, Observable } from 'rxjs';
import { SchulconnexConfigurationMissingLoggable } from './loggable';
import { SchulconnexPersonenInfoParams } from './request';
import { SanisResponse } from './response';
import { SchulconnexApiInterface } from './schulconnex-api.interface';
import { SchulconnexRestClientOptions } from './schulconnex-rest-client-options';

export class SchulconnexRestClient implements SchulconnexApiInterface {
private readonly SCHULCONNEX_API_BASE_URL: string;

constructor(
private readonly options: SchulconnexRestClientOptions,
private readonly httpService: HttpService,
private readonly oauthAdapterService: OauthAdapterService,
private readonly logger: Logger
) {
this.checkOptions();
this.SCHULCONNEX_API_BASE_URL = options.apiUrl;
}

// TODO: N21-1678 use this in provisioning module
public async getPersonInfo(accessToken: string, options?: { overrideUrl: string }): Promise<SanisResponse> {
const url: URL = new URL(options?.overrideUrl ?? `${this.SCHULCONNEX_API_BASE_URL}/person-info`);

const response: Promise<SanisResponse> = this.getRequest<SanisResponse>(url, accessToken);

return response;
}

public async getPersonenInfo(params: SchulconnexPersonenInfoParams): Promise<SanisResponse[]> {
const token: OAuthTokenDto = await this.requestClientCredentialToken();

const url: URL = new URL(`${this.SCHULCONNEX_API_BASE_URL}/personen-info`);
url.search = QueryString.stringify(params, { arrayFormat: 'comma' });

const response: Promise<SanisResponse[]> = this.getRequest<SanisResponse[]>(url, token.accessToken);

return response;
}

private checkOptions(): void {
if (!this.options.apiUrl || !this.options.clientId || !this.options.clientSecret || !this.options.tokenEndpoint) {
this.logger.debug(new SchulconnexConfigurationMissingLoggable());
}
}

private async getRequest<T>(url: URL, accessToken: string): Promise<T> {
const observable: Observable<AxiosResponse<T>> = this.httpService.get(url.toString(), {
headers: {
Authorization: `Bearer ${accessToken}`,
'Accept-Encoding': 'gzip',
},
});

const responseToken: AxiosResponse<T> = await lastValueFrom(observable);

return responseToken.data;
}

private async requestClientCredentialToken(): Promise<OAuthTokenDto> {
const { tokenEndpoint, clientId, clientSecret } = this.options;

const payload: ClientCredentialsGrantTokenRequest = new ClientCredentialsGrantTokenRequest({
client_id: clientId,
client_secret: clientSecret,
grant_type: OAuthGrantType.CLIENT_CREDENTIALS_GRANT,
});

const tokenDto: OAuthTokenDto = await this.oauthAdapterService.sendTokenRequest(tokenEndpoint, payload);

return tokenDto;
}
}
1 change: 1 addition & 0 deletions apps/server/src/infra/schulconnex-client/testing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { schulconnexResponseFactory } from './schulconnex-response-factory';
Loading

0 comments on commit 74cc4d1

Please sign in to comment.