Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

N21-1630 populate sanis user import #4707

Merged
merged 35 commits into from
Jan 25, 2024
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b0d9491
N21-1630 WIP
arnegns Jan 17, 2024
fa20ebc
mapToImportUser
IgorCapCoder Jan 17, 2024
65a8a6f
N21-1630 WIP 2
arnegns Jan 17, 2024
fe3d0c3
N21-1630 WIP 3
arnegns Jan 18, 2024
0678e79
N21-1630 WIP 4
arnegns Jan 19, 2024
942567f
N21-1630 WIP 5
arnegns Jan 19, 2024
3e3a71f
N21-1630 WIP 6
arnegns Jan 19, 2024
1d27df0
N21-1630 WIP 7
arnegns Jan 19, 2024
a5883e2
N21-1630 WIP 8
arnegns Jan 19, 2024
78da7fb
N21-1630 WIP 9
arnegns Jan 19, 2024
c9aac23
N21-1630 WIP 10
arnegns Jan 19, 2024
a3ad3fc
- add matching of import users
MarvinOehlerkingCap Jan 22, 2024
f57b22f
tests
MarvinOehlerkingCap Jan 23, 2024
4ceeb87
Merge branch 'main' into N21-1630-fetch-sanis-user-import
arnegns Jan 24, 2024
dec85ec
N21-1630 optimizes matching logic
arnegns Jan 24, 2024
c858c81
N21-1630 fixes linter
arnegns Jan 24, 2024
4e761a3
N21-1630 adjusts tests
arnegns Jan 24, 2024
494c08c
Merge branch 'main' into N21-1630-fetch-sanis-user-import
arnegns Jan 24, 2024
7d122de
N21-1630 fixes test
arnegns Jan 24, 2024
37ed342
Merge branch 'main' into N21-1630-fetch-sanis-user-import
arnegns Jan 24, 2024
730d54c
N21-1630 adds comment to SchulconnexClientModule
arnegns Jan 24, 2024
9a3f518
N21-1630 adds new envs to for tests
arnegns Jan 25, 2024
26106f7
N21-1630 changes function name to standard
arnegns Jan 25, 2024
5ad0267
N21-1630 adds logger when options are bad
arnegns Jan 25, 2024
63e877c
N21-1630 changes severity
arnegns Jan 25, 2024
cc13aca
N21-1630 renaming of endpoint
arnegns Jan 25, 2024
9823b8d
N21-1630 renaming of endpoint 2
arnegns Jan 25, 2024
1621bcd
N21-1630 adjust envs
arnegns Jan 25, 2024
047e8e0
N21-1630 review changes
arnegns Jan 25, 2024
abfefd2
N21-1630 test fixes
arnegns Jan 25, 2024
3163c52
Merge branch 'main' into N21-1630-fetch-sanis-user-import
arnegns Jan 25, 2024
8cff9b3
N21-1630 test fixes 2
arnegns Jan 25, 2024
174afcc
N21-1630 test fixes 3
arnegns Jan 25, 2024
229cd21
N21-1630 test fixes 4
arnegns Jan 25, 2024
61f51ab
N21-1630 adds loggable tests again
arnegns Jan 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/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,29 @@
import { OauthAdapterService, OauthModule } from '@modules/oauth';
import { HttpModule, HttpService } from '@nestjs/axios';
import { DynamicModule, Global, Module } from '@nestjs/common';
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],
module: SchulconnexClientModule,
providers: [
{
provide: SchulconnexRestClient,
useFactory: (httpService: HttpService, oauthAdapterService: OauthAdapterService) =>
new SchulconnexRestClient(options, httpService, oauthAdapterService),
inject: [HttpService, OauthAdapterService],
},
],
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,144 @@
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { OauthAdapterService, OAuthTokenDto } from '@modules/oauth';
import { HttpService } from '@nestjs/axios';
import { TestingModule } from '@nestjs/testing';
import { axiosResponseFactory } from '@shared/testing';
import { of } from 'rxjs';
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 module: TestingModule;
let client: SchulconnexRestClient;

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

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

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

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

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

describe('getPersonInfo', () => {
describe('when requesting person-info', () => {
const setup = () => {
const accessToken = 'accessToken';
Dismissed Show dismissed Hide dismissed
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';
Dismissed Show dismissed Hide dismissed
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,70 @@
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 { AxiosResponse } from 'axios';
import QueryString from 'qs';
import { lastValueFrom, Observable } from 'rxjs';
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 API_BASE_URL: string;

constructor(
private readonly options: SchulconnexRestClientOptions,
private readonly httpService: HttpService,
private readonly oauthAdapterService: OauthAdapterService
) {
this.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.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.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 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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { UUID } from 'bson';
import { Factory } from 'fishery';
import { SanisGroupRole, SanisGroupType, SanisResponse, SanisRole } from '../response';

export const schulconnexResponseFactory = Factory.define<SanisResponse>(() => {
return {
pid: 'aef1f4fd-c323-466e-962b-a84354c0e713',
person: {
name: {
vorname: 'Hans',
familienname: 'Peter',
},
geburt: {
datum: '2023-11-17',
},
},
personenkontexte: [
{
id: new UUID().toString(),
rolle: SanisRole.LEIT,
organisation: {
id: new UUID('df66c8e6-cfac-40f7-b35b-0da5d8ee680e').toString(),
name: 'schoolName',
kennung: 'Kennung',
anschrift: {
ort: 'Hannover',
},
},
gruppen: [
{
gruppe: {
id: new UUID().toString(),
bezeichnung: 'bezeichnung',
typ: SanisGroupType.CLASS,
},
gruppenzugehoerigkeit: {
rollen: [SanisGroupRole.TEACHER],
},
sonstige_gruppenzugehoerige: [
{
rollen: [SanisGroupRole.STUDENT],
ktid: 'ktid',
},
],
},
],
},
],
};
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ExecutionContext, INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { Account, User } from '@shared/domain/entity';
import { Permission, RoleName } from '@shared/domain/interface';
import { accountFactory, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing';
import { accountFactory, mapUserToCurrentUser, roleFactory, schoolEntityFactory, userFactory } from '@shared/testing';
import {
AccountByIdBodyParams,
AccountSearchQueryParams,
Expand Down Expand Up @@ -39,7 +39,7 @@ describe('Account Controller (API)', () => {
const defaultPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu';

const setup = async () => {
const school = schoolFactory.buildWithId();
const school = schoolEntityFactory.buildWithId();

const adminRoles = roleFactory.build({
name: RoleName.ADMINISTRATOR,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Account, Role, SchoolEntity, User } from '@shared/domain/entity';

import { Permission, RoleName } from '@shared/domain/interface';
import { EntityId } from '@shared/domain/types';
import { accountFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing';
import { accountFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing';
import bcrypt from 'bcryptjs';
import { LegacyLogger } from '../../../core/logger';
import { AccountRepo } from '../repo/account.repo';
Expand Down Expand Up @@ -149,7 +149,7 @@ describe('AccountDbService', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date(2020, 1, 1));

mockSchool = schoolFactory.buildWithId();
mockSchool = schoolEntityFactory.buildWithId();

mockTeacherUser = userFactory.buildWithId({
school: mockSchool,
Expand Down
Loading
Loading