From 292ac6d514ccb52e6d7f5bab01ff419f43e63439 Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Tue, 20 Oct 2026 13:57:15 -0400 Subject: [PATCH] feat: add sign-up and ORCID-at-sign-up use cases Adds SignUp, GetOrcidSignUpAuthorizationUrl and ConnectOrcidForSignUp for POST /users/signup, GET /users/signup/orcid/authorize and POST /users/signup/orcid, which are called without authentication. AuthenticatedUser gains authenticatedOrcid. ReadError and WriteError now carry the HTTP statusCode and the server's fieldErrors, so clients can show validation errors next to form fields. --- CHANGELOG.md | 7 + docs/useCases.md | 106 +++++++++ src/core/domain/repositories/ReadError.ts | 6 +- .../domain/repositories/RepositoryError.ts | 12 +- src/core/domain/repositories/WriteError.ts | 6 +- src/core/index.ts | 1 + src/core/infra/repositories/ApiRepository.ts | 26 ++- .../domain/dtos/OrcidSignUpCallbackDTO.ts | 5 + src/users/domain/dtos/SignUpDTO.ts | 11 + src/users/domain/models/AuthenticatedUser.ts | 1 + .../domain/models/OrcidSignUpConnection.ts | 9 + src/users/domain/models/SignUpResult.ts | 10 + .../domain/repositories/IUsersRepository.ts | 7 + .../domain/useCases/ConnectOrcidForSignUp.ts | 24 ++ .../GetOrcidSignUpAuthorizationUrl.ts | 22 ++ src/users/domain/useCases/SignUp.ts | 24 ++ src/users/index.ts | 19 +- .../infra/repositories/UsersRepository.ts | 52 ++++- .../orcidSignUpConnectionTransformers.ts | 17 ++ test/environment/docker-compose.yml | 2 + .../integration/users/UsersRepository.test.ts | 129 ++++++++++- .../testHelpers/users/builtinUserApiHelper.ts | 12 + test/unit/core/RepositoryError.test.ts | 26 +++ test/unit/users/ConnectOrcidForSignUp.test.ts | 35 +++ .../GetOrcidSignUpAuthorizationUrl.test.ts | 30 +++ test/unit/users/SignUp.test.ts | 35 +++ test/unit/users/UsersRepository.test.ts | 214 +++++++++++++++++- 27 files changed, 830 insertions(+), 18 deletions(-) create mode 100644 src/users/domain/dtos/OrcidSignUpCallbackDTO.ts create mode 100644 src/users/domain/dtos/SignUpDTO.ts create mode 100644 src/users/domain/models/OrcidSignUpConnection.ts create mode 100644 src/users/domain/models/SignUpResult.ts create mode 100644 src/users/domain/useCases/ConnectOrcidForSignUp.ts create mode 100644 src/users/domain/useCases/GetOrcidSignUpAuthorizationUrl.ts create mode 100644 src/users/domain/useCases/SignUp.ts create mode 100644 src/users/infra/repositories/transformers/orcidSignUpConnectionTransformers.ts create mode 100644 test/unit/core/RepositoryError.test.ts create mode 100644 test/unit/users/ConnectOrcidForSignUp.test.ts create mode 100644 test/unit/users/GetOrcidSignUpAuthorizationUrl.test.ts create mode 100644 test/unit/users/SignUp.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d822c2..a7b2c8f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,15 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added +- Users: Added `signUp` use case for creating a builtin user account without authentication (`POST /users/signup`). +- Users: Added `getOrcidSignUpAuthorizationUrl` and `connectOrcidForSignUp` use cases for authenticating an ORCID iD while signing up. +- Users: Added optional `authenticatedOrcid` to `AuthenticatedUser`. +- Core: `ReadError` and `WriteError` now expose the response's `statusCode` and, when the API returns them, per-field `fieldErrors`. + ### Changed +- Core: `ApiRepository.doPost` accepts an optional `authRequired` argument (default `true`) to send a request without the configured credentials. + ### Fixed ### Removed diff --git a/docs/useCases.md b/docs/useCases.md index 64a2c8d6..72ef6fbe 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -109,10 +109,13 @@ The different use cases currently available in the package are classified below, - [Users read use cases](#users-read-use-cases) - [Get Current Authenticated User](#get-current-authenticated-user) - [Get Current API Token](#get-current-api-token) + - [Get ORCID Sign-Up Authorization URL](#get-orcid-sign-up-authorization-url) - [Users write use cases](#users-write-use-cases) - [Delete Current API Token](#delete-current-api-token) - [Recreate Current API Token](#recreate-current-api-token) - [Register User](#register-user) + - [Sign Up](#sign-up) + - [Connect ORCID For Sign-Up](#connect-orcid-for-sign-up) - [Roles](#Roles) - [Roles read use cases](#roles-read-use-cases) - [Get User Selectable Roles](#get-user-selectable-roles) @@ -2747,6 +2750,8 @@ There is a third optional parameter called `datasetType` which will include addi Returns the current [AuthenticatedUser](../src/users/domain/models/AuthenticatedUser.ts) corresponding to the authentication mechanism provided through `ApiConfig`. +The `authenticatedOrcid` property holds the URL of the ORCID iD the user has authenticated and linked to their account, if any. + ##### Example call: ```typescript @@ -2783,6 +2788,32 @@ getCurrentApiToken.execute().then((apiTokenInfo: ApiTokenInfo) => { _See [use case](../src/users/domain/useCases/GetCurrentApiToken.ts) implementation_. +#### Get ORCID Sign-Up Authorization URL + +Returns the ORCID authorization URL that sends a new user to ORCID to authenticate their ORCID iD while signing up. Send the user's browser to this URL; after they sign in, ORCID redirects back to `redirectUri` with `code` and `state` query parameters, which are then passed to [Connect ORCID For Sign-Up](#connect-orcid-for-sign-up). + +No credentials are sent, so this works for anonymous users. The installation needs an ORCID OAuth2 provider (it can be disabled for login), and `redirectUri` must be listed in its `dataverse.api.signup.orcid-redirect-uris` setting. + +##### Example call: + +```typescript +import { getOrcidSignUpAuthorizationUrl } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const redirectUri = 'https://dataverse.example.edu/spa/sign-up' + +getOrcidSignUpAuthorizationUrl.execute(redirectUri).then((authorizationUrl: string) => { + window.location.assign(authorizationUrl) +}) + +/* ... */ +``` + +_See [use case](../src/users/domain/useCases/GetOrcidSignUpAuthorizationUrl.ts) implementation_. + +The use case throws a `ReadError` with `statusCode` 404 when no ORCID provider is configured, and 400 when the redirect URI is not allowed. + ### Users write use cases ### Delete Current API Token @@ -2851,6 +2882,81 @@ registerUser.execute(userDTO) _See [use case](../src/users/domain/useCases/RegisterUser.ts) implementation_. +### Sign Up + +Creates a new builtin user account, given a [SignUpDTO](../src/users/domain/dtos/SignUpDTO.ts). No credentials are sent, so this works for anonymous users. The installation must enable the `api-builtin-user-signup` feature flag and allow sign-up (`:AllowSignUp`). + +To save an authenticated ORCID iD to the new account, include the `orcidToken` returned by [Connect ORCID For Sign-Up](#connect-orcid-for-sign-up). + +##### Example call: + +```typescript +import { signUp } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const signUpDTO: SignUpDTO = { + username: 'alovelace', + firstName: 'Ada', + lastName: 'Lovelace', + emailAddress: 'ada@example.edu', + password: 'Str0ngPassw0rd', + affiliation: 'Harvard University', + termsAccepted: true +} + +signUp + .execute(signUpDTO) + .then((result: SignUpResult) => { + if (result.status === 'created') { + /* the account exists; result.identifier is e.g. '@alovelace' */ + } else { + /* status 'pending': the installation sent a verification email first */ + } + }) + .catch((error: WriteError) => { + /* error.fieldErrors maps invalid fields to messages, e.g. { username: 'This username is already taken.' } */ + }) + +/* ... */ +``` + +_See [use case](../src/users/domain/useCases/SignUp.ts) implementation_. + +The [SignUpResult](../src/users/domain/models/SignUpResult.ts) is `{ status: 'created', identifier }` when the account was created right away, or `{ status: 'pending' }` when the installation requires email verification before creating it. + +### Connect ORCID For Sign-Up + +Exchanges the `code` and `state` that ORCID returns to the redirect URI for the user's authenticated ORCID iD, given an [OrcidSignUpCallbackDTO](../src/users/domain/dtos/OrcidSignUpCallbackDTO.ts). Use the same `redirectUri` that was passed to [Get ORCID Sign-Up Authorization URL](#get-orcid-sign-up-authorization-url). No credentials are sent. + +Returns an [OrcidSignUpConnection](../src/users/domain/models/OrcidSignUpConnection.ts) with the ORCID iD, any details the user shares on their ORCID record (useful to pre-fill a sign-up form), and an `orcidToken` to include in [Sign Up](#sign-up). The token is valid for 30 minutes. + +##### Example call: + +```typescript +import { connectOrcidForSignUp } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const params = new URLSearchParams(window.location.search) + +connectOrcidForSignUp + .execute({ + code: params.get('code') as string, + state: params.get('state') as string, + redirectUri: 'https://dataverse.example.edu/spa/sign-up' + }) + .then((connection: OrcidSignUpConnection) => { + /* connection.orcid, connection.firstName, ..., connection.orcidToken */ + }) + +/* ... */ +``` + +_See [use case](../src/users/domain/useCases/ConnectOrcidForSignUp.ts) implementation_. + +The use case throws a `WriteError` with `statusCode` 409 when the ORCID iD is already linked to another account, and 400 when the state is invalid or expired. + ## Roles ### Get User Selectable Roles diff --git a/src/core/domain/repositories/ReadError.ts b/src/core/domain/repositories/ReadError.ts index 67028c8b..f3e40509 100644 --- a/src/core/domain/repositories/ReadError.ts +++ b/src/core/domain/repositories/ReadError.ts @@ -1,7 +1,7 @@ -import { RepositoryError } from './RepositoryError' +import { RepositoryError, RepositoryErrorDetails } from './RepositoryError' export class ReadError extends RepositoryError { - constructor(reason?: string) { - super('There was an error when reading the resource.', reason) + constructor(reason?: string, details?: RepositoryErrorDetails) { + super('There was an error when reading the resource.', reason, details) } } diff --git a/src/core/domain/repositories/RepositoryError.ts b/src/core/domain/repositories/RepositoryError.ts index 3a5495a6..d29bebcf 100644 --- a/src/core/domain/repositories/RepositoryError.ts +++ b/src/core/domain/repositories/RepositoryError.ts @@ -1,8 +1,18 @@ +export interface RepositoryErrorDetails { + statusCode?: number + fieldErrors?: Record +} + export abstract class RepositoryError extends Error { - constructor(message: string, reason?: string) { + readonly statusCode?: number + readonly fieldErrors?: Record + + constructor(message: string, reason?: string, details?: RepositoryErrorDetails) { if (reason) { message += ` Reason was: ${reason}` } super(message) + this.statusCode = details?.statusCode + this.fieldErrors = details?.fieldErrors } } diff --git a/src/core/domain/repositories/WriteError.ts b/src/core/domain/repositories/WriteError.ts index 2d00be6c..9661f89b 100644 --- a/src/core/domain/repositories/WriteError.ts +++ b/src/core/domain/repositories/WriteError.ts @@ -1,7 +1,7 @@ -import { RepositoryError } from './RepositoryError' +import { RepositoryError, RepositoryErrorDetails } from './RepositoryError' export class WriteError extends RepositoryError { - constructor(reason?: string) { - super('There was an error when writing the resource.', reason) + constructor(reason?: string, details?: RepositoryErrorDetails) { + super('There was an error when writing the resource.', reason, details) } } diff --git a/src/core/index.ts b/src/core/index.ts index ddd54a8a..a525bacc 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,5 +1,6 @@ export { ReadError } from './domain/repositories/ReadError' export { WriteError } from './domain/repositories/WriteError' +export { RepositoryErrorDetails } from './domain/repositories/RepositoryError' export { ApiConfig } from './infra/repositories/ApiConfig' export { DvObjectOwnerNode, DvObjectType } from './domain/models/DvObjectOwnerNode' export { PublicationStatus } from './domain/models/PublicationStatus' diff --git a/src/core/infra/repositories/ApiRepository.ts b/src/core/infra/repositories/ApiRepository.ts index 7a70c17c..3c65fd22 100644 --- a/src/core/infra/repositories/ApiRepository.ts +++ b/src/core/infra/repositories/ApiRepository.ts @@ -1,6 +1,7 @@ import axios, { AxiosResponse } from 'axios' import { ReadError } from '../../domain/repositories/ReadError' import { WriteError } from '../../domain/repositories/WriteError' +import { RepositoryErrorDetails } from '../../domain/repositories/RepositoryError' import { buildRequestConfig, buildRequestUrl } from './apiConfigBuilders' import { ApiConstants } from './ApiConstants' @@ -14,7 +15,7 @@ export abstract class ApiRepository { .get(buildRequestUrl(apiEndpoint), buildRequestConfig(authRequired, queryParams)) .then((response) => response) .catch((error) => { - throw new ReadError(this.buildErrorMessage(error)) + throw new ReadError(this.buildErrorMessage(error), this.buildErrorDetails(error)) }) } @@ -22,9 +23,10 @@ export abstract class ApiRepository { apiEndpoint: string, data: string | object | boolean, queryParams: object = {}, - contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON + contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON, + authRequired = true ): Promise { - return await this.doRequest('post', apiEndpoint, data, queryParams, contentType) + return await this.doRequest('post', apiEndpoint, data, queryParams, contentType, authRequired) } public async doPut( @@ -41,7 +43,7 @@ export abstract class ApiRepository { .delete(buildRequestUrl(apiEndpoint), buildRequestConfig(true, queryParams)) .then((response) => response) .catch((error) => { - throw new WriteError(this.buildErrorMessage(error)) + throw new WriteError(this.buildErrorMessage(error), this.buildErrorDetails(error)) }) } @@ -59,6 +61,15 @@ export abstract class ApiRepository { : `/${resourceName}${operationSegment}` } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private buildErrorDetails(error: any): RepositoryErrorDetails { + const fieldErrors = error?.response?.data?.fieldErrors + return { + statusCode: error?.response?.status, + fieldErrors: fieldErrors && typeof fieldErrors === 'object' ? fieldErrors : undefined + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any private buildErrorMessage(error: any): string { const status = @@ -72,7 +83,8 @@ export abstract class ApiRepository { apiEndpoint: string, data: string | object | boolean, queryParams: object = {}, - contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON + contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON, + authRequired = true ): Promise { let requestData = data @@ -83,13 +95,13 @@ export abstract class ApiRepository { } const requestUrl = buildRequestUrl(apiEndpoint) - const requestConfig = buildRequestConfig(true, queryParams, contentType) + const requestConfig = buildRequestConfig(authRequired, queryParams, contentType) try { const response = await axios[method](requestUrl, requestData, requestConfig) return response } catch (error) { - throw new WriteError(this.buildErrorMessage(error)) + throw new WriteError(this.buildErrorMessage(error), this.buildErrorDetails(error)) } } } diff --git a/src/users/domain/dtos/OrcidSignUpCallbackDTO.ts b/src/users/domain/dtos/OrcidSignUpCallbackDTO.ts new file mode 100644 index 00000000..b7c78250 --- /dev/null +++ b/src/users/domain/dtos/OrcidSignUpCallbackDTO.ts @@ -0,0 +1,5 @@ +export interface OrcidSignUpCallbackDTO { + code: string + state: string + redirectUri: string +} diff --git a/src/users/domain/dtos/SignUpDTO.ts b/src/users/domain/dtos/SignUpDTO.ts new file mode 100644 index 00000000..b896c140 --- /dev/null +++ b/src/users/domain/dtos/SignUpDTO.ts @@ -0,0 +1,11 @@ +export interface SignUpDTO { + username: string + firstName: string + lastName: string + emailAddress: string + password: string + affiliation?: string + position?: string + termsAccepted: boolean + orcidToken?: string +} diff --git a/src/users/domain/models/AuthenticatedUser.ts b/src/users/domain/models/AuthenticatedUser.ts index 1fd0c475..b73fb10c 100644 --- a/src/users/domain/models/AuthenticatedUser.ts +++ b/src/users/domain/models/AuthenticatedUser.ts @@ -16,4 +16,5 @@ export interface AuthenticatedUser { affiliation?: string position?: string emailLastConfirmed?: string + authenticatedOrcid?: string } diff --git a/src/users/domain/models/OrcidSignUpConnection.ts b/src/users/domain/models/OrcidSignUpConnection.ts new file mode 100644 index 00000000..69c126ad --- /dev/null +++ b/src/users/domain/models/OrcidSignUpConnection.ts @@ -0,0 +1,9 @@ +export interface OrcidSignUpConnection { + orcid: string + orcidToken: string + firstName?: string + lastName?: string + emailAddress?: string + affiliation?: string + position?: string +} diff --git a/src/users/domain/models/SignUpResult.ts b/src/users/domain/models/SignUpResult.ts new file mode 100644 index 00000000..ad484785 --- /dev/null +++ b/src/users/domain/models/SignUpResult.ts @@ -0,0 +1,10 @@ +export type SignUpResult = SignUpCreatedResult | SignUpPendingResult + +export interface SignUpCreatedResult { + status: 'created' + identifier: string +} + +export interface SignUpPendingResult { + status: 'pending' +} diff --git a/src/users/domain/repositories/IUsersRepository.ts b/src/users/domain/repositories/IUsersRepository.ts index e2b649d7..d33c29af 100644 --- a/src/users/domain/repositories/IUsersRepository.ts +++ b/src/users/domain/repositories/IUsersRepository.ts @@ -1,6 +1,10 @@ import { ApiTokenInfo } from '../models/ApiTokenInfo' import { AuthenticatedUser } from '../models/AuthenticatedUser' import { UserDTO } from '../dtos/UserDTO' +import { SignUpDTO } from '../dtos/SignUpDTO' +import { OrcidSignUpCallbackDTO } from '../dtos/OrcidSignUpCallbackDTO' +import { SignUpResult } from '../models/SignUpResult' +import { OrcidSignUpConnection } from '../models/OrcidSignUpConnection' export interface IUsersRepository { getCurrentAuthenticatedUser(): Promise @@ -8,4 +12,7 @@ export interface IUsersRepository { getCurrentApiToken(): Promise deleteCurrentApiToken(): Promise registerUser(userDTO: UserDTO): Promise + signUp(signUpDTO: SignUpDTO): Promise + getOrcidSignUpAuthorizationUrl(redirectUri: string): Promise + connectOrcidForSignUp(callback: OrcidSignUpCallbackDTO): Promise } diff --git a/src/users/domain/useCases/ConnectOrcidForSignUp.ts b/src/users/domain/useCases/ConnectOrcidForSignUp.ts new file mode 100644 index 00000000..c2701c4a --- /dev/null +++ b/src/users/domain/useCases/ConnectOrcidForSignUp.ts @@ -0,0 +1,24 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { OrcidSignUpCallbackDTO } from '../dtos/OrcidSignUpCallbackDTO' +import { OrcidSignUpConnection } from '../models/OrcidSignUpConnection' +import { IUsersRepository } from '../repositories/IUsersRepository' + +export class ConnectOrcidForSignUp implements UseCase { + private usersRepository: IUsersRepository + + constructor(usersRepository: IUsersRepository) { + this.usersRepository = usersRepository + } + + /** + * Exchanges the code and state that ORCID returns to the redirect URI for the user's authenticated ORCID iD. + * No authentication is used. + * + * @param {OrcidSignUpCallbackDTO} [callback] - The code and state from ORCID's redirect, and the same redirectUri used to get the authorization URL. + * @returns {Promise} - The ORCID iD, any details the user shares on ORCID, and an orcidToken to include when signing up. + * @throws {WriteError} - If the state is invalid or expired, ORCID rejects the code, or the ORCID iD is already linked to another account (statusCode 409). + */ + async execute(callback: OrcidSignUpCallbackDTO): Promise { + return await this.usersRepository.connectOrcidForSignUp(callback) + } +} diff --git a/src/users/domain/useCases/GetOrcidSignUpAuthorizationUrl.ts b/src/users/domain/useCases/GetOrcidSignUpAuthorizationUrl.ts new file mode 100644 index 00000000..20953a9a --- /dev/null +++ b/src/users/domain/useCases/GetOrcidSignUpAuthorizationUrl.ts @@ -0,0 +1,22 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IUsersRepository } from '../repositories/IUsersRepository' + +export class GetOrcidSignUpAuthorizationUrl implements UseCase { + private usersRepository: IUsersRepository + + constructor(usersRepository: IUsersRepository) { + this.usersRepository = usersRepository + } + + /** + * Returns the ORCID authorization URL that sends a new user to ORCID to authenticate their ORCID iD while signing up. + * No authentication is used. + * + * @param {string} [redirectUri] - The URL ORCID redirects back to. Must be allowed by the installation's dataverse.api.signup.orcid-redirect-uris setting. + * @returns {Promise} - The ORCID authorization URL. + * @throws {ReadError} - If ORCID is not configured, or the redirect URI is not allowed. + */ + async execute(redirectUri: string): Promise { + return await this.usersRepository.getOrcidSignUpAuthorizationUrl(redirectUri) + } +} diff --git a/src/users/domain/useCases/SignUp.ts b/src/users/domain/useCases/SignUp.ts new file mode 100644 index 00000000..8f47c907 --- /dev/null +++ b/src/users/domain/useCases/SignUp.ts @@ -0,0 +1,24 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { SignUpDTO } from '../dtos/SignUpDTO' +import { SignUpResult } from '../models/SignUpResult' +import { IUsersRepository } from '../repositories/IUsersRepository' + +export class SignUp implements UseCase { + private usersRepository: IUsersRepository + + constructor(usersRepository: IUsersRepository) { + this.usersRepository = usersRepository + } + + /** + * Signs up a new builtin user account. No authentication is used, so it works for anonymous users. + * Requires the api-builtin-user-signup feature flag on the Dataverse installation. + * + * @param {SignUpDTO} [signUpDTO] - The new user's details and password. Include orcidToken (from connectOrcidForSignUp) to save an authenticated ORCID iD to the account. + * @returns {Promise} - status 'created' with the new user's identifier when the account was created, or status 'pending' when the installation requires email verification before creating it. + * @throws {WriteError} - If the sign-up fails. Its fieldErrors property maps invalid fields to their error messages. + */ + async execute(signUpDTO: SignUpDTO): Promise { + return await this.usersRepository.signUp(signUpDTO) + } +} diff --git a/src/users/index.ts b/src/users/index.ts index e6d24914..5c83a9b0 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -4,6 +4,9 @@ import { RecreateCurrentApiToken } from './domain/useCases/RecreateCurrentApiTok import { GetCurrentApiToken } from './domain/useCases/GetCurrentApiToken' import { DeleteCurrentApiToken } from './domain/useCases/DeleteCurrentApiToken' import { RegisterUser } from './domain/useCases/RegisterUser' +import { SignUp } from './domain/useCases/SignUp' +import { GetOrcidSignUpAuthorizationUrl } from './domain/useCases/GetOrcidSignUpAuthorizationUrl' +import { ConnectOrcidForSignUp } from './domain/useCases/ConnectOrcidForSignUp' const usersRepository = new UsersRepository() @@ -12,14 +15,28 @@ const recreateCurrentApiToken = new RecreateCurrentApiToken(usersRepository) const getCurrentApiToken = new GetCurrentApiToken(usersRepository) const deleteCurrentApiToken = new DeleteCurrentApiToken(usersRepository) const registerUser = new RegisterUser(usersRepository) +const signUp = new SignUp(usersRepository) +const getOrcidSignUpAuthorizationUrl = new GetOrcidSignUpAuthorizationUrl(usersRepository) +const connectOrcidForSignUp = new ConnectOrcidForSignUp(usersRepository) export { getCurrentAuthenticatedUser, recreateCurrentApiToken, getCurrentApiToken, deleteCurrentApiToken, - registerUser + registerUser, + signUp, + getOrcidSignUpAuthorizationUrl, + connectOrcidForSignUp } export { AuthenticatedUser } from './domain/models/AuthenticatedUser' export { ApiTokenInfo } from './domain/models/ApiTokenInfo' export { UserDTO } from './domain/dtos/UserDTO' +export { SignUpDTO } from './domain/dtos/SignUpDTO' +export { OrcidSignUpCallbackDTO } from './domain/dtos/OrcidSignUpCallbackDTO' +export { + SignUpResult, + SignUpCreatedResult, + SignUpPendingResult +} from './domain/models/SignUpResult' +export { OrcidSignUpConnection } from './domain/models/OrcidSignUpConnection' diff --git a/src/users/infra/repositories/UsersRepository.ts b/src/users/infra/repositories/UsersRepository.ts index 476e713c..43767306 100644 --- a/src/users/infra/repositories/UsersRepository.ts +++ b/src/users/infra/repositories/UsersRepository.ts @@ -8,6 +8,12 @@ import { transformRecreateApiTokenResponseToApiTokenInfo } from './transformers/apiTokenInfoTransformers' import { UserDTO } from '../../domain/dtos/UserDTO' +import { SignUpDTO } from '../../domain/dtos/SignUpDTO' +import { OrcidSignUpCallbackDTO } from '../../domain/dtos/OrcidSignUpCallbackDTO' +import { SignUpResult } from '../../domain/models/SignUpResult' +import { OrcidSignUpConnection } from '../../domain/models/OrcidSignUpConnection' +import { ApiConstants } from '../../../core/infra/repositories/ApiConstants' +import { transformOrcidSignUpConnectionResponseToOrcidSignUpConnection } from './transformers/orcidSignUpConnectionTransformers' export class UsersRepository extends ApiRepository implements IUsersRepository { private readonly usersResourceName: string = 'users' @@ -52,6 +58,49 @@ export class UsersRepository extends ApiRepository implements IUsersRepository { }) } + public async signUp(signUpDTO: SignUpDTO): Promise { + return this.doPost( + `/${this.usersResourceName}/signup`, + signUpDTO, + {}, + ApiConstants.CONTENT_TYPE_APPLICATION_JSON, + false + ) + .then((response): SignUpResult => { + if (response.status === 202) { + return { status: 'pending' } + } + return { status: 'created', identifier: response.data.data.identifier } + }) + .catch((error) => { + throw error + }) + } + + public async getOrcidSignUpAuthorizationUrl(redirectUri: string): Promise { + return this.doGet(`/${this.usersResourceName}/signup/orcid/authorize`, false, { redirectUri }) + .then((response) => response.data.data.authorizationUrl) + .catch((error) => { + throw error + }) + } + + public async connectOrcidForSignUp( + callback: OrcidSignUpCallbackDTO + ): Promise { + return this.doPost( + `/${this.usersResourceName}/signup/orcid`, + callback, + {}, + ApiConstants.CONTENT_TYPE_APPLICATION_JSON, + false + ) + .then((response) => transformOrcidSignUpConnectionResponseToOrcidSignUpConnection(response)) + .catch((error) => { + throw error + }) + } + private getAuthenticatedUserFromResponse(response: AxiosResponse): AuthenticatedUser { const responseData = response.data.data return { @@ -71,7 +120,8 @@ export class UsersRepository extends ApiRepository implements IUsersRepository { deactivatedTime: responseData.deactivatedTime, affiliation: responseData.affiliation, position: responseData.position, - emailLastConfirmed: responseData.emailLastConfirmed + emailLastConfirmed: responseData.emailLastConfirmed, + authenticatedOrcid: responseData.authenticatedOrcid } } } diff --git a/src/users/infra/repositories/transformers/orcidSignUpConnectionTransformers.ts b/src/users/infra/repositories/transformers/orcidSignUpConnectionTransformers.ts new file mode 100644 index 00000000..a77546e4 --- /dev/null +++ b/src/users/infra/repositories/transformers/orcidSignUpConnectionTransformers.ts @@ -0,0 +1,17 @@ +import { AxiosResponse } from 'axios' +import { OrcidSignUpConnection } from '../../../domain/models/OrcidSignUpConnection' + +export const transformOrcidSignUpConnectionResponseToOrcidSignUpConnection = ( + response: AxiosResponse +): OrcidSignUpConnection => { + const data = response.data.data + return { + orcid: data.orcid, + orcidToken: data.orcidToken, + ...(data.firstName !== undefined && { firstName: data.firstName }), + ...(data.lastName !== undefined && { lastName: data.lastName }), + ...(data.emailAddress !== undefined && { emailAddress: data.emailAddress }), + ...(data.affiliation !== undefined && { affiliation: data.affiliation }), + ...(data.position !== undefined && { position: data.position }) + } +} diff --git a/test/environment/docker-compose.yml b/test/environment/docker-compose.yml index ad98d16b..1bccd93b 100644 --- a/test/environment/docker-compose.yml +++ b/test/environment/docker-compose.yml @@ -11,6 +11,8 @@ services: DATAVERSE_DB_HOST: postgres DATAVERSE_DB_PASSWORD: secret DATAVERSE_DB_USER: ${DATAVERSE_DB_USER} + DATAVERSE_FEATURE_API_BUILTIN_USER_SIGNUP: '1' + DATAVERSE_API_SIGNUP_RATE_LIMIT_PER_HOUR: '0' JVM_ARGS: -Ddataverse.files.storage-driver-id=file1 -Ddataverse.pid.providers=fake -Ddataverse.pid.default-provider=fake diff --git a/test/integration/users/UsersRepository.test.ts b/test/integration/users/UsersRepository.test.ts index e51f97c4..ada38767 100644 --- a/test/integration/users/UsersRepository.test.ts +++ b/test/integration/users/UsersRepository.test.ts @@ -4,8 +4,12 @@ import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' import { TestConstants } from '../../testHelpers/TestConstants' -import { ReadError, WriteError } from '../../../src' +import { ReadError, SignUpDTO, WriteError } from '../../../src' import { createApiTokenViaApi } from '../../testHelpers/users/apiTokenHelper' +import { + deleteAuthenticatedUserViaAdminApi, + getAuthenticatedUserViaAdminApi +} from '../../testHelpers/users/builtinUserApiHelper' describe('UsersRepository', () => { const sut: UsersRepository = new UsersRepository() @@ -93,4 +97,127 @@ describe('UsersRepository', () => { await expect(sut.deleteCurrentApiToken()).rejects.toThrow(errorExpected) }) }) + + describe('signUp', () => { + const uniqueSuffix = Date.now().toString(36) + const testSignUpDTO: SignUpDTO = { + username: `signup${uniqueSuffix}`, + firstName: 'Ada', + lastName: 'Lovelace', + emailAddress: `signup${uniqueSuffix}@mailinator.com`, + password: 'Str0ngPassw0rd1', + affiliation: 'Harvard University', + position: 'Researcher', + termsAccepted: true + } + + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + afterAll(async () => { + await deleteAuthenticatedUserViaAdminApi(testSignUpDTO.username).catch(() => undefined) + }) + + test('should create a builtin user without sending the configured credentials', async () => { + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY, 'invalidApiKey') + + const actual = await sut.signUp(testSignUpDTO) + + expect(actual).toEqual({ status: 'created', identifier: `@${testSignUpDTO.username}` }) + const createdUser = await getAuthenticatedUserViaAdminApi(testSignUpDTO.username) + expect(createdUser.authenticationProviderId).toBe('builtin') + expect(createdUser.email).toBe(testSignUpDTO.emailAddress) + expect(createdUser.affiliation).toBe('Harvard University') + expect(createdUser.authenticatedOrcid).toBeUndefined() + }) + + test('should return field errors when the username and email are already taken', async () => { + let error: WriteError | undefined = undefined + await sut.signUp(testSignUpDTO).catch((e) => (error = e)) + + expect(error).toBeInstanceOf(WriteError) + expect((error as unknown as WriteError).statusCode).toBe(400) + expect((error as unknown as WriteError).fieldErrors).toEqual( + expect.objectContaining({ + username: 'This username is already taken.', + emailAddress: 'This email address is already taken.' + }) + ) + }) + + test('should return field errors for invalid values', async () => { + let error: WriteError | undefined = undefined + await sut + .signUp({ + username: 'not valid!', + firstName: '', + lastName: '', + emailAddress: 'not-an-email', + password: 'x', + termsAccepted: false + }) + .catch((e) => (error = e)) + + expect(error).toBeInstanceOf(WriteError) + expect((error as unknown as WriteError).statusCode).toBe(400) + expect(Object.keys((error as unknown as WriteError).fieldErrors ?? {})).toEqual( + expect.arrayContaining([ + 'username', + 'firstName', + 'lastName', + 'emailAddress', + 'password', + 'termsAccepted' + ]) + ) + }) + + test('should reject an invalid orcidToken with an orcid field error', async () => { + let error: WriteError | undefined = undefined + await sut + .signUp({ + ...testSignUpDTO, + username: `${testSignUpDTO.username}x`, + emailAddress: `x${testSignUpDTO.emailAddress}`, + orcidToken: 'not-a-valid-token' + }) + .catch((e) => (error = e)) + + expect(error).toBeInstanceOf(WriteError) + expect((error as unknown as WriteError).statusCode).toBe(400) + expect((error as unknown as WriteError).fieldErrors).toEqual({ + orcid: 'Your ORCID connection has expired. Please connect your ORCID iD again.' + }) + }) + }) + + describe('ORCID at sign-up without an ORCID provider', () => { + const redirectUri = 'http://localhost:8000/spa/sign-up' + + test('getOrcidSignUpAuthorizationUrl should return a 404 ReadError', async () => { + let error: ReadError | undefined = undefined + await sut.getOrcidSignUpAuthorizationUrl(redirectUri).catch((e) => (error = e)) + + expect(error).toBeInstanceOf(ReadError) + expect((error as unknown as ReadError).statusCode).toBe(404) + expect((error as unknown as ReadError).message).toContain( + 'ORCID is not configured on this installation.' + ) + }) + + test('connectOrcidForSignUp should return a 404 WriteError', async () => { + let error: WriteError | undefined = undefined + await sut + .connectOrcidForSignUp({ code: 'code', state: 'state', redirectUri }) + .catch((e) => (error = e)) + + expect(error).toBeInstanceOf(WriteError) + expect((error as unknown as WriteError).statusCode).toBe(404) + }) + }) }) diff --git a/test/testHelpers/users/builtinUserApiHelper.ts b/test/testHelpers/users/builtinUserApiHelper.ts index d91e08a3..957705a0 100644 --- a/test/testHelpers/users/builtinUserApiHelper.ts +++ b/test/testHelpers/users/builtinUserApiHelper.ts @@ -25,3 +25,15 @@ export const createBuiltInUser = async (userName: string): Promise => { throw new Error(`Error while creating API token`) } } + +export const getAuthenticatedUserViaAdminApi = async ( + userIdentifier: string +): Promise> => { + return axios + .get(`${TestConstants.TEST_API_URL}/admin/authenticatedUsers/${userIdentifier}`) + .then((response) => response.data.data) +} + +export const deleteAuthenticatedUserViaAdminApi = async (userIdentifier: string): Promise => { + await axios.delete(`${TestConstants.TEST_API_URL}/admin/authenticatedUsers/${userIdentifier}`) +} diff --git a/test/unit/core/RepositoryError.test.ts b/test/unit/core/RepositoryError.test.ts new file mode 100644 index 00000000..6ab6e1b0 --- /dev/null +++ b/test/unit/core/RepositoryError.test.ts @@ -0,0 +1,26 @@ +import { ReadError, WriteError } from '../../../src' + +describe('RepositoryError details', () => { + test('keeps the existing message and exposes statusCode and fieldErrors', () => { + const error = new WriteError('[400] Invalid fields.', { + statusCode: 400, + fieldErrors: { username: 'This username is already taken.' } + }) + + expect(error.message).toBe( + 'There was an error when writing the resource. Reason was: [400] Invalid fields.' + ) + expect(error.statusCode).toBe(400) + expect(error.fieldErrors).toEqual({ username: 'This username is already taken.' }) + }) + + test('leaves the details undefined when none are given', () => { + const error = new ReadError('[404] Not found.') + + expect(error.message).toBe( + 'There was an error when reading the resource. Reason was: [404] Not found.' + ) + expect(error.statusCode).toBeUndefined() + expect(error.fieldErrors).toBeUndefined() + }) +}) diff --git a/test/unit/users/ConnectOrcidForSignUp.test.ts b/test/unit/users/ConnectOrcidForSignUp.test.ts new file mode 100644 index 00000000..0c2595ee --- /dev/null +++ b/test/unit/users/ConnectOrcidForSignUp.test.ts @@ -0,0 +1,35 @@ +import { ConnectOrcidForSignUp } from '../../../src/users/domain/useCases/ConnectOrcidForSignUp' +import { IUsersRepository } from '../../../src/users/domain/repositories/IUsersRepository' +import { OrcidSignUpCallbackDTO, OrcidSignUpConnection, WriteError } from '../../../src' + +describe('execute', () => { + const callback: OrcidSignUpCallbackDTO = { + code: 'orcid-code', + state: 'orcid-state', + redirectUri: 'http://127.0.0.1:8000/spa/sign-up' + } + + test('should return the ORCID connection on repository success', async () => { + const connection: OrcidSignUpConnection = { + orcid: 'https://orcid.org/0000-0002-1825-0097', + orcidToken: 'orcid-token', + firstName: 'Josiah' + } + const usersRepositoryStub: IUsersRepository = {} as IUsersRepository + usersRepositoryStub.connectOrcidForSignUp = jest.fn().mockResolvedValue(connection) + const sut = new ConnectOrcidForSignUp(usersRepositoryStub) + + const actual = await sut.execute(callback) + + expect(usersRepositoryStub.connectOrcidForSignUp).toHaveBeenCalledWith(callback) + expect(actual).toEqual(connection) + }) + + test('should return error result on repository error', async () => { + const usersRepositoryStub: IUsersRepository = {} as IUsersRepository + usersRepositoryStub.connectOrcidForSignUp = jest.fn().mockRejectedValue(new WriteError()) + const sut = new ConnectOrcidForSignUp(usersRepositoryStub) + + await expect(sut.execute(callback)).rejects.toThrow(WriteError) + }) +}) diff --git a/test/unit/users/GetOrcidSignUpAuthorizationUrl.test.ts b/test/unit/users/GetOrcidSignUpAuthorizationUrl.test.ts new file mode 100644 index 00000000..1991bfdb --- /dev/null +++ b/test/unit/users/GetOrcidSignUpAuthorizationUrl.test.ts @@ -0,0 +1,30 @@ +import { GetOrcidSignUpAuthorizationUrl } from '../../../src/users/domain/useCases/GetOrcidSignUpAuthorizationUrl' +import { IUsersRepository } from '../../../src/users/domain/repositories/IUsersRepository' +import { ReadError } from '../../../src' + +describe('execute', () => { + const redirectUri = 'http://127.0.0.1:8000/spa/sign-up' + + test('should return the authorization URL on repository success', async () => { + const usersRepositoryStub: IUsersRepository = {} as IUsersRepository + usersRepositoryStub.getOrcidSignUpAuthorizationUrl = jest + .fn() + .mockResolvedValue('https://orcid.org/oauth/authorize?client_id=APP-1') + const sut = new GetOrcidSignUpAuthorizationUrl(usersRepositoryStub) + + const actual = await sut.execute(redirectUri) + + expect(usersRepositoryStub.getOrcidSignUpAuthorizationUrl).toHaveBeenCalledWith(redirectUri) + expect(actual).toBe('https://orcid.org/oauth/authorize?client_id=APP-1') + }) + + test('should return error result on repository error', async () => { + const usersRepositoryStub: IUsersRepository = {} as IUsersRepository + usersRepositoryStub.getOrcidSignUpAuthorizationUrl = jest + .fn() + .mockRejectedValue(new ReadError()) + const sut = new GetOrcidSignUpAuthorizationUrl(usersRepositoryStub) + + await expect(sut.execute(redirectUri)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/users/SignUp.test.ts b/test/unit/users/SignUp.test.ts new file mode 100644 index 00000000..3e0effca --- /dev/null +++ b/test/unit/users/SignUp.test.ts @@ -0,0 +1,35 @@ +import { SignUp } from '../../../src/users/domain/useCases/SignUp' +import { IUsersRepository } from '../../../src/users/domain/repositories/IUsersRepository' +import { SignUpDTO, WriteError } from '../../../src' + +describe('execute', () => { + const testSignUpDTO: SignUpDTO = { + username: 'alovelace', + firstName: 'Ada', + lastName: 'Lovelace', + emailAddress: 'ada@example.edu', + password: 'Str0ngPassw0rd', + termsAccepted: true + } + + test('should return the sign-up result on repository success', async () => { + const usersRepositoryStub: IUsersRepository = {} as IUsersRepository + usersRepositoryStub.signUp = jest + .fn() + .mockResolvedValue({ status: 'created', identifier: '@alovelace' }) + const sut = new SignUp(usersRepositoryStub) + + const actual = await sut.execute(testSignUpDTO) + + expect(usersRepositoryStub.signUp).toHaveBeenCalledWith(testSignUpDTO) + expect(actual).toEqual({ status: 'created', identifier: '@alovelace' }) + }) + + test('should return error result on repository error', async () => { + const usersRepositoryStub: IUsersRepository = {} as IUsersRepository + usersRepositoryStub.signUp = jest.fn().mockRejectedValue(new WriteError()) + const sut = new SignUp(usersRepositoryStub) + + await expect(sut.execute(testSignUpDTO)).rejects.toThrow(WriteError) + }) +}) diff --git a/test/unit/users/UsersRepository.test.ts b/test/unit/users/UsersRepository.test.ts index f7117a5c..68e39c33 100644 --- a/test/unit/users/UsersRepository.test.ts +++ b/test/unit/users/UsersRepository.test.ts @@ -6,7 +6,7 @@ import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' import { TestConstants } from '../../testHelpers/TestConstants' -import { ReadError } from '../../../src' +import { ReadError, SignUpDTO, WriteError } from '../../../src' describe('getCurrentAuthenticatedUser', () => { const sut: UsersRepository = new UsersRepository() @@ -77,3 +77,215 @@ describe('getCurrentAuthenticatedUser', () => { expect(error).toBeInstanceOf(Error) }) }) + +describe('getCurrentAuthenticatedUser authenticatedOrcid', () => { + const sut: UsersRepository = new UsersRepository() + + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + TestConstants.TEST_DUMMY_API_KEY + ) + }) + + test('should map authenticatedOrcid when present', async () => { + const testAuthenticatedUser = createAuthenticatedUser() + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + status: 'OK', + data: { + ...testAuthenticatedUser, + authenticatedOrcid: 'https://orcid.org/0000-0002-1825-0097' + } + } + }) + + const actual = await sut.getCurrentAuthenticatedUser() + + expect(actual.authenticatedOrcid).toBe('https://orcid.org/0000-0002-1825-0097') + }) +}) + +describe('signUp', () => { + const sut: UsersRepository = new UsersRepository() + const testSignUpDTO: SignUpDTO = { + username: 'alovelace', + firstName: 'Ada', + lastName: 'Lovelace', + emailAddress: 'ada@example.edu', + password: 'Str0ngPassw0rd', + affiliation: 'Harvard University', + termsAccepted: true, + orcidToken: 'orcid-token' + } + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/users/signup` + + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + TestConstants.TEST_DUMMY_API_KEY + ) + }) + + test('should post the sign-up without credentials and return created', async () => { + jest.spyOn(axios, 'post').mockResolvedValue({ + status: 201, + data: { status: 'OK', data: { identifier: '@alovelace' } } + }) + + const actual = await sut.signUp(testSignUpDTO) + + expect(axios.post).toHaveBeenCalledWith( + expectedApiEndpoint, + JSON.stringify(testSignUpDTO), + TestConstants.TEST_EXPECTED_UNAUTHENTICATED_REQUEST_CONFIG + ) + expect(actual).toEqual({ status: 'created', identifier: '@alovelace' }) + }) + + test('should return pending when the response is 202 Accepted', async () => { + jest.spyOn(axios, 'post').mockResolvedValue({ + status: 202, + data: { status: 'OK', data: { message: 'Check your email.' } } + }) + + const actual = await sut.signUp(testSignUpDTO) + + expect(actual).toEqual({ status: 'pending' }) + }) + + test('should reject with a WriteError carrying statusCode and fieldErrors', async () => { + jest.spyOn(axios, 'post').mockRejectedValue({ + response: { + status: 400, + data: { + status: 'ERROR', + message: 'The provided fields are invalid for signing up a new user.', + fieldErrors: { username: 'This username is already taken.' } + } + } + }) + + let error: WriteError | undefined = undefined + await sut.signUp(testSignUpDTO).catch((e) => (error = e)) + + expect(error).toBeInstanceOf(WriteError) + expect((error as unknown as WriteError).statusCode).toBe(400) + expect((error as unknown as WriteError).fieldErrors).toEqual({ + username: 'This username is already taken.' + }) + expect((error as unknown as WriteError).message).toContain( + '[400] The provided fields are invalid for signing up a new user.' + ) + }) +}) + +describe('getOrcidSignUpAuthorizationUrl', () => { + const sut: UsersRepository = new UsersRepository() + const redirectUri = 'http://127.0.0.1:8000/spa/sign-up' + + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + TestConstants.TEST_DUMMY_API_KEY + ) + }) + + test('should request the authorization URL without credentials', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + status: 200, + data: { + status: 'OK', + data: { authorizationUrl: 'https://orcid.org/oauth/authorize?client_id=APP-1' } + } + }) + + const actual = await sut.getOrcidSignUpAuthorizationUrl(redirectUri) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/users/signup/orcid/authorize`, + { ...TestConstants.TEST_EXPECTED_UNAUTHENTICATED_REQUEST_CONFIG, params: { redirectUri } } + ) + expect(actual).toBe('https://orcid.org/oauth/authorize?client_id=APP-1') + }) + + test('should reject with a ReadError carrying the status code', async () => { + jest.spyOn(axios, 'get').mockRejectedValue({ + response: { + status: 404, + data: { status: 'ERROR', message: 'ORCID is not configured on this installation.' } + } + }) + + let error: ReadError | undefined = undefined + await sut.getOrcidSignUpAuthorizationUrl(redirectUri).catch((e) => (error = e)) + + expect(error).toBeInstanceOf(ReadError) + expect((error as unknown as ReadError).statusCode).toBe(404) + expect((error as unknown as ReadError).fieldErrors).toBeUndefined() + }) +}) + +describe('connectOrcidForSignUp', () => { + const sut: UsersRepository = new UsersRepository() + const callback = { + code: 'orcid-code', + state: 'orcid-state', + redirectUri: 'http://127.0.0.1:8000/spa/sign-up' + } + + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + TestConstants.TEST_DUMMY_API_KEY + ) + }) + + test('should post the ORCID callback without credentials and map the connection', async () => { + jest.spyOn(axios, 'post').mockResolvedValue({ + status: 200, + data: { + status: 'OK', + data: { + orcid: 'https://orcid.org/0000-0002-1825-0097', + orcidToken: 'orcid-token', + firstName: 'Josiah', + lastName: 'Carberry' + } + } + }) + + const actual = await sut.connectOrcidForSignUp(callback) + + expect(axios.post).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/users/signup/orcid`, + JSON.stringify(callback), + TestConstants.TEST_EXPECTED_UNAUTHENTICATED_REQUEST_CONFIG + ) + expect(actual).toEqual({ + orcid: 'https://orcid.org/0000-0002-1825-0097', + orcidToken: 'orcid-token', + firstName: 'Josiah', + lastName: 'Carberry' + }) + }) + + test('should reject with a 409 WriteError when the ORCID iD is already linked', async () => { + jest.spyOn(axios, 'post').mockRejectedValue({ + response: { + status: 409, + data: { status: 'ERROR', message: 'This ORCID iD is already linked to another account.' } + } + }) + + let error: WriteError | undefined = undefined + await sut.connectOrcidForSignUp(callback).catch((e) => (error = e)) + + expect(error).toBeInstanceOf(WriteError) + expect((error as unknown as WriteError).statusCode).toBe(409) + }) +})