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

56 - Add use cases to get the current authenticated user and log out #57

Merged
merged 11 commits into from
May 11, 2023
5 changes: 2 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/auth/domain/repositories/IAuthRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface IAuthRepository {
logout(): Promise<void>;
}
14 changes: 14 additions & 0 deletions src/auth/domain/useCases/Logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { UseCase } from '../../../core/domain/useCases/UseCase';
import { IAuthRepository } from '../repositories/IAuthRepository';

export class Logout implements UseCase<void> {
private authRepository: IAuthRepository;

constructor(logoutRepository: IAuthRepository) {
this.authRepository = logoutRepository;
}

async execute(): Promise<void> {
await this.authRepository.logout();
}
}
6 changes: 6 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { AuthRepository } from './infra/repositories/AuthRepository';
import { Logout } from './domain/useCases/Logout';

const logout = new Logout(new AuthRepository());

export { logout };
12 changes: 12 additions & 0 deletions src/auth/infra/repositories/AuthRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ApiRepository } from '../../../core/infra/repositories/ApiRepository';
import { IAuthRepository } from '../../domain/repositories/IAuthRepository';

export class AuthRepository extends ApiRepository implements IAuthRepository {
public async logout(): Promise<void> {
return this.doPost('/logout', '')
.then(() => undefined)
.catch((error) => {
throw error;
});
}
}
10 changes: 4 additions & 6 deletions src/core/domain/repositories/ReadError.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
export class ReadError extends Error {
import { RepositoryError } from './RepositoryError';

export class ReadError extends RepositoryError {
constructor(reason?: string) {
let message = 'There was an error when reading the resource.';
if (reason) {
message += ` Reason was: ${reason}`;
}
super(message);
super('There was an error when reading the resource.', reason);
}
}
8 changes: 8 additions & 0 deletions src/core/domain/repositories/RepositoryError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export abstract class RepositoryError extends Error {
constructor(message: string, reason?: string) {
if (reason) {
message += ` Reason was: ${reason}`;
}
super(message);
}
}
7 changes: 7 additions & 0 deletions src/core/domain/repositories/WriteError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { RepositoryError } from './RepositoryError';

export class WriteError extends RepositoryError {
constructor(reason?: string) {
super('There was an error when writing the resource.', reason);
}
}
2 changes: 2 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { ReadError } from './domain/repositories/ReadError';
export { WriteError } from './domain/repositories/WriteError';
export { ApiConfig } from './infra/repositories/ApiConfig';
7 changes: 7 additions & 0 deletions src/core/infra/repositories/ApiConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class ApiConfig {
static DATAVERSE_API_URL: string;

static init(dataverseApiUrl: string) {
this.DATAVERSE_API_URL = dataverseApiUrl;
}
}
38 changes: 38 additions & 0 deletions src/core/infra/repositories/ApiRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import axios, { AxiosResponse } from 'axios';
import { ApiConfig } from './ApiConfig';
import { ReadError } from '../../domain/repositories/ReadError';
import { WriteError } from '../../domain/repositories/WriteError';

/* TODO:
We set { withCredentials: true } to send the JSESSIONID cookie in the requests for API authentication.
This is required, along with the session auth feature flag enabled in the backend, to be able to authenticate using the JSESSIONID cookie.
Auth mechanisms like this must be configurable to set the one that fits the particular use case of js-dataverse. (For the SPA MVP, it is the session cookie API auth).
For 2.0.0, we must also support API key auth to be backwards compatible and support use cases other than SPA MVP.
*/
export abstract class ApiRepository {
public async doGet(apiEndpoint: string): Promise<AxiosResponse> {
return await axios
.get(this.buildRequestUrl(apiEndpoint), { withCredentials: true })
.then((response) => response)
.catch((error) => {
throw new ReadError(
`[${error.response.status}]${error.response.data ? ` ${error.response.data.message}` : ''}`,
);
});
}

public async doPost(apiEndpoint: string, data: string | object): Promise<AxiosResponse> {
return await axios
.post(this.buildRequestUrl(apiEndpoint), JSON.stringify(data), { withCredentials: true })
.then((response) => response)
.catch((error) => {
throw new WriteError(
`[${error.response.status}]${error.response.data ? ` ${error.response.data.message}` : ''}`,
);
});
}

private buildRequestUrl(apiEndpoint: string): string {
return `${ApiConfig.DATAVERSE_API_URL}${apiEndpoint}`;
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './core';
export * from './info';
export * from './users';
export * from './auth';
2 changes: 1 addition & 1 deletion src/info/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DataverseInfoRepository } from './infra/repositories/DataverseInfoRepository';
import { GetDataverseVersion } from './domain/useCases/GetDataverseVersion';

const getDataverseVersion = new GetDataverseVersion(new DataverseInfoRepository(process.env.DATAVERSE_API_URL));
const getDataverseVersion = new GetDataverseVersion(new DataverseInfoRepository());

export { getDataverseVersion };
13 changes: 5 additions & 8 deletions src/info/infra/repositories/DataverseInfoRepository.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { ApiRepository } from '../../../core/infra/repositories/ApiRepository';
import { IDataverseInfoRepository } from '../../domain/repositories/IDataverseInfoRepository';
import axios, { AxiosResponse } from 'axios';
import { ReadError } from '../../../core/domain/repositories/ReadError';
import { DataverseVersion } from '../../domain/models/DataverseVersion';
import { AxiosResponse } from 'axios';

export class DataverseInfoRepository implements IDataverseInfoRepository {
constructor(private readonly apiUrl: string) {}

export class DataverseInfoRepository extends ApiRepository implements IDataverseInfoRepository {
public async getDataverseVersion(): Promise<DataverseVersion> {
return await axios
.get(`${this.apiUrl}/info/version`)
return this.doGet('/info/version')
.then((response) => this.getVersionFromResponse(response))
.catch((error) => {
throw new ReadError(error.response.status + error.response.data ? ': ' + error.response.data.message : '');
throw error;
});
}

Expand Down
19 changes: 19 additions & 0 deletions src/users/domain/models/AuthenticatedUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface AuthenticatedUser {
id: number;
persistentUserId: string;
identifier: string;
displayName: string;
firstName: string;
lastName: string;
email: string;
superuser: boolean;
deactivated: boolean;
createdTime: string;
authenticationProviderId: string;
lastLoginTime?: string;
lastApiUseTime?: string;
deactivatedTime?: string;
affiliation?: string;
position?: string;
emailLastConfirmed?: string;
}
5 changes: 5 additions & 0 deletions src/users/domain/repositories/IUsersRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AuthenticatedUser } from '../models/AuthenticatedUser';

export interface IUsersRepository {
getCurrentAuthenticatedUser(): Promise<AuthenticatedUser>;
}
15 changes: 15 additions & 0 deletions src/users/domain/useCases/GetCurrentAuthenticatedUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { UseCase } from '../../../core/domain/useCases/UseCase';
import { IUsersRepository } from '../repositories/IUsersRepository';
import { AuthenticatedUser } from '../models/AuthenticatedUser';

export class GetCurrentAuthenticatedUser implements UseCase<AuthenticatedUser> {
private usersRepository: IUsersRepository;

constructor(usersRepository: IUsersRepository) {
this.usersRepository = usersRepository;
}

async execute(): Promise<AuthenticatedUser> {
return await this.usersRepository.getCurrentAuthenticatedUser();
}
}
7 changes: 7 additions & 0 deletions src/users/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { UsersRepository } from './infra/repositories/UsersRepository';
import { GetCurrentAuthenticatedUser } from './domain/useCases/GetCurrentAuthenticatedUser';

const getCurrentAuthenticatedUser = new GetCurrentAuthenticatedUser(new UsersRepository());

export { getCurrentAuthenticatedUser };
export { AuthenticatedUser } from './domain/models/AuthenticatedUser';
37 changes: 37 additions & 0 deletions src/users/infra/repositories/UsersRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ApiRepository } from '../../../core/infra/repositories/ApiRepository';
import { IUsersRepository } from '../../domain/repositories/IUsersRepository';
import { AuthenticatedUser } from '../../domain/models/AuthenticatedUser';
import { AxiosResponse } from 'axios';

export class UsersRepository extends ApiRepository implements IUsersRepository {
public async getCurrentAuthenticatedUser(): Promise<AuthenticatedUser> {
return this.doGet('/users/:me')
.then((response) => this.getAuthenticatedUserFromResponse(response))
.catch((error) => {
throw error;
});
}

private getAuthenticatedUserFromResponse(response: AxiosResponse): AuthenticatedUser {
const responseData = response.data.data;
return {
id: responseData.id,
persistentUserId: responseData.persistentUserId,
identifier: responseData.identifier,
displayName: responseData.displayName,
firstName: responseData.firstName,
lastName: responseData.lastName,
email: responseData.email,
superuser: responseData.superuser,
deactivated: responseData.deactivated,
createdTime: responseData.createdTime,
authenticationProviderId: responseData.authenticationProviderId,
lastLoginTime: responseData.lastLoginTime,
lastApiUseTime: responseData.lastApiUseTime,
deactivatedTime: responseData.deactivatedTime,
affiliation: responseData.affiliation,
position: responseData.position,
emailLastConfirmed: responseData.emailLastConfirmed,
};
}
}
5 changes: 4 additions & 1 deletion test/integration/info/DataverseInfoRepository.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { DataverseInfoRepository } from '../../../src/info/infra/repositories/DataverseInfoRepository';
import { ApiConfig } from '../../../src/core/infra/repositories/ApiConfig';

describe('getDataverseVersion', () => {
// TODO: Change API URL to another of an integration test oriented Dataverse instance
const sut: DataverseInfoRepository = new DataverseInfoRepository('https://demo.dataverse.org/api/v1');
const sut: DataverseInfoRepository = new DataverseInfoRepository();

ApiConfig.init('https://demo.dataverse.org/api/v1');

test('should return Dataverse version', async () => {
const actual = await sut.getDataverseVersion();
Expand Down
23 changes: 23 additions & 0 deletions test/integration/users/UsersRepository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { UsersRepository } from '../../../src/users/infra/repositories/UsersRepository';
import { ReadError } from '../../../src/core/domain/repositories/ReadError';
import { assert } from 'sinon';
import { ApiConfig } from '../../../src/core/infra/repositories/ApiConfig';

describe('getCurrentAuthenticatedUser', () => {
// TODO: Change API URL to another of an integration test oriented Dataverse instance
const sut: UsersRepository = new UsersRepository();

ApiConfig.init('https://demo.dataverse.org/api/v1');

test('should return error when authentication is not provided', async () => {
let error: ReadError = undefined;
await sut.getCurrentAuthenticatedUser().catch((e) => (error = e));

assert.match(
error.message,
'There was an error when reading the resource. Reason was: [400] User with token null not found.',
);
});

// TODO: Add more test cases once the integration test environment is established
});
19 changes: 19 additions & 0 deletions test/testHelpers/users/authenticatedUserHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { AuthenticatedUser } from '../../../src/users/domain/models/AuthenticatedUser';

export const createAuthenticatedUser = (): AuthenticatedUser => {
return {
id: 1,
persistentUserId: 'Test',
identifier: '@Test',
displayName: 'Test User',
firstName: 'Testname',
lastName: 'Testlastname',
email: 'testuser@dataverse.org',
superuser: false,
deactivated: false,
createdTime: '2023-04-14T11:52:28Z',
authenticationProviderId: 'builtin',
lastLoginTime: '2023-04-14T11:52:28Z',
lastApiUseTime: '2023-04-14T15:53:32Z',
};
};
50 changes: 50 additions & 0 deletions test/unit/auth/AuthRepository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { AuthRepository } from '../../../src/auth/infra/repositories/AuthRepository';
import { assert, createSandbox, SinonSandbox } from 'sinon';
import axios from 'axios';
import { expect } from 'chai';
import { ApiConfig } from '../../../src/core/infra/repositories/ApiConfig';
import { WriteError } from '../../../src/core/domain/repositories/WriteError';

describe('logout', () => {
const sandbox: SinonSandbox = createSandbox();
const sut: AuthRepository = new AuthRepository();
const testApiUrl = 'https://test.dataverse.org/api/v1';

ApiConfig.init(testApiUrl);

afterEach(() => {
sandbox.restore();
});

test('should not return error on successful response', async () => {
const testSuccessfulResponse = {
data: {
status: 'OK',
data: {
message: 'User logged out',
},
},
};
const axiosPostStub = sandbox.stub(axios, 'post').resolves(testSuccessfulResponse);

await sut.logout();

assert.calledWithExactly(axiosPostStub, `${testApiUrl}/logout`, JSON.stringify(''), { withCredentials: true });
});

test('should return error result on error response', async () => {
const testErrorResponse = {
response: {
status: 'ERROR',
message: 'test',
},
};
const axiosPostStub = sandbox.stub(axios, 'post').rejects(testErrorResponse);

let error: WriteError = undefined;
await sut.logout().catch((e) => (error = e));

assert.calledWithExactly(axiosPostStub, `${testApiUrl}/logout`, JSON.stringify(''), { withCredentials: true });
expect(error).to.be.instanceOf(Error);
});
});
Loading