Skip to content

Commit

Permalink
Merge pull request #57 from IQSS/53-users-me
Browse files Browse the repository at this point in the history
56 - Add use cases to get the current authenticated user and log out
  • Loading branch information
kcondon authored May 11, 2023
2 parents 8bcebb9 + 18243bc commit 88d3743
Show file tree
Hide file tree
Showing 28 changed files with 440 additions and 3,087 deletions.
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;
}
}
41 changes: 41 additions & 0 deletions src/core/infra/repositories/ApiRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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, withCredentials: boolean = false): Promise<AxiosResponse> {
return await axios
.get(this.buildRequestUrl(apiEndpoint), { withCredentials: withCredentials })
.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,
headers: { 'Content-Type': 'application/json' },
})
.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', true)
.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',
};
};
56 changes: 56 additions & 0 deletions test/unit/auth/AuthRepository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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,
headers: { 'Content-Type': 'application/json' },
});
});

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,
headers: { 'Content-Type': 'application/json' },
});
expect(error).to.be.instanceOf(Error);
});
});
Loading

0 comments on commit 88d3743

Please sign in to comment.